diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50a62a50..1dc86d87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,56 @@ jobs: env: RUST_BACKTRACE: full + visual-tests: + needs: + - format + runs-on: ubuntu-22.04 + steps: + - name: Checkout sources + uses: actions/checkout@v3 + with: + submodules: recursive + - name: Get date for registry cache + id: date + run: echo "::set-output name=date::$(date +'%Y-%m-%d')" + - name: Cargo registry cache + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git + key: ${{ runner.os }}-cargo-registry-visual-${{ steps.date.outputs.date }} + restore-keys: ${{ runner.os }}-cargo-registry-visual- + - name: System dependencies + run: sudo apt-get update; sudo apt-get install -y libxkbcommon-dev libwayland-dev + - name: Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Run visual snapshot tests + run: cargo test --no-default-features --test visual_tests + env: + RUST_BACKTRACE: full + - name: Run theme consistency tests + run: cargo test --no-default-features --test theme_tests + env: + RUST_BACKTRACE: full + - name: Run property-based tests + run: cargo test --no-default-features --test property_tests + env: + RUST_BACKTRACE: full + - name: Run widget unit tests + run: cargo test --no-default-features --test widget_unit_tests + env: + RUST_BACKTRACE: full + - name: Upload snapshot artifacts on failure + if: failure() + uses: actions/upload-artifact@v3 + with: + name: snapshot-diffs + path: | + tests/snapshots/*.actual.png + tests/snapshots/*.diff.png + examples: needs: - format diff --git a/Cargo.toml b/Cargo.toml index feaa8c74..dc0bdccf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -226,3 +226,7 @@ dirs = "6.0.0" [dev-dependencies] tempfile = "3.24.0" +proptest = "1.6" +image = { version = "0.25.9", default-features = false, features = ["png"] } +tiny-skia = "0.11" +ron = "0.12" diff --git a/src/widget/spin_button.rs b/src/widget/spin_button.rs index 9ad81b4d..ad1042dc 100644 --- a/src/widget/spin_button.rs +++ b/src/widget/spin_button.rs @@ -318,8 +318,98 @@ fn container_style(theme: &crate::Theme) -> iced_widget::container::Style { #[cfg(test)] mod tests { + use super::*; + #[test] - fn decrement() { - assert_eq!(super::decrement(0i32, 10, 15, 35), 15); + fn decrement_clamps_to_min() { + assert_eq!(decrement(0i32, 10, 15, 35), 15); + } + + #[test] + fn decrement_normal_step() { + assert_eq!(decrement(20i32, 5, 0, 100), 15); + } + + #[test] + fn decrement_at_min_stays_at_min() { + assert_eq!(decrement(0i32, 1, 0, 100), 0); + } + + #[test] + fn decrement_near_min_clamps() { + assert_eq!(decrement(3i32, 5, 0, 100), 0); + } + + #[test] + fn increment_normal_step() { + assert_eq!(increment(20i32, 5, 0, 100), 25); + } + + #[test] + fn increment_clamps_to_max() { + assert_eq!(increment(98i32, 5, 0, 100), 100); + } + + #[test] + fn increment_at_max_stays_at_max() { + assert_eq!(increment(100i32, 1, 0, 100), 100); + } + + #[test] + fn increment_near_max_clamps() { + assert_eq!(increment(97i32, 5, 0, 100), 100); + } + + #[test] + fn increment_then_decrement_round_trips() { + let val = 50i32; + let step = 10; + let min = 0; + let max = 100; + let up = increment(val, step, min, max); + let back = decrement(up, step, min, max); + assert_eq!(back, val); + } + + #[test] + fn float_increment() { + let result = increment(0.5f64, 0.1, 0.0, 1.0); + assert!((result - 0.6).abs() < f64::EPSILON); + } + + #[test] + fn float_decrement() { + let result = decrement(0.5f64, 0.1, 0.0, 1.0); + assert!((result - 0.4).abs() < f64::EPSILON); + } + + #[test] + fn new_clamps_value_below_min() { + let sb = SpinButton::new("test", -5i32, 1, 0, 100, Orientation::Horizontal, |v| v); + assert_eq!(sb.value, 0); + } + + #[test] + fn new_clamps_value_above_max() { + let sb = SpinButton::new("test", 200i32, 1, 0, 100, Orientation::Horizontal, |v| v); + assert_eq!(sb.value, 100); + } + + #[test] + fn new_accepts_value_in_range() { + let sb = SpinButton::new("test", 50i32, 1, 0, 100, Orientation::Horizontal, |v| v); + assert_eq!(sb.value, 50); + } + + #[test] + fn new_accepts_value_at_min() { + let sb = SpinButton::new("test", 0i32, 1, 0, 100, Orientation::Horizontal, |v| v); + assert_eq!(sb.value, 0); + } + + #[test] + fn new_accepts_value_at_max() { + let sb = SpinButton::new("test", 100i32, 1, 0, 100, Orientation::Horizontal, |v| v); + assert_eq!(sb.value, 100); } } diff --git a/tests/property_tests.rs b/tests/property_tests.rs new file mode 100644 index 00000000..3ba74e1a --- /dev/null +++ b/tests/property_tests.rs @@ -0,0 +1,258 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Property-based tests for COSMIC data models and utility functions. +//! +//! Uses proptest to generate random inputs and verify that invariants hold +//! across a wide range of values. This catches edge cases that hand-written +//! unit tests often miss. + +use proptest::prelude::*; + +// --------------------------------------------------------------------------- +// Spin button property tests +// --------------------------------------------------------------------------- +mod spin_button_props { + use super::*; + + // We can't directly test the private increment/decrement functions, + // but we can replicate their logic and verify the same invariants. + + fn increment(value: i32, step: i32, _min: i32, max: i32) -> i32 { + if value > max - step { + max + } else { + value + step + } + } + + fn decrement(value: i32, step: i32, min: i32, _max: i32) -> i32 { + if value < min + step { + min + } else { + value - step + } + } + + proptest! { + #[test] + fn increment_never_exceeds_max( + value in -1000i32..1000, + step in 1i32..100, + max in 0i32..2000, + ) { + let min = -1000; + let result = increment(value, step, min, max); + prop_assert!(result <= max, "increment({value}, {step}) = {result} > max({max})"); + } + + #[test] + fn decrement_never_goes_below_min( + value in -1000i32..1000, + step in 1i32..100, + min in -2000i32..0, + ) { + let max = 1000; + let result = decrement(value, step, min, max); + prop_assert!(result >= min, "decrement({value}, {step}) = {result} < min({min})"); + } + + #[test] + fn increment_at_max_stays_at_max( + step in 1i32..100, + max in 0i32..1000, + ) { + let min = -1000; + let result = increment(max, step, min, max); + prop_assert_eq!(result, max); + } + + #[test] + fn decrement_at_min_stays_at_min( + step in 1i32..100, + min in -1000i32..0, + ) { + let max = 1000; + let result = decrement(min, step, min, max); + prop_assert_eq!(result, min); + } + + #[test] + fn increment_then_decrement_returns_to_original_or_clamped( + value in -500i32..500, + step in 1i32..50, + ) { + let min = -1000; + let max = 1000; + let incremented = increment(value, step, min, max); + let round_tripped = decrement(incremented, step, min, max); + // If we didn't hit the max, we should return to original + if value <= max - step { + prop_assert_eq!(round_tripped, value); + } else { + prop_assert!(round_tripped >= value || round_tripped >= min); + } + } + } +} + +// --------------------------------------------------------------------------- +// Calendar property tests +// --------------------------------------------------------------------------- +mod calendar_props { + use super::*; + use chrono::{Datelike, NaiveDate, Weekday}; + use cosmic::widget::calendar::{get_calender_first, set_day}; + + proptest! { + #[test] + fn get_calendar_first_returns_correct_weekday( + year in 2000i32..2100, + month in 1u32..=12, + weekday_idx in 0u32..7, + ) { + let weekday = match weekday_idx { + 0 => Weekday::Mon, + 1 => Weekday::Tue, + 2 => Weekday::Wed, + 3 => Weekday::Thu, + 4 => Weekday::Fri, + 5 => Weekday::Sat, + _ => Weekday::Sun, + }; + let first = get_calender_first(year, month, weekday); + prop_assert_eq!(first.weekday(), weekday); + } + + #[test] + fn get_calendar_first_is_before_or_at_month_start( + year in 2000i32..2100, + month in 1u32..=12, + ) { + let first = get_calender_first(year, month, Weekday::Mon); + let month_start = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); + prop_assert!(first <= month_start, + "Calendar first {first} is after month start {month_start}"); + } + + #[test] + fn get_calendar_first_is_within_one_week_of_month_start( + year in 2000i32..2100, + month in 1u32..=12, + ) { + let first = get_calender_first(year, month, Weekday::Mon); + let month_start = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); + let diff = (month_start - first).num_days(); + prop_assert!(diff >= 0 && diff < 7, + "Calendar first {first} is {diff} days before month start {month_start}"); + } + + #[test] + fn set_day_preserves_month_and_year( + month in 1u32..=12, + from_day in 1u32..=28, + to_day in 1u32..=28, + ) { + let date = NaiveDate::from_ymd_opt(2024, month, from_day).unwrap(); + let result = set_day(date, to_day); + prop_assert_eq!(result.month(), month); + prop_assert_eq!(result.year(), 2024); + } + + #[test] + fn set_day_idempotent( + month in 1u32..=12, + day in 1u32..=28, + ) { + let date = NaiveDate::from_ymd_opt(2024, month, day).unwrap(); + let result = set_day(date, day); + prop_assert_eq!(result, date); + } + } +} + +// --------------------------------------------------------------------------- +// Segmented button model property tests +// --------------------------------------------------------------------------- +mod segmented_model_props { + use super::*; + use cosmic::widget::segmented_button::{Model, SingleSelect, MultiSelect}; + + proptest! { + #[test] + fn model_length_matches_insertions(count in 0usize..50) { + let mut builder = Model::::builder(); + for i in 0..count { + builder = builder.insert(move |b| b.text(format!("Item {i}"))); + } + let model = builder.build(); + prop_assert_eq!(model.len(), count); + } + + #[test] + fn clear_always_empties_model(count in 1usize..20) { + let mut builder = Model::::builder(); + for i in 0..count { + builder = builder.insert(move |b| b.text(format!("Item {i}"))); + } + let mut model = builder.build(); + model.clear(); + prop_assert_eq!(model.len(), 0); + } + + #[test] + fn multi_select_activate_is_toggle(count in 2usize..10) { + let mut builder = Model::::builder(); + for i in 0..count { + builder = builder.insert(move |b| b.text(format!("Item {i}"))); + } + let mut model = builder.build(); + + // Collect entities from the model + let ids: Vec<_> = model.iter().collect(); + + // Activate first item + model.activate(ids[0]); + prop_assert!(model.is_active(ids[0])); + + // Toggle it off + model.activate(ids[0]); + prop_assert!(!model.is_active(ids[0])); + } + } +} + +// --------------------------------------------------------------------------- +// Theme spacing property tests +// --------------------------------------------------------------------------- +mod theme_props { + use super::*; + + #[test] + fn spacing_values_monotonically_increase() { + let spacing = cosmic::theme::spacing(); + // xxs < xs < s < m < l < xl < xxl + assert!(spacing.space_xxs <= spacing.space_xs); + assert!(spacing.space_xs <= spacing.space_s); + assert!(spacing.space_s <= spacing.space_m); + assert!(spacing.space_m <= spacing.space_l); + assert!(spacing.space_l <= spacing.space_xl); + assert!(spacing.space_xl <= spacing.space_xxl); + } + + proptest! { + #[test] + fn cosmic_theme_dark_default_is_valid(_dummy in 0..1u8) { + let theme = cosmic_theme::Theme::dark_default(); + prop_assert!(theme.is_dark); + prop_assert!(theme.spacing.space_xxs > 0); + } + + #[test] + fn cosmic_theme_light_default_is_valid(_dummy in 0..1u8) { + let theme = cosmic_theme::Theme::light_default(); + prop_assert!(!theme.is_dark); + prop_assert!(theme.spacing.space_xxs > 0); + } + } +} diff --git a/tests/snapshot_harness/mod.rs b/tests/snapshot_harness/mod.rs new file mode 100644 index 00000000..d4e11b35 --- /dev/null +++ b/tests/snapshot_harness/mod.rs @@ -0,0 +1,355 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Snapshot testing harness for COSMIC widgets. +//! +//! Renders widgets to pixel buffers using the tiny-skia software renderer +//! and compares against stored reference images. Provides deterministic, +//! headless visual regression testing without requiring a display server. + +use cosmic::iced_core::{self, Rectangle, Size, layout, mouse, renderer}; +use cosmic::iced_core::widget::Tree; + +use std::path::{Path, PathBuf}; + +/// Maximum per-pixel color channel difference allowed before a mismatch +/// is flagged. Accounts for minor anti-aliasing and rounding differences. +const PIXEL_TOLERANCE: u8 = 2; + +/// Maximum fraction of pixels allowed to differ before the snapshot is +/// considered a failure (0.01 = 1%). +const MISMATCH_THRESHOLD: f64 = 0.01; + +/// Test harness for rendering COSMIC widgets to pixel buffers. +pub struct SnapshotHarness { + width: u32, + height: u32, +} + +impl SnapshotHarness { + /// Create a new harness with the given pixel dimensions. + pub fn new(width: u32, height: u32) -> Self { + Self { width, height } + } + + /// Render an element and compare it against a stored reference snapshot. + /// + /// If no reference exists, the rendered image is saved as the new reference. + /// If a reference exists, the rendered image is compared pixel-by-pixel. + pub fn snapshot<'a>( + &self, + name: &str, + element: cosmic::Element<'a, ()>, + ) { + let pixels = self.render_element(element); + let snapshot_dir = snapshot_dir(); + + std::fs::create_dir_all(&snapshot_dir) + .expect("Failed to create snapshots directory"); + + let reference_path = snapshot_dir.join(format!("{name}.png")); + let actual_path = snapshot_dir.join(format!("{name}.actual.png")); + let diff_path = snapshot_dir.join(format!("{name}.diff.png")); + + // Save the current render + save_rgba_png(&actual_path, &pixels, self.width, self.height); + + if reference_path.exists() { + // Compare against reference + let reference = load_png_rgba(&reference_path); + let result = compare_images( + &reference.data, + reference.width, + reference.height, + &pixels, + self.width, + self.height, + ); + + match result { + CompareResult::Match => { + // Clean up actual file on success + let _ = std::fs::remove_file(&actual_path); + let _ = std::fs::remove_file(&diff_path); + } + CompareResult::SizeMismatch { + ref_w, + ref_h, + act_w, + act_h, + } => { + panic!( + "Snapshot '{name}' size mismatch: reference is {ref_w}x{ref_h}, \ + actual is {act_w}x{act_h}. \ + Actual saved to: {}\n\ + To update, delete the reference and re-run.", + actual_path.display() + ); + } + CompareResult::PixelMismatch { + mismatch_count, + total_pixels, + diff_image, + } => { + let pct = (mismatch_count as f64 / total_pixels as f64) * 100.0; + save_rgba_png(&diff_path, &diff_image, self.width, self.height); + panic!( + "Snapshot '{name}' has {mismatch_count}/{total_pixels} pixels \ + different ({pct:.2}%). Threshold: {:.2}%\n\ + Actual: {}\n\ + Diff: {}\n\ + To update, delete the reference and re-run.", + MISMATCH_THRESHOLD * 100.0, + actual_path.display(), + diff_path.display(), + ); + } + } + } else { + // No reference exists - promote actual to reference + std::fs::rename(&actual_path, &reference_path).expect("Failed to save reference"); + eprintln!( + "Created new snapshot reference: {}", + reference_path.display() + ); + } + } + + /// Render a COSMIC element to an RGBA pixel buffer. + fn render_element<'a>(&self, mut element: cosmic::Element<'a, ()>) -> Vec { + let w = self.width as f32; + let h = self.height as f32; + + // Build the widget tree + let mut tree = Tree::new(element.as_widget()); + + // Create a tiny-skia renderer + let mut renderer = iced_tiny_skia::Renderer::new( + iced_core::Font::DEFAULT, + iced_core::Pixels(14.0), + ); + + // Compute layout + let limits = layout::Limits::new(Size::ZERO, Size::new(w, h)); + let node = element.as_widget_mut().layout(&mut tree, &renderer, &limits); + let layout_obj = layout::Layout::new(&node); + + // Get theme + let cosmic_theme = cosmic::theme::active(); + + // Draw widget + let viewport = Rectangle { + x: 0.0, + y: 0.0, + width: w, + height: h, + }; + + let style = renderer::Style { + text_color: iced_core::Color::WHITE, + icon_color: iced_core::Color::WHITE, + scale_factor: 1.0, + }; + + element.as_widget().draw( + &tree, + &mut renderer, + &cosmic_theme, + &style, + layout_obj, + mouse::Cursor::Unavailable, + &viewport, + ); + + // Render to pixel buffer + let phys_w = self.width; + let phys_h = self.height; + let mut pixmap = tiny_skia::Pixmap::new(phys_w, phys_h) + .expect("Failed to create pixmap"); + + let viewport_obj = iced_tiny_skia::graphics::Viewport::with_logical_size( + Size::new(w, h), + 1.0, + ); + + let mut mask = tiny_skia::Mask::new(phys_w, phys_h) + .expect("Failed to create mask"); + + let damage = vec![Rectangle { + x: 0.0, + y: 0.0, + width: w, + height: h, + }]; + + let bg_color = iced_core::Color::from_rgb(0.15, 0.15, 0.15); + let overlay: &[String] = &[]; + + renderer.draw( + &mut pixmap.as_mut(), + &mut mask, + &viewport_obj, + &damage, + bg_color, + overlay, + ); + + pixmap.data().to_vec() + } +} + +/// Directory where snapshot reference images are stored. +fn snapshot_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("snapshots") +} + +/// Save RGBA pixel data as a PNG file. +fn save_rgba_png(path: &Path, data: &[u8], width: u32, height: u32) { + let img = image::RgbaImage::from_raw(width, height, data.to_vec()) + .expect("Invalid image dimensions"); + img.save(path) + .unwrap_or_else(|e| panic!("Failed to save PNG to {}: {e}", path.display())); +} + +/// Loaded PNG image data. +struct PngImage { + data: Vec, + width: u32, + height: u32, +} + +/// Load a PNG file as RGBA pixel data. +fn load_png_rgba(path: &Path) -> PngImage { + let img = image::open(path) + .unwrap_or_else(|e| panic!("Failed to load PNG from {}: {e}", path.display())) + .to_rgba8(); + let width = img.width(); + let height = img.height(); + PngImage { + data: img.into_raw(), + width, + height, + } +} + +/// Result of comparing two images. +enum CompareResult { + Match, + SizeMismatch { + ref_w: u32, + ref_h: u32, + act_w: u32, + act_h: u32, + }, + PixelMismatch { + mismatch_count: usize, + total_pixels: usize, + diff_image: Vec, + }, +} + +/// Compare two RGBA images pixel-by-pixel. +fn compare_images( + reference: &[u8], + ref_w: u32, + ref_h: u32, + actual: &[u8], + act_w: u32, + act_h: u32, +) -> CompareResult { + if ref_w != act_w || ref_h != act_h { + return CompareResult::SizeMismatch { + ref_w, + ref_h, + act_w, + act_h, + }; + } + + let total_pixels = (ref_w * ref_h) as usize; + let mut mismatch_count = 0; + let mut diff_image = vec![0u8; reference.len()]; + + for i in 0..total_pixels { + let offset = i * 4; + let r_diff = (reference[offset] as i16 - actual[offset] as i16).unsigned_abs() as u8; + let g_diff = + (reference[offset + 1] as i16 - actual[offset + 1] as i16).unsigned_abs() as u8; + let b_diff = + (reference[offset + 2] as i16 - actual[offset + 2] as i16).unsigned_abs() as u8; + let a_diff = + (reference[offset + 3] as i16 - actual[offset + 3] as i16).unsigned_abs() as u8; + + if r_diff > PIXEL_TOLERANCE + || g_diff > PIXEL_TOLERANCE + || b_diff > PIXEL_TOLERANCE + || a_diff > PIXEL_TOLERANCE + { + mismatch_count += 1; + // Highlight differences in red + diff_image[offset] = 255; + diff_image[offset + 1] = 0; + diff_image[offset + 2] = 0; + diff_image[offset + 3] = 255; + } else { + // Dim matching pixels + diff_image[offset] = actual[offset] / 3; + diff_image[offset + 1] = actual[offset + 1] / 3; + diff_image[offset + 2] = actual[offset + 2] / 3; + diff_image[offset + 3] = 255; + } + } + + let mismatch_fraction = mismatch_count as f64 / total_pixels as f64; + if mismatch_fraction <= MISMATCH_THRESHOLD { + CompareResult::Match + } else { + CompareResult::PixelMismatch { + mismatch_count, + total_pixels, + diff_image, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compare_identical_images() { + let data = vec![128u8; 4 * 10 * 10]; + let result = compare_images(&data, 10, 10, &data, 10, 10); + assert!(matches!(result, CompareResult::Match)); + } + + #[test] + fn compare_different_sizes() { + let small = vec![0u8; 4 * 5 * 5]; + let big = vec![0u8; 4 * 10 * 10]; + let result = compare_images(&small, 5, 5, &big, 10, 10); + assert!(matches!(result, CompareResult::SizeMismatch { .. })); + } + + #[test] + fn compare_within_tolerance() { + let a = vec![100u8; 4 * 4 * 4]; + let mut b = a.clone(); + // Change one channel by 1 (within tolerance of 2) + b[0] = 101; + let result = compare_images(&a, 4, 4, &b, 4, 4); + assert!(matches!(result, CompareResult::Match)); + } + + #[test] + fn compare_exceeds_tolerance() { + let a = vec![100u8; 4 * 2 * 2]; // 4 pixels + let mut b = a.clone(); + // Make all pixels differ significantly + for i in (0..b.len()).step_by(4) { + b[i] = 200; + } + let result = compare_images(&a, 2, 2, &b, 2, 2); + assert!(matches!(result, CompareResult::PixelMismatch { .. })); + } +} diff --git a/tests/snapshots/column_layout.png b/tests/snapshots/column_layout.png new file mode 100644 index 00000000..1a3026e8 Binary files /dev/null and b/tests/snapshots/column_layout.png differ diff --git a/tests/snapshots/container_with_background.png b/tests/snapshots/container_with_background.png new file mode 100644 index 00000000..02e5f535 Binary files /dev/null and b/tests/snapshots/container_with_background.png differ diff --git a/tests/snapshots/dark_theme_container.png b/tests/snapshots/dark_theme_container.png new file mode 100644 index 00000000..b00c56a3 Binary files /dev/null and b/tests/snapshots/dark_theme_container.png differ diff --git a/tests/snapshots/divider_heavy.png b/tests/snapshots/divider_heavy.png new file mode 100644 index 00000000..8b6a1666 Binary files /dev/null and b/tests/snapshots/divider_heavy.png differ diff --git a/tests/snapshots/divider_horizontal.png b/tests/snapshots/divider_horizontal.png new file mode 100644 index 00000000..e0910fef Binary files /dev/null and b/tests/snapshots/divider_horizontal.png differ diff --git a/tests/snapshots/nested_containers.png b/tests/snapshots/nested_containers.png new file mode 100644 index 00000000..7d0ad195 Binary files /dev/null and b/tests/snapshots/nested_containers.png differ diff --git a/tests/snapshots/progress_bar_states.png b/tests/snapshots/progress_bar_states.png new file mode 100644 index 00000000..03f77d2f Binary files /dev/null and b/tests/snapshots/progress_bar_states.png differ diff --git a/tests/snapshots/row_layout.png b/tests/snapshots/row_layout.png new file mode 100644 index 00000000..43a8471e Binary files /dev/null and b/tests/snapshots/row_layout.png differ diff --git a/tests/snapshots/text_styles.png b/tests/snapshots/text_styles.png new file mode 100644 index 00000000..683f5b9a Binary files /dev/null and b/tests/snapshots/text_styles.png differ diff --git a/tests/theme_tests.rs b/tests/theme_tests.rs new file mode 100644 index 00000000..d34dc0f1 --- /dev/null +++ b/tests/theme_tests.rs @@ -0,0 +1,291 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Theme and style consistency tests for COSMIC. +//! +//! These tests verify that the COSMIC theme system produces consistent, +//! valid output across different theme variants (light, dark, high-contrast). +//! They catch issues like missing colors, zero-alpha components, and +//! inconsistencies between theme variants. + +use cosmic::theme; +use cosmic_theme::Theme as CosmicTheme; + +// --------------------------------------------------------------------------- +// Theme variant consistency tests +// --------------------------------------------------------------------------- + +fn all_themes() -> Vec<(&'static str, &'static CosmicTheme)> { + vec![ + ("dark", &*theme::COSMIC_DARK), + ("light", &*theme::COSMIC_LIGHT), + ("hc_dark", &*theme::COSMIC_HC_DARK), + ("hc_light", &*theme::COSMIC_HC_LIGHT), + ] +} + +#[test] +fn all_themes_have_valid_spacing() { + for (name, t) in all_themes() { + assert!(t.spacing.space_xxs > 0, "{name}: space_xxs is zero"); + assert!(t.spacing.space_xs > 0, "{name}: space_xs is zero"); + assert!(t.spacing.space_s > 0, "{name}: space_s is zero"); + assert!(t.spacing.space_m > 0, "{name}: space_m is zero"); + assert!(t.spacing.space_l > 0, "{name}: space_l is zero"); + assert!(t.spacing.space_xl > 0, "{name}: space_xl is zero"); + assert!(t.spacing.space_xxl > 0, "{name}: space_xxl is zero"); + } +} + +#[test] +fn all_themes_have_valid_corner_radii() { + for (name, t) in all_themes() { + let radii = &t.corner_radii; + // Corner radii should be non-negative + for r in &radii.radius_0 { + assert!(*r >= 0.0, "{name}: negative radius_0"); + } + for r in &radii.radius_xs { + assert!(*r >= 0.0, "{name}: negative radius_xs"); + } + for r in &radii.radius_s { + assert!(*r >= 0.0, "{name}: negative radius_s"); + } + for r in &radii.radius_m { + assert!(*r >= 0.0, "{name}: negative radius_m"); + } + for r in &radii.radius_l { + assert!(*r >= 0.0, "{name}: negative radius_l"); + } + for r in &radii.radius_xl { + assert!(*r >= 0.0, "{name}: negative radius_xl"); + } + } +} + +#[test] +fn all_themes_have_non_zero_alpha_accent() { + for (name, t) in all_themes() { + let accent = &t.accent; + assert!( + accent.base.alpha > 0.0, + "{name}: accent base alpha is zero" + ); + } +} + +#[test] +fn dark_themes_have_is_dark_set() { + assert!(theme::COSMIC_DARK.is_dark); + assert!(theme::COSMIC_HC_DARK.is_dark); +} + +#[test] +fn light_themes_have_is_dark_unset() { + assert!(!theme::COSMIC_LIGHT.is_dark); + assert!(!theme::COSMIC_HC_LIGHT.is_dark); +} + +#[test] +fn all_themes_have_non_zero_alpha_on_bg() { + for (name, t) in all_themes() { + let bg = &t.background; + assert!( + bg.base.alpha > 0.0, + "{name}: background base alpha is zero" + ); + } +} + +#[test] +fn all_themes_have_non_zero_alpha_on_primary() { + for (name, t) in all_themes() { + let primary = &t.primary; + assert!( + primary.base.alpha > 0.0, + "{name}: primary base alpha is zero" + ); + } +} + +#[test] +fn all_themes_have_non_zero_alpha_on_destructive() { + for (name, t) in all_themes() { + let dest = &t.destructive; + assert!( + dest.base.alpha > 0.0, + "{name}: destructive base alpha is zero" + ); + } +} + +#[test] +fn all_themes_have_non_zero_alpha_on_warning() { + for (name, t) in all_themes() { + let warn = &t.warning; + assert!( + warn.base.alpha > 0.0, + "{name}: warning base alpha is zero" + ); + } +} + +#[test] +fn all_themes_have_non_zero_alpha_on_success() { + for (name, t) in all_themes() { + let success = &t.success; + assert!( + success.base.alpha > 0.0, + "{name}: success base alpha is zero" + ); + } +} + +// --------------------------------------------------------------------------- +// Component state consistency tests +// --------------------------------------------------------------------------- + +/// Verify that all states of a Component have non-zero alpha. +fn assert_component_states_visible(name: &str, variant: &str, component: &cosmic_theme::Component) { + assert!( + component.base.alpha > 0.0, + "{name}/{variant}: base alpha is zero" + ); + // hover/pressed/selected states can have zero alpha in some themes, but + // the 'on' color (text on top) should always be visible + assert!( + component.on.alpha > 0.0, + "{name}/{variant}: 'on' (text) alpha is zero" + ); +} + +#[test] +fn background_component_states_visible() { + for (name, t) in all_themes() { + assert_component_states_visible(name, "bg.component", &t.background.component); + } +} + +#[test] +fn primary_component_states_visible() { + for (name, t) in all_themes() { + assert_component_states_visible(name, "primary.component", &t.primary.component); + } +} + +// --------------------------------------------------------------------------- +// Serialization round-trip test +// --------------------------------------------------------------------------- + +#[test] +fn dark_theme_serializes_to_ron() { + let theme = CosmicTheme::dark_default(); + let serialized = ron::to_string(&theme); + assert!(serialized.is_ok(), "Dark theme failed to serialize to RON"); +} + +#[test] +fn light_theme_serializes_to_ron() { + let theme = CosmicTheme::light_default(); + let serialized = ron::to_string(&theme); + assert!(serialized.is_ok(), "Light theme failed to serialize to RON"); +} + +#[test] +fn dark_theme_ron_round_trip() { + let theme = CosmicTheme::dark_default(); + let serialized = ron::to_string(&theme).expect("serialize"); + let deserialized: CosmicTheme = ron::from_str(&serialized).expect("deserialize"); + + assert_eq!(theme.is_dark, deserialized.is_dark); + assert_eq!(theme.spacing.space_m, deserialized.spacing.space_m); +} + +#[test] +fn light_theme_ron_round_trip() { + let theme = CosmicTheme::light_default(); + let serialized = ron::to_string(&theme).expect("serialize"); + let deserialized: CosmicTheme = ron::from_str(&serialized).expect("deserialize"); + + assert_eq!(theme.is_dark, deserialized.is_dark); + assert_eq!(theme.spacing.space_m, deserialized.spacing.space_m); +} + +// --------------------------------------------------------------------------- +// Spacing monotonicity +// --------------------------------------------------------------------------- + +#[test] +fn all_themes_spacing_is_monotonic() { + for (name, t) in all_themes() { + let s = &t.spacing; + assert!( + s.space_xxs <= s.space_xs, + "{name}: xxs({}) > xs({})", + s.space_xxs, + s.space_xs + ); + assert!( + s.space_xs <= s.space_s, + "{name}: xs({}) > s({})", + s.space_xs, + s.space_s + ); + assert!( + s.space_s <= s.space_m, + "{name}: s({}) > m({})", + s.space_s, + s.space_m + ); + assert!( + s.space_m <= s.space_l, + "{name}: m({}) > l({})", + s.space_m, + s.space_l + ); + assert!( + s.space_l <= s.space_xl, + "{name}: l({}) > xl({})", + s.space_l, + s.space_xl + ); + assert!( + s.space_xl <= s.space_xxl, + "{name}: xl({}) > xxl({})", + s.space_xl, + s.space_xxl + ); + } +} + +// --------------------------------------------------------------------------- +// Corner radii ordering +// --------------------------------------------------------------------------- + +#[test] +fn all_themes_corner_radii_are_ordered() { + for (name, t) in all_themes() { + let r = &t.corner_radii; + // radius_0 should be all zeros + for val in &r.radius_0 { + assert_eq!(*val, 0.0, "{name}: radius_0 is not zero"); + } + // Each subsequent radius should be >= the previous (at index 0) + assert!( + r.radius_xs[0] <= r.radius_s[0], + "{name}: radius_xs > radius_s" + ); + assert!( + r.radius_s[0] <= r.radius_m[0], + "{name}: radius_s > radius_m" + ); + assert!( + r.radius_m[0] <= r.radius_l[0], + "{name}: radius_m > radius_l" + ); + assert!( + r.radius_l[0] <= r.radius_xl[0], + "{name}: radius_l > radius_xl" + ); + } +} diff --git a/tests/visual_tests.rs b/tests/visual_tests.rs new file mode 100644 index 00000000..a756e320 --- /dev/null +++ b/tests/visual_tests.rs @@ -0,0 +1,183 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Visual snapshot tests for COSMIC widgets. +//! +//! These tests render widgets using the tiny-skia software renderer and compare +//! the output against stored reference snapshots. This catches unintended visual +//! regressions in widget rendering, theming, and layout. +//! +//! # Updating Snapshots +//! +//! When a visual change is intentional, delete the old snapshot PNG from +//! `tests/snapshots/` and re-run the tests. The new reference will be generated +//! automatically. + +mod snapshot_harness; + +use cosmic::iced_core::Length; +use cosmic::theme; +use snapshot_harness::SnapshotHarness; + +/// Helper to render a cosmic Element to a snapshot. +fn render_element_snapshot( + name: &str, + width: u32, + height: u32, + element: cosmic::Element<'_, ()>, +) { + let harness = SnapshotHarness::new(width, height); + harness.snapshot(name, element); +} + +// --------------------------------------------------------------------------- +// Theme rendering tests - verify that the cosmic theme system produces +// consistent visual output for core UI primitives. +// --------------------------------------------------------------------------- + +#[test] +fn snapshot_container_with_background() { + use cosmic::widget::{container, text}; + + let content = text::body("Hello, COSMIC!") + .apply(container) + .padding(16) + .width(Length::Fixed(200.0)) + .height(Length::Fixed(60.0)) + .class(theme::Container::Primary); + + render_element_snapshot("container_with_background", 220, 80, content.into()); +} + +#[test] +fn snapshot_divider_horizontal() { + use cosmic::widget::{column, container, divider}; + + let content = column::with_capacity(3) + .push(cosmic::widget::text::body("Above")) + .push(divider::horizontal::default()) + .push(cosmic::widget::text::body("Below")) + .width(Length::Fixed(200.0)) + .apply(container) + .padding(8); + + render_element_snapshot("divider_horizontal", 220, 100, content.into()); +} + +#[test] +fn snapshot_divider_heavy() { + use cosmic::widget::{column, container, divider}; + + let content = column::with_capacity(3) + .push(cosmic::widget::text::body("Above")) + .push(divider::horizontal::heavy()) + .push(cosmic::widget::text::body("Below")) + .width(Length::Fixed(200.0)) + .apply(container) + .padding(8); + + render_element_snapshot("divider_heavy", 220, 100, content.into()); +} + +#[test] +fn snapshot_text_styles() { + use cosmic::widget::{column, container, text}; + + let content = column::with_capacity(4) + .push(text::title3("Title Text")) + .push(text::body("Body text content")) + .push(text::caption("Caption text")) + .push(text::monotext("Monospace text")) + .spacing(8) + .width(Length::Fixed(300.0)) + .apply(container) + .padding(16); + + render_element_snapshot("text_styles", 340, 200, content.into()); +} + +#[test] +fn snapshot_progress_bar() { + use cosmic::widget::{column, container, progress_bar}; + + let content = column::with_capacity(3) + .push(progress_bar(0.0..=100.0, 0.0).width(Length::Fixed(250.0))) + .push(progress_bar(0.0..=100.0, 50.0).width(Length::Fixed(250.0))) + .push(progress_bar(0.0..=100.0, 100.0).width(Length::Fixed(250.0))) + .spacing(12) + .apply(container) + .padding(16); + + render_element_snapshot("progress_bar_states", 290, 120, content.into()); +} + +#[test] +fn snapshot_row_layout() { + use cosmic::widget::{container, row, text}; + + let content = row::with_capacity(3) + .push(text::body("Left")) + .push(text::body("Center")) + .push(text::body("Right")) + .spacing(16) + .apply(container) + .padding(12); + + render_element_snapshot("row_layout", 300, 60, content.into()); +} + +#[test] +fn snapshot_column_layout() { + use cosmic::widget::{column, container, text}; + + let content = column::with_capacity(3) + .push(text::body("First")) + .push(text::body("Second")) + .push(text::body("Third")) + .spacing(8) + .apply(container) + .padding(12); + + render_element_snapshot("column_layout", 200, 120, content.into()); +} + +#[test] +fn snapshot_nested_containers() { + use cosmic::widget::{column, container, row, text}; + + let inner = column::with_capacity(2) + .push(text::body("Nested")) + .push(text::caption("Content")) + .spacing(4) + .apply(container) + .padding(8) + .class(theme::Container::Primary); + + let outer = row::with_capacity(2) + .push(inner) + .push(text::body("Adjacent")) + .spacing(16) + .apply(container) + .padding(12); + + render_element_snapshot("nested_containers", 300, 120, outer.into()); +} + +#[test] +fn snapshot_dark_theme_container() { + use cosmic::widget::{container, text}; + + // Set dark theme temporarily for this test + let content = text::body("Dark Theme") + .apply(container) + .padding(16) + .width(Length::Fixed(200.0)) + .height(Length::Fixed(60.0)) + .class(theme::Container::Primary); + + // The default theme is dark, so this tests the default rendering + render_element_snapshot("dark_theme_container", 220, 80, content.into()); +} + +// We use `apply` from the Apply trait for chaining +use apply::Apply; diff --git a/tests/widget_unit_tests.rs b/tests/widget_unit_tests.rs new file mode 100644 index 00000000..6a8e1b09 --- /dev/null +++ b/tests/widget_unit_tests.rs @@ -0,0 +1,555 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Unit tests for COSMIC widget logic and models. +//! +//! These tests verify the behavioral correctness of widget models, +//! helper functions, and data structures without requiring a renderer. + +// --------------------------------------------------------------------------- +// Spin button logic tests +// --------------------------------------------------------------------------- +mod spin_button_tests { + use cosmic::widget::spin_button; + + // Test the internal increment/decrement functions via the public API. + // The functions are private, so we test through behavior. + + #[test] + fn spin_button_clamps_value_to_min() { + // When value is below min, it should be clamped + let button = spin_button::spin_button( + "-5", + #[cfg(feature = "a11y")] + "test", + -5i32, + 1, + 0, + 100, + |v| v, + ); + // The SpinButton constructor clamps value to min when value < min + // We verify this through the element creation (it won't panic) + let _: cosmic::Element<'_, i32> = button.into(); + } + + #[test] + fn spin_button_clamps_value_to_max() { + let button = spin_button::spin_button( + "150", + #[cfg(feature = "a11y")] + "test", + 150i32, + 1, + 0, + 100, + |v| v, + ); + let _: cosmic::Element<'_, i32> = button.into(); + } + + #[test] + fn spin_button_accepts_valid_range() { + let button = spin_button::spin_button( + "50", + #[cfg(feature = "a11y")] + "test", + 50i32, + 1, + 0, + 100, + |v| v, + ); + let _: cosmic::Element<'_, i32> = button.into(); + } + + #[test] + fn spin_button_vertical_variant() { + let button = spin_button::vertical( + "25", + #[cfg(feature = "a11y")] + "vertical test", + 25i32, + 5, + 0, + 100, + |v| v, + ); + let _: cosmic::Element<'_, i32> = button.into(); + } + + #[test] + fn spin_button_float_values() { + let button = spin_button::spin_button( + "3.14", + #[cfg(feature = "a11y")] + "float test", + 3.14f64, + 0.1, + 0.0, + 10.0, + |v| v, + ); + let _: cosmic::Element<'_, f64> = button.into(); + } +} + +// --------------------------------------------------------------------------- +// Calendar model tests +// --------------------------------------------------------------------------- +mod calendar_model_tests { + use chrono::{Datelike, NaiveDate, Weekday}; + use cosmic::widget::calendar::{CalendarModel, get_calender_first, set_day}; + + #[test] + fn calendar_model_creation() { + let model = CalendarModel::now(); + assert_eq!(model.selected, model.visible); + } + + #[test] + fn calendar_model_custom_date() { + let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(); + let model = CalendarModel::new(date, date); + assert_eq!(model.selected.month(), 6); + assert_eq!(model.selected.day(), 15); + } + + #[test] + fn calendar_show_prev_month() { + let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(); + let mut model = CalendarModel::new(date, date); + model.show_prev_month(); + assert_eq!(model.visible.month(), 2); + assert_eq!(model.selected, date); // selected unchanged + } + + #[test] + fn calendar_show_next_month() { + let date = NaiveDate::from_ymd_opt(2024, 11, 15).unwrap(); + let mut model = CalendarModel::new(date, date); + model.show_next_month(); + assert_eq!(model.visible.month(), 12); + } + + #[test] + fn calendar_show_prev_month_wraps_year() { + let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(); + let mut model = CalendarModel::new(date, date); + model.show_prev_month(); + assert_eq!(model.visible.month(), 12); + assert_eq!(model.visible.year(), 2023); + } + + #[test] + fn calendar_show_next_month_wraps_year() { + let date = NaiveDate::from_ymd_opt(2024, 12, 15).unwrap(); + let mut model = CalendarModel::new(date, date); + model.show_next_month(); + assert_eq!(model.visible.month(), 1); + assert_eq!(model.visible.year(), 2025); + } + + #[test] + fn calendar_set_prev_month_updates_selected() { + let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(); + let mut model = CalendarModel::new(date, date); + model.set_prev_month(); + assert_eq!(model.selected, model.visible); + assert_eq!(model.visible.month(), 5); + } + + #[test] + fn calendar_set_next_month_updates_selected() { + let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(); + let mut model = CalendarModel::new(date, date); + model.set_next_month(); + assert_eq!(model.selected, model.visible); + assert_eq!(model.visible.month(), 7); + } + + #[test] + fn calendar_set_selected_visible() { + let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(); + let new_date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let mut model = CalendarModel::new(date, date); + model.set_selected_visible(new_date); + assert_eq!(model.selected, new_date); + assert_eq!(model.visible, new_date); + } + + #[test] + fn set_day_increases() { + let date = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap(); + let result = set_day(date, 20); + assert_eq!(result.day(), 20); + assert_eq!(result.month(), 6); + } + + #[test] + fn set_day_decreases() { + let date = NaiveDate::from_ymd_opt(2024, 6, 20).unwrap(); + let result = set_day(date, 5); + assert_eq!(result.day(), 5); + } + + #[test] + fn set_day_same_returns_original() { + let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(); + let result = set_day(date, 15); + assert_eq!(result, date); + } + + #[test] + fn get_calendar_first_monday_start() { + // June 2024 starts on a Saturday + let first = get_calender_first(2024, 6, Weekday::Mon); + assert_eq!(first.weekday(), Weekday::Mon); + // The first Monday before June 1 (Saturday) is May 27 + assert_eq!(first.day(), 27); + assert_eq!(first.month(), 5); + } + + #[test] + fn get_calendar_first_sunday_start() { + let first = get_calender_first(2024, 6, Weekday::Sun); + assert_eq!(first.weekday(), Weekday::Sun); + assert_eq!(first.day(), 26); + assert_eq!(first.month(), 5); + } + + #[test] + fn get_calendar_first_when_month_starts_on_first_day() { + // April 2024 starts on a Monday + let first = get_calender_first(2024, 4, Weekday::Mon); + assert_eq!(first.day(), 1); + assert_eq!(first.month(), 4); + } +} + +// --------------------------------------------------------------------------- +// Segmented button model tests +// --------------------------------------------------------------------------- +mod segmented_model_tests { + use cosmic::widget::segmented_button::{Model, SingleSelect, MultiSelect}; + + #[test] + fn builder_creates_items_in_order() { + let model: Model = Model::builder() + .insert(|b| b.text("First")) + .insert(|b| b.text("Second")) + .insert(|b| b.text("Third")) + .build(); + + let texts: Vec<&str> = model + .iter() + .filter_map(|id| model.text(id)) + .collect(); + + assert_eq!(texts, vec!["First", "Second", "Third"]); + } + + #[test] + fn single_select_activates_only_one() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("A").with_id(|id| ids.push(id))) + .insert(|b| b.text("B").with_id(|id| ids.push(id))) + .insert(|b| b.text("C").with_id(|id| ids.push(id))) + .build(); + + model.activate(ids[0]); + assert!(model.is_active(ids[0])); + assert!(!model.is_active(ids[1])); + + model.activate(ids[1]); + assert!(!model.is_active(ids[0])); + assert!(model.is_active(ids[1])); + } + + #[test] + fn multi_select_toggles_items() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("A").with_id(|id| ids.push(id))) + .insert(|b| b.text("B").with_id(|id| ids.push(id))) + .build(); + + model.activate(ids[0]); + model.activate(ids[1]); + assert!(model.is_active(ids[0])); + assert!(model.is_active(ids[1])); + + // Toggle off + model.activate(ids[0]); + assert!(!model.is_active(ids[0])); + assert!(model.is_active(ids[1])); + } + + #[test] + fn remove_item_reduces_length() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("A").with_id(|id| ids.push(id))) + .insert(|b| b.text("B").with_id(|id| ids.push(id))) + .insert(|b| b.text("C").with_id(|id| ids.push(id))) + .build(); + + assert_eq!(model.len(), 3); + model.remove(ids[1]); + assert_eq!(model.len(), 2); + assert!(!model.contains_item(ids[1])); + } + + #[test] + fn clear_removes_all() { + let mut model: Model = Model::builder() + .insert(|b| b.text("A")) + .insert(|b| b.text("B")) + .build(); + + model.clear(); + assert_eq!(model.len(), 0); + } + + #[test] + fn data_storage_and_retrieval() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("Item").with_id(|id| ids.push(id))) + .build(); + + model.data_set::(ids[0], 42); + assert_eq!(model.data::(ids[0]), Some(&42)); + + model.data_remove::(ids[0]); + assert_eq!(model.data::(ids[0]), None); + } + + #[test] + fn text_set_and_remove() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("Original").with_id(|id| ids.push(id))) + .build(); + + assert_eq!(model.text(ids[0]), Some("Original")); + + model.text_set(ids[0], "Updated"); + assert_eq!(model.text(ids[0]), Some("Updated")); + + model.text_remove(ids[0]); + assert_eq!(model.text(ids[0]), None); + } + + #[test] + fn enable_disable_items() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("Item").with_id(|id| ids.push(id))) + .build(); + + assert!(model.is_enabled(ids[0])); + + model.enable(ids[0], false); + assert!(!model.is_enabled(ids[0])); + + model.enable(ids[0], true); + assert!(model.is_enabled(ids[0])); + } + + #[test] + fn position_operations() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("A").with_id(|id| ids.push(id))) + .insert(|b| b.text("B").with_id(|id| ids.push(id))) + .insert(|b| b.text("C").with_id(|id| ids.push(id))) + .build(); + + assert_eq!(model.position(ids[0]), Some(0)); + assert_eq!(model.position(ids[1]), Some(1)); + assert_eq!(model.position(ids[2]), Some(2)); + + // Move C to position 0 + model.position_set(ids[2], 0); + assert_eq!(model.position(ids[2]), Some(0)); + assert_eq!(model.position(ids[0]), Some(1)); + } + + #[test] + fn position_swap() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("A").with_id(|id| ids.push(id))) + .insert(|b| b.text("B").with_id(|id| ids.push(id))) + .build(); + + assert!(model.position_swap(ids[0], ids[1])); + assert_eq!(model.position(ids[0]), Some(1)); + assert_eq!(model.position(ids[1]), Some(0)); + } + + #[test] + fn entity_at_returns_correct_entity() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("A").with_id(|id| ids.push(id))) + .insert(|b| b.text("B").with_id(|id| ids.push(id))) + .build(); + + assert_eq!(model.entity_at(0), Some(ids[0])); + assert_eq!(model.entity_at(1), Some(ids[1])); + assert_eq!(model.entity_at(2), None); + } + + #[test] + fn activate_position_works() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("A").with_id(|id| ids.push(id))) + .insert(|b| b.text("B").with_id(|id| ids.push(id))) + .build(); + + assert!(model.activate_position(1)); + assert!(model.is_active(ids[1])); + + assert!(!model.activate_position(5)); // Out of bounds + } + + #[test] + fn closable_set_and_check() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("Tab").with_id(|id| ids.push(id))) + .build(); + + assert!(!model.is_closable(ids[0])); + + model.closable_set(ids[0], true); + assert!(model.is_closable(ids[0])); + } + + #[test] + fn indent_operations() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("Item").with_id(|id| ids.push(id))) + .build(); + + assert_eq!(model.indent(ids[0]), None); + + model.indent_set(ids[0], 2); + assert_eq!(model.indent(ids[0]), Some(2)); + + model.indent_remove(ids[0]); + assert_eq!(model.indent(ids[0]), None); + } + + #[test] + fn divider_above_operations() { + let mut ids = Vec::new(); + let mut model: Model = Model::builder() + .insert(|b| b.text("Item").with_id(|id| ids.push(id))) + .build(); + + assert_eq!(model.divider_above(ids[0]), None); + + model.divider_above_set(ids[0], true); + assert_eq!(model.divider_above(ids[0]), Some(true)); + + model.divider_above_remove(ids[0]); + assert_eq!(model.divider_above(ids[0]), None); + } + + #[test] + fn insert_returns_entity_mut() { + let mut model: Model = Model::builder().build(); + assert_eq!(model.len(), 0); + + let id = model.insert().text("Dynamic").id(); + assert_eq!(model.len(), 1); + assert_eq!(model.text(id), Some("Dynamic")); + } +} + +// --------------------------------------------------------------------------- +// Color picker model tests +// --------------------------------------------------------------------------- +mod color_picker_tests { + use cosmic::widget::color_picker::ColorPickerModel; + use iced_core::Color; + + #[test] + fn color_picker_model_creation() { + // Verify model can be created without panicking + let _model = ColorPickerModel::new("Hex", "RGB", None, None); + } + + #[test] + fn color_picker_with_initial_color() { + let color = Color::from_rgb(0.5, 0.3, 0.8); + let _model = ColorPickerModel::new("Hex", "RGB", Some(color), Some(color)); + } + + #[test] + fn color_picker_with_fallback() { + let fallback = Color::from_rgb(1.0, 0.0, 0.0); + let _model = ColorPickerModel::new("Hex", "RGB", Some(fallback), None); + } +} + +// --------------------------------------------------------------------------- +// Theme tests +// --------------------------------------------------------------------------- +mod theme_tests { + use cosmic::theme; + + #[test] + fn dark_theme_is_dark() { + let dark = &*theme::COSMIC_DARK; + assert!(dark.is_dark); + } + + #[test] + fn light_theme_is_light() { + let light = &*theme::COSMIC_LIGHT; + assert!(!light.is_dark); + } + + #[test] + fn high_contrast_dark_is_dark() { + let hc_dark = &*theme::COSMIC_HC_DARK; + assert!(hc_dark.is_dark); + } + + #[test] + fn high_contrast_light_is_light() { + let hc_light = &*theme::COSMIC_HC_LIGHT; + assert!(!hc_light.is_dark); + } + + #[test] + fn spacing_values_are_positive() { + let spacing = theme::spacing(); + assert!(spacing.space_xxs > 0); + assert!(spacing.space_xs > 0); + assert!(spacing.space_s > 0); + assert!(spacing.space_m > 0); + assert!(spacing.space_l > 0); + assert!(spacing.space_xl > 0); + assert!(spacing.space_xxl > 0); + } + + #[test] + fn default_theme_is_dark() { + assert!(theme::is_dark()); + } + + #[test] + fn active_theme_returns_valid_theme() { + let active = theme::active(); + // Should not panic + let _cosmic = active.cosmic(); + } +}