Skip to main content

native_theme/
presets.rs

1//! Bundled theme presets and TOML serialization API.
2//!
3//! Provides 16 user-facing built-in presets embedded at compile time:
4//! 2 core platform (kde-breeze, adwaita), 4 platform (windows-11,
5//! macos-sonoma, material, ios), and 10 community (Catppuccin 4 flavors,
6//! Nord, Dracula, Gruvbox, Solarized, Tokyo Night, One Dark), plus
7//! 4 internal live presets (geometry-only, used by the OS-first pipeline)
8//! and functions for loading themes from TOML strings and files.
9
10use crate::{Error, NativeTheme, Result};
11use std::path::Path;
12
13// Embed preset TOML files at compile time
14const KDE_BREEZE_TOML: &str = include_str!("presets/kde-breeze.toml");
15const ADWAITA_TOML: &str = include_str!("presets/adwaita.toml");
16const WINDOWS_11_TOML: &str = include_str!("presets/windows-11.toml");
17const MACOS_SONOMA_TOML: &str = include_str!("presets/macos-sonoma.toml");
18const MATERIAL_TOML: &str = include_str!("presets/material.toml");
19const IOS_TOML: &str = include_str!("presets/ios.toml");
20const CATPPUCCIN_LATTE_TOML: &str = include_str!("presets/catppuccin-latte.toml");
21const CATPPUCCIN_FRAPPE_TOML: &str = include_str!("presets/catppuccin-frappe.toml");
22const CATPPUCCIN_MACCHIATO_TOML: &str = include_str!("presets/catppuccin-macchiato.toml");
23const CATPPUCCIN_MOCHA_TOML: &str = include_str!("presets/catppuccin-mocha.toml");
24const NORD_TOML: &str = include_str!("presets/nord.toml");
25const DRACULA_TOML: &str = include_str!("presets/dracula.toml");
26const GRUVBOX_TOML: &str = include_str!("presets/gruvbox.toml");
27const SOLARIZED_TOML: &str = include_str!("presets/solarized.toml");
28const TOKYO_NIGHT_TOML: &str = include_str!("presets/tokyo-night.toml");
29const ONE_DARK_TOML: &str = include_str!("presets/one-dark.toml");
30
31// Live presets: geometry/metrics only (internal, not user-selectable)
32const KDE_BREEZE_LIVE_TOML: &str = include_str!("presets/kde-breeze-live.toml");
33const ADWAITA_LIVE_TOML: &str = include_str!("presets/adwaita-live.toml");
34const MACOS_SONOMA_LIVE_TOML: &str = include_str!("presets/macos-sonoma-live.toml");
35const WINDOWS_11_LIVE_TOML: &str = include_str!("presets/windows-11-live.toml");
36
37/// All available user-facing preset names (excludes internal live presets).
38const PRESET_NAMES: &[&str] = &[
39    "kde-breeze",
40    "adwaita",
41    "windows-11",
42    "macos-sonoma",
43    "material",
44    "ios",
45    "catppuccin-latte",
46    "catppuccin-frappe",
47    "catppuccin-macchiato",
48    "catppuccin-mocha",
49    "nord",
50    "dracula",
51    "gruvbox",
52    "solarized",
53    "tokyo-night",
54    "one-dark",
55];
56
57pub(crate) fn preset(name: &str) -> Result<NativeTheme> {
58    let toml_str = match name {
59        "kde-breeze" => KDE_BREEZE_TOML,
60        "adwaita" => ADWAITA_TOML,
61        "windows-11" => WINDOWS_11_TOML,
62        "macos-sonoma" => MACOS_SONOMA_TOML,
63        "material" => MATERIAL_TOML,
64        "ios" => IOS_TOML,
65        "catppuccin-latte" => CATPPUCCIN_LATTE_TOML,
66        "catppuccin-frappe" => CATPPUCCIN_FRAPPE_TOML,
67        "catppuccin-macchiato" => CATPPUCCIN_MACCHIATO_TOML,
68        "catppuccin-mocha" => CATPPUCCIN_MOCHA_TOML,
69        "nord" => NORD_TOML,
70        "dracula" => DRACULA_TOML,
71        "gruvbox" => GRUVBOX_TOML,
72        "solarized" => SOLARIZED_TOML,
73        "tokyo-night" => TOKYO_NIGHT_TOML,
74        "one-dark" => ONE_DARK_TOML,
75        // Internal live presets (not in PRESET_NAMES but loadable)
76        "kde-breeze-live" => KDE_BREEZE_LIVE_TOML,
77        "adwaita-live" => ADWAITA_LIVE_TOML,
78        "macos-sonoma-live" => MACOS_SONOMA_LIVE_TOML,
79        "windows-11-live" => WINDOWS_11_LIVE_TOML,
80        _ => return Err(Error::Unavailable(format!("unknown preset: {name}"))),
81    };
82    from_toml(toml_str)
83}
84
85pub(crate) fn list_presets() -> &'static [&'static str] {
86    PRESET_NAMES
87}
88
89/// Platform-specific preset names that should only appear on their native platform.
90const PLATFORM_SPECIFIC: &[(&str, &[&str])] = &[
91    ("kde-breeze", &["linux-kde"]),
92    ("adwaita", &["linux"]),
93    ("windows-11", &["windows"]),
94    ("macos-sonoma", &["macos"]),
95    ("ios", &["macos", "ios"]),
96];
97
98/// Detect the current platform tag for preset filtering.
99///
100/// Returns a string like "linux-kde", "linux", "windows", or "macos".
101#[allow(unreachable_code)]
102fn detect_platform() -> &'static str {
103    #[cfg(target_os = "macos")]
104    {
105        return "macos";
106    }
107    #[cfg(target_os = "windows")]
108    {
109        return "windows";
110    }
111    #[cfg(target_os = "linux")]
112    {
113        let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
114        for component in desktop.split(':') {
115            if component == "KDE" {
116                return "linux-kde";
117            }
118        }
119        "linux"
120    }
121    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
122    {
123        "linux"
124    }
125}
126
127/// Returns preset names appropriate for the current platform.
128///
129/// Platform-specific presets (kde-breeze, adwaita, windows-11, macos-sonoma, ios)
130/// are only included on their native platform. Community themes are always included.
131pub(crate) fn list_presets_for_platform() -> Vec<&'static str> {
132    let platform = detect_platform();
133
134    PRESET_NAMES
135        .iter()
136        .filter(|name| {
137            if let Some((_, platforms)) = PLATFORM_SPECIFIC.iter().find(|(n, _)| n == *name) {
138                platforms.iter().any(|p| platform.starts_with(p))
139            } else {
140                true // Community themes always visible
141            }
142        })
143        .copied()
144        .collect()
145}
146
147pub(crate) fn from_toml(toml_str: &str) -> Result<NativeTheme> {
148    let theme: NativeTheme = toml::from_str(toml_str)?;
149    Ok(theme)
150}
151
152pub(crate) fn from_file(path: impl AsRef<Path>) -> Result<NativeTheme> {
153    let contents = std::fs::read_to_string(path)?;
154    from_toml(&contents)
155}
156
157pub(crate) fn to_toml(theme: &NativeTheme) -> Result<String> {
158    let s = toml::to_string_pretty(theme)?;
159    Ok(s)
160}
161
162#[cfg(test)]
163#[allow(clippy::unwrap_used, clippy::expect_used)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn all_presets_loadable_via_preset_fn() {
169        for name in list_presets() {
170            let theme =
171                preset(name).unwrap_or_else(|e| panic!("preset '{name}' failed to parse: {e}"));
172            assert!(
173                theme.light.is_some(),
174                "preset '{name}' missing light variant"
175            );
176            assert!(theme.dark.is_some(), "preset '{name}' missing dark variant");
177        }
178    }
179
180    #[test]
181    fn all_presets_have_nonempty_core_colors() {
182        for name in list_presets() {
183            let theme = preset(name).unwrap();
184            let light = theme.light.as_ref().unwrap();
185            let dark = theme.dark.as_ref().unwrap();
186
187            assert!(
188                light.defaults.accent.is_some(),
189                "preset '{name}' light missing accent"
190            );
191            assert!(
192                light.defaults.background.is_some(),
193                "preset '{name}' light missing background"
194            );
195            assert!(
196                light.defaults.foreground.is_some(),
197                "preset '{name}' light missing foreground"
198            );
199            assert!(
200                dark.defaults.accent.is_some(),
201                "preset '{name}' dark missing accent"
202            );
203            assert!(
204                dark.defaults.background.is_some(),
205                "preset '{name}' dark missing background"
206            );
207            assert!(
208                dark.defaults.foreground.is_some(),
209                "preset '{name}' dark missing foreground"
210            );
211        }
212    }
213
214    #[test]
215    fn preset_unknown_name_returns_unavailable() {
216        let err = preset("nonexistent").unwrap_err();
217        match err {
218            Error::Unavailable(msg) => assert!(msg.contains("nonexistent")),
219            other => panic!("expected Unavailable, got: {other:?}"),
220        }
221    }
222
223    #[test]
224    fn list_presets_returns_all_sixteen() {
225        let names = list_presets();
226        assert_eq!(names.len(), 16);
227        assert!(names.contains(&"kde-breeze"));
228        assert!(names.contains(&"adwaita"));
229        assert!(names.contains(&"windows-11"));
230        assert!(names.contains(&"macos-sonoma"));
231        assert!(names.contains(&"material"));
232        assert!(names.contains(&"ios"));
233        assert!(names.contains(&"catppuccin-latte"));
234        assert!(names.contains(&"catppuccin-frappe"));
235        assert!(names.contains(&"catppuccin-macchiato"));
236        assert!(names.contains(&"catppuccin-mocha"));
237        assert!(names.contains(&"nord"));
238        assert!(names.contains(&"dracula"));
239        assert!(names.contains(&"gruvbox"));
240        assert!(names.contains(&"solarized"));
241        assert!(names.contains(&"tokyo-night"));
242        assert!(names.contains(&"one-dark"));
243    }
244
245    #[test]
246    fn from_toml_minimal_valid() {
247        let toml_str = r##"
248name = "Minimal"
249
250[light.defaults]
251accent = "#ff0000"
252"##;
253        let theme = from_toml(toml_str).unwrap();
254        assert_eq!(theme.name, "Minimal");
255        assert!(theme.light.is_some());
256        let light = theme.light.unwrap();
257        assert_eq!(light.defaults.accent, Some(crate::Rgba::rgb(255, 0, 0)));
258    }
259
260    #[test]
261    fn from_toml_invalid_returns_format_error() {
262        let err = from_toml("{{{{invalid toml").unwrap_err();
263        match err {
264            Error::Format(_) => {}
265            other => panic!("expected Format, got: {other:?}"),
266        }
267    }
268
269    #[test]
270    fn to_toml_produces_valid_round_trip() {
271        let theme = preset("catppuccin-mocha").unwrap();
272        let toml_str = to_toml(&theme).unwrap();
273
274        // Must be parseable back into a NativeTheme
275        let reparsed = from_toml(&toml_str).unwrap();
276        assert_eq!(reparsed.name, theme.name);
277        assert!(reparsed.light.is_some());
278        assert!(reparsed.dark.is_some());
279
280        // Core colors should survive the round-trip
281        let orig_light = theme.light.as_ref().unwrap();
282        let new_light = reparsed.light.as_ref().unwrap();
283        assert_eq!(orig_light.defaults.accent, new_light.defaults.accent);
284    }
285
286    #[test]
287    fn from_file_with_tempfile() {
288        let dir = std::env::temp_dir();
289        let path = dir.join("native_theme_test_preset.toml");
290        let toml_str = r##"
291name = "File Test"
292
293[light.defaults]
294accent = "#00ff00"
295"##;
296        std::fs::write(&path, toml_str).unwrap();
297
298        let theme = from_file(&path).unwrap();
299        assert_eq!(theme.name, "File Test");
300        assert!(theme.light.is_some());
301
302        // Clean up
303        let _ = std::fs::remove_file(&path);
304    }
305
306    // === icon_set preset tests ===
307
308    #[test]
309    fn icon_set_native_presets_have_correct_values() {
310        let cases: &[(&str, &str)] = &[
311            ("windows-11", "segoe-fluent"),
312            ("macos-sonoma", "sf-symbols"),
313            ("ios", "sf-symbols"),
314            ("adwaita", "freedesktop"),
315            ("kde-breeze", "freedesktop"),
316            ("material", "material"),
317        ];
318        for (name, expected) in cases {
319            let theme = preset(name).unwrap();
320            let light = theme.light.as_ref().unwrap();
321            assert_eq!(
322                light.icon_set.as_deref(),
323                Some(*expected),
324                "preset '{name}' light.icon_set should be Some(\"{expected}\")"
325            );
326            let dark = theme.dark.as_ref().unwrap();
327            assert_eq!(
328                dark.icon_set.as_deref(),
329                Some(*expected),
330                "preset '{name}' dark.icon_set should be Some(\"{expected}\")"
331            );
332        }
333    }
334
335    #[test]
336    fn icon_set_community_presets_are_freedesktop() {
337        let community = &[
338            "catppuccin-latte",
339            "catppuccin-frappe",
340            "catppuccin-macchiato",
341            "catppuccin-mocha",
342            "nord",
343            "dracula",
344            "gruvbox",
345            "solarized",
346            "tokyo-night",
347            "one-dark",
348        ];
349        for name in community {
350            let theme = preset(name).unwrap();
351            let light = theme.light.as_ref().unwrap();
352            assert_eq!(
353                light.icon_set.as_deref(),
354                Some("freedesktop"),
355                "preset '{name}' light.icon_set should be Some(\"freedesktop\")"
356            );
357            let dark = theme.dark.as_ref().unwrap();
358            assert_eq!(
359                dark.icon_set.as_deref(),
360                Some("freedesktop"),
361                "preset '{name}' dark.icon_set should be Some(\"freedesktop\")"
362            );
363        }
364    }
365
366    #[test]
367    fn from_file_nonexistent_returns_error() {
368        let err = from_file("/tmp/nonexistent_theme_file_12345.toml").unwrap_err();
369        match err {
370            Error::Unavailable(_) => {}
371            other => panic!("expected Unavailable, got: {other:?}"),
372        }
373    }
374
375    #[test]
376    fn preset_names_match_list() {
377        // Every name in list_presets() must be loadable via preset()
378        for name in list_presets() {
379            assert!(preset(name).is_ok(), "preset '{name}' not loadable");
380        }
381    }
382
383    #[test]
384    fn presets_have_correct_names() {
385        assert_eq!(preset("kde-breeze").unwrap().name, "KDE Breeze");
386        assert_eq!(preset("adwaita").unwrap().name, "Adwaita");
387        assert_eq!(preset("windows-11").unwrap().name, "Windows 11");
388        assert_eq!(preset("macos-sonoma").unwrap().name, "macOS Sonoma");
389        assert_eq!(preset("material").unwrap().name, "Material");
390        assert_eq!(preset("ios").unwrap().name, "iOS");
391        assert_eq!(preset("catppuccin-latte").unwrap().name, "Catppuccin Latte");
392        assert_eq!(
393            preset("catppuccin-frappe").unwrap().name,
394            "Catppuccin Frappe"
395        );
396        assert_eq!(
397            preset("catppuccin-macchiato").unwrap().name,
398            "Catppuccin Macchiato"
399        );
400        assert_eq!(preset("catppuccin-mocha").unwrap().name, "Catppuccin Mocha");
401        assert_eq!(preset("nord").unwrap().name, "Nord");
402        assert_eq!(preset("dracula").unwrap().name, "Dracula");
403        assert_eq!(preset("gruvbox").unwrap().name, "Gruvbox");
404        assert_eq!(preset("solarized").unwrap().name, "Solarized");
405        assert_eq!(preset("tokyo-night").unwrap().name, "Tokyo Night");
406        assert_eq!(preset("one-dark").unwrap().name, "One Dark");
407    }
408
409    #[test]
410    fn all_presets_with_fonts_have_valid_sizes() {
411        for name in list_presets() {
412            let theme = preset(name).unwrap();
413            for (label, variant) in [
414                ("light", theme.light.as_ref()),
415                ("dark", theme.dark.as_ref()),
416            ] {
417                let variant = variant.unwrap();
418                // Community color themes may omit fonts entirely — skip those.
419                if let Some(size) = variant.defaults.font.size {
420                    assert!(
421                        size > 0.0,
422                        "preset '{name}' {label} font size must be positive, got {size}"
423                    );
424                }
425                if let Some(mono_size) = variant.defaults.mono_font.size {
426                    assert!(
427                        mono_size > 0.0,
428                        "preset '{name}' {label} mono font size must be positive, got {mono_size}"
429                    );
430                }
431            }
432        }
433    }
434
435    #[test]
436    fn platform_presets_no_derived_fields() {
437        // Platform presets must not contain fields that are derived by resolve()
438        let platform_presets = &["kde-breeze", "adwaita", "windows-11", "macos-sonoma"];
439        for name in platform_presets {
440            let theme = preset(name).unwrap();
441            for (label, variant_opt) in [
442                ("light", theme.light.as_ref()),
443                ("dark", theme.dark.as_ref()),
444            ] {
445                let variant = variant_opt.unwrap();
446                // button.primary_bg is derived from accent - should not be in presets
447                assert!(
448                    variant.button.primary_bg.is_none(),
449                    "preset '{name}' {label}.button.primary_bg should be None (derived)"
450                );
451                // checkbox.checked_bg is derived from accent
452                assert!(
453                    variant.checkbox.checked_bg.is_none(),
454                    "preset '{name}' {label}.checkbox.checked_bg should be None (derived)"
455                );
456                // slider.fill is derived from accent
457                assert!(
458                    variant.slider.fill.is_none(),
459                    "preset '{name}' {label}.slider.fill should be None (derived)"
460                );
461                // progress_bar.fill is derived from accent
462                assert!(
463                    variant.progress_bar.fill.is_none(),
464                    "preset '{name}' {label}.progress_bar.fill should be None (derived)"
465                );
466                // switch.checked_bg is derived from accent
467                assert!(
468                    variant.switch.checked_bg.is_none(),
469                    "preset '{name}' {label}.switch.checked_bg should be None (derived)"
470                );
471            }
472        }
473    }
474
475    // === resolve()/validate() integration tests (PRESET-03) ===
476
477    #[test]
478    fn all_presets_resolve_validate() {
479        for name in list_presets() {
480            let theme = preset(name).unwrap();
481            if let Some(mut light) = theme.light.clone() {
482                light.resolve();
483                light.validate().unwrap_or_else(|e| {
484                    panic!("preset {name} light variant failed validation: {e}");
485                });
486            }
487            if let Some(mut dark) = theme.dark.clone() {
488                dark.resolve();
489                dark.validate().unwrap_or_else(|e| {
490                    panic!("preset {name} dark variant failed validation: {e}");
491                });
492            }
493        }
494    }
495
496    #[test]
497    fn resolve_fills_accent_derived_fields() {
498        // Load a preset that only has accent set (not explicit widget accent-derived fields).
499        // After resolve(), the accent-derived fields should be populated.
500        let theme = preset("catppuccin-mocha").unwrap();
501        let mut light = theme.light.clone().unwrap();
502
503        // Before resolve: accent-derived fields should be None (not in preset TOML)
504        assert!(
505            light.button.primary_bg.is_none(),
506            "primary_bg should be None pre-resolve"
507        );
508        assert!(
509            light.checkbox.checked_bg.is_none(),
510            "checkbox.checked_bg should be None pre-resolve"
511        );
512        assert!(
513            light.slider.fill.is_none(),
514            "slider.fill should be None pre-resolve"
515        );
516        assert!(
517            light.progress_bar.fill.is_none(),
518            "progress_bar.fill should be None pre-resolve"
519        );
520        assert!(
521            light.switch.checked_bg.is_none(),
522            "switch.checked_bg should be None pre-resolve"
523        );
524
525        light.resolve();
526
527        // After resolve: all accent-derived fields should equal accent
528        let accent = light.defaults.accent.unwrap();
529        assert_eq!(
530            light.button.primary_bg,
531            Some(accent),
532            "button.primary_bg should match accent"
533        );
534        assert_eq!(
535            light.checkbox.checked_bg,
536            Some(accent),
537            "checkbox.checked_bg should match accent"
538        );
539        assert_eq!(
540            light.slider.fill,
541            Some(accent),
542            "slider.fill should match accent"
543        );
544        assert_eq!(
545            light.progress_bar.fill,
546            Some(accent),
547            "progress_bar.fill should match accent"
548        );
549        assert_eq!(
550            light.switch.checked_bg,
551            Some(accent),
552            "switch.checked_bg should match accent"
553        );
554    }
555
556    #[test]
557    fn resolve_then_validate_produces_complete_theme() {
558        let theme = preset("catppuccin-mocha").unwrap();
559        let mut light = theme.light.clone().unwrap();
560        light.resolve();
561        let resolved = light.validate().unwrap();
562
563        assert_eq!(resolved.defaults.font.family, "Inter");
564        assert_eq!(resolved.defaults.font.size, 14.0);
565        assert_eq!(resolved.defaults.font.weight, 400);
566        assert_eq!(resolved.defaults.line_height, 1.4);
567        assert_eq!(resolved.defaults.radius, 8.0);
568        assert_eq!(resolved.defaults.focus_ring_width, 2.0);
569        assert_eq!(resolved.defaults.icon_sizes.toolbar, 24.0);
570        assert_eq!(resolved.defaults.text_scaling_factor, 1.0);
571        assert!(!resolved.defaults.reduce_motion);
572        // Window inherits from defaults
573        assert_eq!(resolved.window.background, resolved.defaults.background);
574        // icon_set should be populated
575        assert_eq!(resolved.icon_set, "freedesktop");
576    }
577
578    #[test]
579    fn font_subfield_inheritance_integration() {
580        // Load a preset, set menu.font to only have size=12.0 (clear family/weight),
581        // resolve, and verify family/weight are inherited from defaults.
582        let theme = preset("catppuccin-mocha").unwrap();
583        let mut light = theme.light.clone().unwrap();
584
585        // Set partial font on menu
586        use crate::model::FontSpec;
587        light.menu.font = Some(FontSpec {
588            family: None,
589            size: Some(12.0),
590            weight: None,
591        });
592
593        light.resolve();
594        let resolved = light.validate().unwrap();
595
596        // menu font should have inherited family/weight from defaults
597        assert_eq!(
598            resolved.menu.font.family, "Inter",
599            "menu font family should inherit from defaults"
600        );
601        assert_eq!(
602            resolved.menu.font.size, 12.0,
603            "menu font size should be the explicit value"
604        );
605        assert_eq!(
606            resolved.menu.font.weight, 400,
607            "menu font weight should inherit from defaults"
608        );
609    }
610
611    #[test]
612    fn text_scale_inheritance_integration() {
613        // Load a preset, ensure text_scale.caption gets populated from defaults.
614        let theme = preset("catppuccin-mocha").unwrap();
615        let mut light = theme.light.clone().unwrap();
616
617        // Clear caption to test inheritance
618        light.text_scale.caption = None;
619
620        light.resolve();
621        let resolved = light.validate().unwrap();
622
623        // caption should have been populated from defaults.font
624        assert_eq!(
625            resolved.text_scale.caption.size, 14.0,
626            "caption size from defaults.font.size"
627        );
628        assert_eq!(
629            resolved.text_scale.caption.weight, 400,
630            "caption weight from defaults.font.weight"
631        );
632        // line_height = defaults.line_height * size = 1.4 * 14.0 = 19.6
633        assert!(
634            (resolved.text_scale.caption.line_height - 19.6).abs() < 0.01,
635            "caption line_height should be line_height_multiplier * size = 19.6, got {}",
636            resolved.text_scale.caption.line_height
637        );
638    }
639
640    #[test]
641    fn all_presets_round_trip_exact() {
642        // All 16 presets must survive a serde round-trip
643        for name in list_presets() {
644            let theme1 =
645                preset(name).unwrap_or_else(|e| panic!("preset '{name}' failed to parse: {e}"));
646            let toml_str = to_toml(&theme1)
647                .unwrap_or_else(|e| panic!("preset '{name}' failed to serialize: {e}"));
648            let theme2 = from_toml(&toml_str)
649                .unwrap_or_else(|e| panic!("preset '{name}' failed to re-parse: {e}"));
650            assert_eq!(
651                theme1, theme2,
652                "preset '{name}' round-trip produced different value"
653            );
654        }
655    }
656
657    // === Live preset tests ===
658
659    #[test]
660    fn live_presets_loadable() {
661        let live_names = &[
662            "kde-breeze-live",
663            "adwaita-live",
664            "macos-sonoma-live",
665            "windows-11-live",
666        ];
667        for name in live_names {
668            let theme = preset(name)
669                .unwrap_or_else(|e| panic!("live preset '{name}' failed to parse: {e}"));
670
671            // Both variants must exist
672            assert!(
673                theme.light.is_some(),
674                "live preset '{name}' missing light variant"
675            );
676            assert!(
677                theme.dark.is_some(),
678                "live preset '{name}' missing dark variant"
679            );
680
681            let light = theme.light.as_ref().unwrap();
682            let dark = theme.dark.as_ref().unwrap();
683
684            // No colors
685            assert!(
686                light.defaults.accent.is_none(),
687                "live preset '{name}' light should have no accent"
688            );
689            assert!(
690                light.defaults.background.is_none(),
691                "live preset '{name}' light should have no background"
692            );
693            assert!(
694                light.defaults.foreground.is_none(),
695                "live preset '{name}' light should have no foreground"
696            );
697            assert!(
698                dark.defaults.accent.is_none(),
699                "live preset '{name}' dark should have no accent"
700            );
701            assert!(
702                dark.defaults.background.is_none(),
703                "live preset '{name}' dark should have no background"
704            );
705            assert!(
706                dark.defaults.foreground.is_none(),
707                "live preset '{name}' dark should have no foreground"
708            );
709
710            // No fonts
711            assert!(
712                light.defaults.font.family.is_none(),
713                "live preset '{name}' light should have no font family"
714            );
715            assert!(
716                light.defaults.font.size.is_none(),
717                "live preset '{name}' light should have no font size"
718            );
719            assert!(
720                light.defaults.font.weight.is_none(),
721                "live preset '{name}' light should have no font weight"
722            );
723            assert!(
724                dark.defaults.font.family.is_none(),
725                "live preset '{name}' dark should have no font family"
726            );
727            assert!(
728                dark.defaults.font.size.is_none(),
729                "live preset '{name}' dark should have no font size"
730            );
731            assert!(
732                dark.defaults.font.weight.is_none(),
733                "live preset '{name}' dark should have no font weight"
734            );
735        }
736    }
737
738    #[test]
739    fn list_presets_for_platform_returns_subset() {
740        let all = list_presets();
741        let filtered = list_presets_for_platform();
742        // Filtered list should be a subset of all presets
743        for name in &filtered {
744            assert!(
745                all.contains(name),
746                "filtered preset '{name}' not in full list"
747            );
748        }
749        // Community themes should always be present
750        let community = &[
751            "catppuccin-latte",
752            "catppuccin-frappe",
753            "catppuccin-macchiato",
754            "catppuccin-mocha",
755            "nord",
756            "dracula",
757            "gruvbox",
758            "solarized",
759            "tokyo-night",
760            "one-dark",
761        ];
762        for name in community {
763            assert!(
764                filtered.contains(name),
765                "community preset '{name}' should always be in filtered list"
766            );
767        }
768        // material is cross-platform, always present
769        assert!(
770            filtered.contains(&"material"),
771            "material should always be in filtered list"
772        );
773    }
774
775    #[test]
776    fn live_presets_fail_validate_standalone() {
777        let live_names = &[
778            "kde-breeze-live",
779            "adwaita-live",
780            "macos-sonoma-live",
781            "windows-11-live",
782        ];
783        for name in live_names {
784            let theme = preset(name).unwrap();
785            let mut light = theme.light.clone().unwrap();
786            light.resolve();
787            let result = light.validate();
788            assert!(
789                result.is_err(),
790                "live preset '{name}' light should fail validation standalone (missing colors/fonts)"
791            );
792
793            let mut dark = theme.dark.clone().unwrap();
794            dark.resolve();
795            let result = dark.validate();
796            assert!(
797                result.is_err(),
798                "live preset '{name}' dark should fail validation standalone (missing colors/fonts)"
799            );
800        }
801    }
802}