use std::path::PathBuf; use std::sync::Mutex; use std::sync::atomic::{ AtomicU32, Ordering }; use ltk::{ ThemeDocument, ThemeMode, WallpaperFit }; /// Serialises tests that mutate the process-global active theme state /// (`set_active_mode`, `theme_wallpaper`, …). `cargo test` runs tests /// in parallel by default; without this guard a Light test can read /// Dark state set by a concurrent test mid-flight. static THEME_STATE_LOCK: Mutex<()> = Mutex::new( () ); // ── Per-test temp dir ───────────────────────────────────────────────────────── /// Build a unique temp dir per test invocation. `process::id()` alone is not /// enough — `cargo test` runs tests in the same process so a stale dir from a /// previous test in the same run would collide. Add a process-local counter. fn fresh_temp_dir( tag: &str ) -> PathBuf { static COUNTER: AtomicU32 = AtomicU32::new( 0 ); let n = COUNTER.fetch_add( 1, Ordering::SeqCst ); let dir = std::env::temp_dir() .join( format!( "ltk-theme-test-{}-{}-{}", std::process::id(), tag, n ) ); let _ = std::fs::remove_dir_all( &dir ); std::fs::create_dir_all( &dir ).unwrap(); dir } const MINIMAL_THEME_JSON: &str = r##" { "theme": { "id": "demo", "name": "Demo" }, "fonts": { "sora": { "name": "Sora", "fallbacks": [], "sources": [] } }, "modes": { "light": { "wallpaper": { "path": "wp/light.jpg", "fit": "cover" }, "lockscreen": { "path": "wp/lock-light.jpg", "fit": "cover" }, "launcher": { "background": "#FFFFFFE6", "border_radius": 24.0 }, "window_controls": { "icon": "#112233", "hover_bg": "#11223322", "pressed_bg": "#11223333", "close_hover_bg": "#AA0000", "close_icon": "#FFFFFF", "focus_ring": "#00CEB1" }, "slots": { "bg-page": { "type": "color", "value": "#FFFFFF" }, "surface": { "type": "color", "value": "#FFFFFF99" }, "surface-alt": { "type": "color", "value": "#FFFFFFD9" }, "text-primary": { "type": "color", "value": "#102030" }, "text-secondary": { "type": "color", "value": "#10203080" }, "accent": { "type": "color", "value": "#00CEB1" }, "divider": { "type": "color", "value": "#00000014" }, "icon": { "type": "color", "value": "#000000" } } }, "dark": { "wallpaper": { "path": "wp/dark.jpg", "fit": "contain" }, "lockscreen": { "path": "wp/lock-dark.jpg", "fit": "cover" }, "launcher": { "background": "#221A5AE6", "border_radius": 24.0 }, "window_controls": { "icon": "#EEEEEE", "hover_bg": "#FFFFFF18", "pressed_bg": "#FFFFFF26", "close_hover_bg": "#E5484D", "close_icon": "#FFFFFF", "focus_ring": "#00CEB1" }, "slots": { "bg-page": { "type": "color", "value": "#000000" }, "surface": { "type": "color", "value": "#18104599" }, "surface-alt": { "type": "color", "value": "#221A5AD9" }, "text-primary": { "type": "color", "value": "#FFFFFF" }, "text-secondary": { "type": "color", "value": "#FFFFFFB3" }, "accent": { "type": "color", "value": "#00CEB1" }, "divider": { "type": "color", "value": "#FFFFFF14" }, "icon": { "type": "color", "value": "#FFFFFF" } } } } } "##; // ── Round-trip ──────────────────────────────────────────────────────────────── #[ test ] fn parses_id_name_and_both_modes() { let dir = fresh_temp_dir( "round-trip" ); std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap(); let doc = ThemeDocument::load_from_dir( &dir ).expect( "minimal theme should parse" ); assert_eq!( doc.id, "demo" ); assert_eq!( doc.name, "Demo" ); // Wallpaper fit fields round-trip through the enum. assert_eq!( doc.light.wallpaper.as_ref().unwrap().fit, WallpaperFit::Cover ); assert_eq!( doc.dark.wallpaper.as_ref().unwrap().fit, WallpaperFit::Contain ); let _ = std::fs::remove_dir_all( &dir ); } #[ test ] fn palette_hex_strings_round_trip_through_slots() { let dir = fresh_temp_dir( "palette" ); std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap(); let doc = ThemeDocument::load_from_dir( &dir ).unwrap(); // "#102030" → R=0x10/255, G=0x20/255, B=0x30/255, A=1.0 let p = doc.light.slots.color( "text-primary" ).expect( "text-primary slot" ); let expected = ( 0x10 as f32 / 255.0, 0x20 as f32 / 255.0, 0x30 as f32 / 255.0 ); assert!( ( p.r - expected.0 ).abs() < 1e-3, "r={}, expected {}", p.r, expected.0 ); assert!( ( p.g - expected.1 ).abs() < 1e-3, "g={}, expected {}", p.g, expected.1 ); assert!( ( p.b - expected.2 ).abs() < 1e-3, "b={}, expected {}", p.b, expected.2 ); assert!( ( p.a - 1.0 ).abs() < 1e-3, "a={}, expected 1.0", p.a ); // 8-digit hex with alpha: "#10203080" → A ≈ 0x80/255 let s = doc.light.slots.color( "text-secondary" ).unwrap(); assert!( ( s.a - ( 0x80 as f32 / 255.0 ) ).abs() < 1e-3 ); let _ = std::fs::remove_dir_all( &dir ); } #[ test ] fn window_control_tokens_round_trip() { let dir = fresh_temp_dir( "window-controls" ); std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap(); let doc = ThemeDocument::load_from_dir( &dir ).unwrap(); let wc = doc.light.window_controls.as_ref().expect( "window_controls" ); assert!( ( wc.icon.r - ( 0x11 as f32 / 255.0 ) ).abs() < 1e-3 ); assert!( ( wc.icon.g - ( 0x22 as f32 / 255.0 ) ).abs() < 1e-3 ); assert!( ( wc.icon.b - ( 0x33 as f32 / 255.0 ) ).abs() < 1e-3 ); assert!( ( wc.icon.a - 1.0 ).abs() < 1e-3 ); assert!( ( wc.hover_bg.a - ( 0x22 as f32 / 255.0 ) ).abs() < 1e-3 ); let _ = std::fs::remove_dir_all( &dir ); } #[ test ] fn relative_asset_paths_resolve_against_theme_dir() { let dir = fresh_temp_dir( "paths" ); std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap(); let doc = ThemeDocument::load_from_dir( &dir ).unwrap(); let wp = doc.light.wallpaper.as_ref().unwrap().path.as_ref().expect( "wallpaper path" ); assert_eq!( wp, &dir.join( "wp/light.jpg" ) ); let lock = doc.dark.lockscreen.as_ref().unwrap().path.as_ref().expect( "lockscreen path" ); assert_eq!( lock, &dir.join( "wp/lock-dark.jpg" ) ); let _ = std::fs::remove_dir_all( &dir ); } // ── Errors ──────────────────────────────────────────────────────────────────── #[ test ] fn missing_theme_json_returns_error() { // Empty dir — load_from_dir should fail rather than panic. let dir = fresh_temp_dir( "missing" ); let result = ThemeDocument::load_from_dir( &dir ); assert!( result.is_err(), "expected Err for empty dir" ); let _ = std::fs::remove_dir_all( &dir ); } // ── Mode resolution ─────────────────────────────────────────────────────────── /// Point `LTK_THEMES_DIR` at the in-tree `themes/` so any test that /// triggers `ensure_active` finds the bundled `default` theme without /// requiring a system install. Sets the env var unconditionally — the /// global `ACTIVE` is process-wide so the first test that calls /// `ensure_active` wins, and a stale env var from a prior run would /// just point at the same directory. fn point_at_in_tree_themes() { std::env::set_var ( "LTK_THEMES_DIR", env!( "CARGO_MANIFEST_DIR" ).to_string() + "/themes", ); } #[ test ] fn theme_mode_round_trip() { // Switching the active mode at runtime is observable via // `ltk::active_mode()`. This triggers `ensure_active()` which loads // the in-tree default theme. let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() ); point_at_in_tree_themes(); ltk::set_active_mode( ThemeMode::Light ); assert_eq!( ltk::active_mode(), ThemeMode::Light ); ltk::set_active_mode( ThemeMode::Dark ); assert_eq!( ltk::active_mode(), ThemeMode::Dark ); } #[ test ] fn theme_wallpaper_resolves_for_active_mode() { // `theme_wallpaper()` resolves via the convention // `branding/{mode}/wallpaper.svg` (the default theme no longer // declares an explicit `wallpaper.path` in theme.json), with the // standard mode → opposite-mode → no-mode fallback chain. let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() ); point_at_in_tree_themes(); ltk::set_active_mode( ThemeMode::Light ); let wp = ltk::theme_wallpaper().expect( "Light resolves via branding/light/" ); let path = wp.path.expect( "convention resolution returns a concrete path" ); assert! ( path.to_string_lossy().ends_with( "branding/light/wallpaper.svg" ), "unexpected Light wallpaper path: {}", path.display(), ); ltk::set_active_mode( ThemeMode::Dark ); let wp = ltk::theme_wallpaper().expect( "Dark resolves via branding/dark/" ); let path = wp.path.expect( "convention resolution returns a concrete path" ); assert! ( path.to_string_lossy().ends_with( "branding/dark/wallpaper.svg" ), "unexpected Dark wallpaper path: {}", path.display(), ); } #[ test ] fn theme_lockscreen_resolves_for_active_mode() { // `theme_lockscreen()` mirrors `theme_wallpaper()` against the // convention `branding/{mode}/lockscreen.svg`. The default theme // ships both Light and Dark variants, so each mode resolves to its // own asset without needing the fallback chain. let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() ); point_at_in_tree_themes(); ltk::set_active_mode( ThemeMode::Light ); let ls = ltk::theme_lockscreen().expect( "Light resolves via branding/light/" ); let path = ls.path.expect( "convention resolution returns a concrete path" ); assert! ( path.to_string_lossy().ends_with( "branding/light/lockscreen.svg" ), "unexpected Light lockscreen path: {}", path.display(), ); ltk::set_active_mode( ThemeMode::Dark ); let ls = ltk::theme_lockscreen().expect( "Dark resolves via branding/dark/" ); let path = ls.path.expect( "convention resolution returns a concrete path" ); assert! ( path.to_string_lossy().ends_with( "branding/dark/lockscreen.svg" ), "unexpected Dark lockscreen path: {}", path.display(), ); } #[ test ] fn theme_logo_variants_resolve_for_active_mode() { // `theme_logo`, `theme_logo_square`, `theme_logo_horizontal` are // thin wrappers over `branding_asset( "logo/", "svg" )`. // The default theme ships all three SVG variants for both modes // under `branding/{mode}/logo/`, so each call resolves directly // to its own-mode file without the cross-mode fallback. let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() ); point_at_in_tree_themes(); for mode in [ ThemeMode::Light, ThemeMode::Dark ] { ltk::set_active_mode( mode ); let mode_dir = match mode { ThemeMode::Light => "branding/light/logo/", ThemeMode::Dark => "branding/dark/logo/", }; let primary = ltk::theme_logo() .unwrap_or_else( || panic!( "theme_logo missing for {mode:?}" ) ); assert! ( primary.to_string_lossy().ends_with( &format!( "{mode_dir}logo.svg" ) ), "unexpected {mode:?} logo path: {}", primary.display(), ); let square = ltk::theme_logo_square() .unwrap_or_else( || panic!( "theme_logo_square missing for {mode:?}" ) ); assert! ( square.to_string_lossy().ends_with( &format!( "{mode_dir}square.svg" ) ), "unexpected {mode:?} square logo path: {}", square.display(), ); let horizontal = ltk::theme_logo_horizontal() .unwrap_or_else( || panic!( "theme_logo_horizontal missing for {mode:?}" ) ); assert! ( horizontal.to_string_lossy().ends_with( &format!( "{mode_dir}horizontal.svg" ) ), "unexpected {mode:?} horizontal logo path: {}", horizontal.display(), ); } } #[ test ] fn branding_raster_picks_smallest_covering_at_each_target_size() { // The default theme ships `1280x720`, `1920x1080`, `3840x2160` WebPs // for both wallpaper and lockscreen in both modes. The lookup must // pick the smallest entry whose two dimensions cover the surface. let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() ); point_at_in_tree_themes(); ltk::set_active_mode( ThemeMode::Light ); // Surface ≤ 720p → 1280x720 covers it. let path = ltk::theme_branding_raster( "wallpaper", 800, 600 ) .expect( "Light raster covers 800x600" ); assert! ( path.to_string_lossy().ends_with( "branding/light/wallpaper/1280x720.webp" ), "unexpected raster for 800x600: {}", path.display(), ); // Surface 1080p exact → 1920x1080 covers it. let path = ltk::theme_branding_raster( "wallpaper", 1920, 1080 ) .expect( "Light raster covers 1920x1080" ); assert! ( path.to_string_lossy().ends_with( "branding/light/wallpaper/1920x1080.webp" ), "unexpected raster for 1920x1080: {}", path.display(), ); // 1920x1200 (16:10 laptop) → 1080 < 1200, so 1080p is too short. Next // up that covers both axes is 4K. let path = ltk::theme_branding_raster( "wallpaper", 1920, 1200 ) .expect( "Light raster covers 1920x1200" ); assert! ( path.to_string_lossy().ends_with( "branding/light/wallpaper/3840x2160.webp" ), "unexpected raster for 1920x1200: {}", path.display(), ); } #[ test ] fn branding_raster_falls_back_to_largest_when_none_covers() { // 5000-wide surface exceeds the largest shipped raster (3840). No // covering candidate, so the lookup falls back to the largest // available raster — a fast-decoding upscaled WebP beats paying the // SVG rasterisation cost. let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() ); point_at_in_tree_themes(); ltk::set_active_mode( ThemeMode::Dark ); let path = ltk::theme_branding_raster( "wallpaper", 5000, 3000 ) .expect( "no covering raster but the largest is returned" ); assert! ( path.to_string_lossy().ends_with( "branding/dark/wallpaper/3840x2160.webp" ), "unexpected fallback raster for 5000x3000: {}", path.display(), ); // `branding_image` returns the same — it only falls to the SVG when // no raster files exist at all. let img = ltk::theme_branding_image( "wallpaper", 5000, 3000 ) .expect( "branding_image returns the same largest raster" ); assert_eq!( img, path ); } #[ test ] fn branding_raster_with_zero_size_returns_smallest_available() { // (0, 0) means "surface size unknown — give me the smallest variant". // Every entry trivially covers a 0×0 surface so the smallest by area // wins. Used at startup so the first frame paints a fast-decoded // lightweight raster instead of paying the SVG rasterisation cost. let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() ); point_at_in_tree_themes(); ltk::set_active_mode( ThemeMode::Light ); let path = ltk::theme_branding_raster( "wallpaper", 0, 0 ) .expect( "(0, 0) returns the smallest raster" ); assert! ( path.to_string_lossy().ends_with( "branding/light/wallpaper/1280x720.webp" ), "unexpected raster for (0, 0): {}", path.display(), ); let img = ltk::theme_branding_image( "wallpaper", 0, 0 ) .expect( "branding_image returns the same smallest raster" ); assert_eq!( img, path ); }