Files
ltk/tests/theme_parsing.rs

400 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<variant>", "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 );
}