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