First commit. Version 0.1.0
This commit is contained in:
399
tests/theme_parsing.rs
Normal file
399
tests/theme_parsing.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
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 );
|
||||
}
|
||||
Reference in New Issue
Block a user