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