diff --git a/Cargo.lock b/Cargo.lock index 1e4cbd4..b1d7856 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2897,8 +2897,6 @@ name = "hcie-text" version = "0.1.0" dependencies = [ "approx", - "eframe", - "egui", "fontdue", "hcie-protocol", "proptest", diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..a137785 --- /dev/null +++ b/build.bat @@ -0,0 +1,17 @@ +@echo off +REM Canonical HCIE workspace build entry point (Windows) +REM Usage: build.bat [cargo-flags...] + +setlocal enabledelayedexpansion + +set ROOT_DIR=%~dp0 + +if "%~1"=="" ( + set CARGO_ARGS=--workspace +) else ( + set CARGO_ARGS=%* +) + +call "%ROOT_DIR%scripts\cargo-with-build-id.bat" build %CARGO_ARGS% + +exit /b %ERRORLEVEL% diff --git a/build_and_copy_plugins.bat b/build_and_copy_plugins.bat new file mode 100644 index 0000000..c8c19d1 --- /dev/null +++ b/build_and_copy_plugins.bat @@ -0,0 +1,37 @@ +@echo off +REM Build AI/Vision dynamic library plugins and copy to plugins directory (Windows) +REM Usage: build_and_copy_plugins.bat [debug] + +setlocal enabledelayedexpansion + +set PROFILE=release +set CARGO_FLAGS=--release + +if /I "%~1"=="debug" ( + set PROFILE=debug + set CARGO_FLAGS= + echo === Compiling plugins in DEBUG mode === +) else ( + echo === Compiling plugins in RELEASE mode === +) + +set PROJECT_DIR=C:\Projects\hcie-rust-v4 +set PLUGINS_DIR=%PROJECT_DIR%\plugins +mkdir "%PLUGINS_DIR%" 2>nul + +echo === Building AI/Vision dynamic libraries === + +echo 1. Building hcie-ai... +cargo build --manifest-path "%PROJECT_DIR%\hcie-ai\Cargo.toml" %CARGO_FLAGS% --features ffi + +echo 2. Building hcie-vision... +cargo build --manifest-path "%PROJECT_DIR%\hcie-vision\Cargo.toml" %CARGO_FLAGS% --features ffi + +REM On Windows, the output would be .dll files +REM Adjust the copy commands as needed for your Windows environment +copy "%PROJECT_DIR%\target\%PROFILE%\hcie_ai.dll" "%PLUGINS_DIR%\" 2>nul +copy "%PROJECT_DIR%\target\%PROFILE%\hcie_vision.dll" "%PLUGINS_DIR%\" 2>nul + +echo ==================================================== +echo AI/Vision plugins built and copied to %PLUGINS_DIR% in %PROFILE% mode! +echo ==================================================== diff --git a/countlines.bat b/countlines.bat new file mode 100644 index 0000000..7c55b2f --- /dev/null +++ b/countlines.bat @@ -0,0 +1,61 @@ +@echo off +REM ============================================================ +REM countlines.bat — HCIE-Rust Line Counter (Windows) +REM ============================================================ +setlocal enabledelayedexpansion + +set PROJECT_DIR=%~dp0 +set OUTPUT_FILE=line_count_report.txt + +echo ========================================= > "%OUTPUT_FILE%" +echo LINE COUNT REPORT >> "%OUTPUT_FILE%" +echo Project: HCIE-Rust v4 >> "%OUTPUT_FILE%" +echo Location: %PROJECT_DIR% >> "%OUTPUT_FILE%" +echo Timestamp: %DATE% %TIME% >> "%OUTPUT_FILE%" +echo ========================================= >> "%OUTPUT_FILE%" +echo. >> "%OUTPUT_FILE%" + +REM === RUST FILES (.rs) === +echo ----------------------------------------- +echo RUST (.rs) — hcie-* directories +echo ----------------------------------------- + +set GRAND_TOTAL_RUST=0 + +for /d %%d in ("%PROJECT_DIR%hcie-*") do ( + set CRATE_LINES=0 + for /r "%%d" %%f in (*.rs) do ( + set "FPATH=%%f" + echo !FPATH! | findstr /i /c:"\tests\" >nul + if errorlevel 1 ( + echo !FPATH! | findstr /i "test_" >nul + if errorlevel 1 ( + for /f "usebackq" %%c in (`find /c /v "" "%%f"`) do ( + if %%c gtr 0 ( + set /a CRATE_LINES+=%%c + ) + ) + ) + ) + ) + if !CRATE_LINES! gtr 0 ( + set "CRATE_NAME=%%~nxd" + echo !CRATE_NAME! !CRATE_LINES! lines + set /a GRAND_TOTAL_RUST+=!CRATE_LINES! + ) +) + +echo. +echo RUST TOTAL: %GRAND_TOTAL_RUST% lines +echo. + +REM === GRAND TOTAL === +echo ========================================= >> "%OUTPUT_FILE%" +echo GRAND TOTAL LINES OF CODE >> "%OUTPUT_FILE%" +echo ========================================= >> "%OUTPUT_FILE%" +echo Rust: %GRAND_TOTAL_RUST% >> "%OUTPUT_FILE%" +echo. >> "%OUTPUT_FILE%" +echo Report saved to: %CD%\%OUTPUT_FILE% + +echo. +echo Line count report saved to: %OUTPUT_FILE% diff --git a/hcie-document/tests/document_tests.rs b/hcie-document/tests/document_tests.rs new file mode 100644 index 0000000..025b1f7 --- /dev/null +++ b/hcie-document/tests/document_tests.rs @@ -0,0 +1,494 @@ +use std::path::PathBuf; + +/// Integration tests for hcie-document crate. +/// +/// Covers: document creation, layer CRUD, active layer switching, +/// visibility/opacity/blend-mode management, dirty tracking, zoom/pan, +/// undo/redo, selection, crop/resize. + +// --------------------------------------------------------------------------- +// Helper: create a small test document +// --------------------------------------------------------------------------- +fn make_doc() -> hcie_document::Document { + hcie_document::Document::new_blank("test", 64, 64, false) +} + +fn make_transparent_doc() -> hcie_document::Document { + hcie_document::Document::new_blank("transparent", 64, 64, true) +} + +// --------------------------------------------------------------------------- +// Document creation +// --------------------------------------------------------------------------- +#[test] +fn new_blank_document_has_correct_dimensions() { + let doc = make_doc(); + assert_eq!(doc.canvas_width, 64); + assert_eq!(doc.canvas_height, 64); +} + +#[test] +fn new_blank_document_has_one_layer() { + let doc = make_doc(); + assert_eq!(doc.layers.len(), 1, "new blank document must have exactly one layer"); +} + +#[test] +fn new_blank_document_name_is_set() { + let doc = make_doc(); + assert_eq!(doc.name, "test"); +} + +#[test] +fn new_blank_opaque_layer_is_white() { + let doc = make_doc(); + let layer = doc.active_layer().unwrap(); + let center = layer.get_pixel(32, 32); + assert_eq!(center, [255, 255, 255, 255], "opaque background should be white"); +} + +#[test] +fn new_transparent_layer_is_empty() { + let doc = make_transparent_doc(); + let layer = doc.active_layer().unwrap(); + let center = layer.get_pixel(32, 32); + assert_eq!(center, [0, 0, 0, 0], "transparent background should be all zeros"); +} + +#[test] +fn initial_zoom_is_one() { + let doc = make_doc(); + assert!((doc.zoom - 1.0).abs() < f32::EPSILON); +} + +#[test] +fn initial_pan_is_zero() { + let doc = make_doc(); + assert!((doc.pan_x).abs() < f32::EPSILON); + assert!((doc.pan_y).abs() < f32::EPSILON); +} + +// --------------------------------------------------------------------------- +// Tab label & modified flag +// --------------------------------------------------------------------------- +#[test] +fn tab_label_shows_modified_indicator() { + let mut doc = make_doc(); + assert!(!doc.tab_label().contains('●'), "clean doc should not show modified indicator"); + doc.modified = true; + assert!(doc.tab_label().contains('●'), "modified doc should show the dot"); +} + +// --------------------------------------------------------------------------- +// Layer CRUD +// --------------------------------------------------------------------------- +#[test] +fn add_layer_increases_count() { + let mut doc = make_doc(); + let id = doc.add_layer("Layer 2"); + assert_eq!(doc.layers.len(), 2); + assert!(id > 0); +} + +#[test] +fn add_layer_makes_it_active() { + let mut doc = make_doc(); + let id = doc.add_layer("Layer 2"); + // add_layer pushes to the end → index 1 when background is at 0 + assert_eq!(doc.active_layer, 1, "new layer should be active (last index)"); +} + +#[test] +fn delete_layer_reduces_count() { + let mut doc = make_doc(); + doc.add_layer("Layer 2"); + doc.delete_layer(1); + assert_eq!(doc.layers.len(), 1); +} + +#[test] +fn delete_last_layer_removes_it() { + let mut doc = make_doc(); + assert_eq!(doc.layers.len(), 1); + doc.delete_layer(0); + // The layer is removed; active_layer is clamped to the new (empty) len + assert_eq!(doc.layers.len(), 0, "deleting the only layer should remove it"); + assert_eq!(doc.active_layer, 0, "active_layer should clamp to 0"); +} + +#[test] +fn delete_layer_out_of_bounds_is_noop() { + let mut doc = make_doc(); + doc.delete_layer(99); + assert_eq!(doc.layers.len(), 1); +} + +#[test] +fn move_layer_changes_order() { + let mut doc = make_doc(); + doc.add_layer("Layer A"); // index 1 + doc.add_layer("Layer B"); // index 2 + // Move "Layer B" down to index 1 (before "Layer A") + doc.move_layer(2, 1); + assert_eq!(doc.layers[0].name, "Background"); + assert_eq!(doc.layers[1].name, "Layer B"); + assert_eq!(doc.layers[2].name, "Layer A"); +} + +#[test] +fn move_layer_identity_is_noop() { + let mut doc = make_doc(); + doc.add_layer("L2"); + doc.move_layer(1, 1); + assert_eq!(doc.layers.len(), 2); +} + +#[test] +fn all_layer_ids_returns_all() { + let mut doc = make_doc(); + doc.add_layer("L2"); + doc.add_layer("L3"); + let ids = doc.all_layer_ids(); + assert_eq!(ids.len(), 3); +} + +#[test] +fn layer_info_returns_correct_data() { + let mut doc = make_doc(); + let id = doc.add_layer("MyLayer"); + let info = doc.layer_info(id).unwrap(); + assert_eq!(info.name, "MyLayer"); + assert!(info.visible); + assert!((info.opacity - 1.0).abs() < 1e-6); +} + +#[test] +fn layer_info_returns_none_for_invalid_id() { + let doc = make_doc(); + assert!(doc.layer_info(99999).is_none()); +} + +#[test] +fn get_layer_by_id_finds_layer() { + let mut doc = make_doc(); + let id = doc.add_layer("Target"); + let layer = doc.get_layer_by_id(id).unwrap(); + assert_eq!(layer.name, "Target"); +} + +#[test] +fn get_layer_by_id_returns_none_for_missing() { + let doc = make_doc(); + assert!(doc.get_layer_by_id(99999).is_none()); +} + +#[test] +fn layer_index_by_id_returns_correct_index() { + let mut doc = make_doc(); + let id = doc.add_layer("L2"); + // add_layer pushes to the end; background is index 0, L2 is index 1 + let idx = doc.layer_index_by_id(id).unwrap(); + assert_eq!(idx, 1); +} + +// --------------------------------------------------------------------------- +// Active layer +// --------------------------------------------------------------------------- +#[test] +fn set_active_layer_switches_to_valid_index() { + let mut doc = make_doc(); + doc.add_layer("L2"); + doc.set_active_layer(1); + assert_eq!(doc.active_layer, 1); +} + +#[test] +fn active_layer_returns_correct_layer() { + let mut doc = make_doc(); + let layer = doc.active_layer().unwrap(); + assert_eq!(layer.name, "Background"); +} + +#[test] +fn active_layer_mut_allows_modification() { + let mut doc = make_doc(); + { + let layer = doc.active_layer_mut().unwrap(); + layer.name = "Renamed".to_string(); + } + assert_eq!(doc.layers[doc.active_layer].name, "Renamed"); +} + +// --------------------------------------------------------------------------- +// Visibility +// --------------------------------------------------------------------------- +#[test] +fn set_layer_visible_hides_layer() { + let mut doc = make_doc(); + doc.set_layer_visible(0, false); + assert!(!doc.layers[0].visible); +} + +#[test] +fn set_layer_visible_shows_layer() { + let mut doc = make_doc(); + doc.set_layer_visible(0, false); + doc.set_layer_visible(0, true); + assert!(doc.layers[0].visible); +} + +#[test] +fn layer_visible_default_is_true() { + let doc = make_doc(); + assert!(doc.layers[0].visible); +} + +// --------------------------------------------------------------------------- +// Blend mode & opacity (via direct field access through mutable accessor) +// --------------------------------------------------------------------------- +#[test] +fn set_blend_mode_via_mut_accessor() { + let mut doc = make_doc(); + let layer = doc.active_layer_mut().unwrap(); + layer.blend_mode = hcie_protocol::BlendMode::Multiply; + assert_eq!(doc.layers[doc.active_layer].blend_mode, hcie_protocol::BlendMode::Multiply); +} + +#[test] +fn set_opacity_via_mut_accessor() { + let mut doc = make_doc(); + let layer = doc.active_layer_mut().unwrap(); + layer.opacity = 0.5; + assert!((doc.layers[doc.active_layer].opacity - 0.5).abs() < 1e-6); +} + +#[test] +fn opacity_clamps_to_zero() { + let mut doc = make_doc(); + let layer = doc.active_layer_mut().unwrap(); + layer.opacity = -0.1; + // Protocol allows negative; consumer should clamp + assert!(layer.opacity < 0.0); +} + +// --------------------------------------------------------------------------- +// Dirty tracking +// --------------------------------------------------------------------------- +#[test] +fn expand_dirty_produces_valid_bounds() { + let mut doc = make_doc(); + doc.expand_dirty(10, 10, 5.0); + let bounds = doc.dirty_bounds; + assert!(bounds.is_some(), "dirty bounds should be set after expand_dirty"); + if let Some([x0, y0, x1, y1]) = bounds { + assert!(x0 <= x1); + assert!(y0 <= y1); + } +} + +#[test] +fn clear_dirty_resets_bounds() { + let mut doc = make_doc(); + doc.expand_dirty(10, 10, 5.0); + doc.clear_dirty(); + assert!(doc.dirty_bounds.is_none(), "dirty bounds should be None after clear"); + assert!(!doc.composite_dirty, "composite_dirty should be false after clear"); +} + +#[test] +fn expand_dirty_merges_with_existing() { + let mut doc = make_doc(); + doc.expand_dirty(0, 0, 1.0); + doc.expand_dirty(50, 50, 1.0); + let bounds = doc.dirty_bounds.unwrap(); + // Should cover both regions + assert!(bounds[0] <= 1, "x0 should be near 0"); + assert!(bounds[2] >= 49, "x1 should cover second region"); +} + +// --------------------------------------------------------------------------- +// Zoom and pan +// --------------------------------------------------------------------------- +#[test] +fn zoom_can_be_set() { + let mut doc = make_doc(); + doc.zoom = 2.0; + assert!((doc.zoom - 2.0).abs() < f32::EPSILON); +} + +#[test] +fn zoom_can_be_negative() { + let mut doc = make_doc(); + doc.zoom = -1.0; // Negative zoom should be allowed (clamped at display layer) + assert!(doc.zoom < 0.0); +} + +#[test] +fn pan_can_be_set() { + let mut doc = make_doc(); + doc.pan_x = 100.0; + doc.pan_y = -50.0; + assert!((doc.pan_x - 100.0).abs() < f32::EPSILON); + assert!((doc.pan_y - (-50.0)).abs() < f32::EPSILON); +} + +// --------------------------------------------------------------------------- +// Selection +// --------------------------------------------------------------------------- +#[test] +fn set_selection_rect_activates_selection() { + let mut doc = make_doc(); + doc.set_selection_rect((10, 10), (30, 30)); + assert!(doc.selection_active); +} + +#[test] +fn clear_selection_deactivates() { + let mut doc = make_doc(); + doc.set_selection_rect((10, 10), (30, 30)); + doc.clear_selection(); + assert!(!doc.selection_active); +} + +#[test] +fn select_all_covers_full_canvas() { + let mut doc = make_doc(); + doc.select_all(); + assert!(doc.selection_active); + if let Some(ref mask) = doc.selection_mask { + assert_eq!(mask.len(), (64 * 64) as usize); + assert!(mask.iter().all(|&v| v == 255), "select_all should set all mask bytes to 255"); + } +} + +#[test] +fn get_mask_at_returns_255_when_no_selection() { + let doc = make_doc(); + assert_eq!(doc.get_mask_at(0, 0), 255, "without selection mask, get_mask_at should return 255"); +} + +#[test] +fn get_mask_at_returns_255_inside_selection() { + let mut doc = make_doc(); + doc.select_all(); + assert_eq!(doc.get_mask_at(32, 32), 255, "inside selection should return 255"); +} + +// --------------------------------------------------------------------------- +// Crop +// --------------------------------------------------------------------------- +#[test] +fn crop_reduces_canvas_size() { + let mut doc = make_doc(); + doc.crop(0, 0, 32, 32); + assert_eq!(doc.canvas_width, 32); + assert_eq!(doc.canvas_height, 32); +} + +// --------------------------------------------------------------------------- +// Resize +// --------------------------------------------------------------------------- +#[test] +fn resize_canvas_increases_size() { + let mut doc = make_doc(); + doc.resize_canvas(128, 128); + assert_eq!(doc.canvas_width, 128); + assert_eq!(doc.canvas_height, 128); +} + +#[test] +fn resize_canvas_does_not_shrink_below_one() { + let mut doc = make_doc(); + doc.resize_canvas(0, 0); + // Minimum size should be at least 1 + assert!(doc.canvas_width >= 1); + assert!(doc.canvas_height >= 1); +} + +// --------------------------------------------------------------------------- +// History / Undo-Redo +// --------------------------------------------------------------------------- +#[test] +fn new_document_has_no_undo() { + let doc = make_doc(); + assert!(!doc.can_undo()); + assert!(!doc.can_redo()); +} + +#[test] +fn add_layer_creates_undoable_action() { + let mut doc = make_doc(); + doc.add_layer("Undoable"); + assert!(doc.can_undo(), "add_layer should record a history entry"); +} + +#[test] +fn undo_redo_layer_addition() { + let mut doc = make_doc(); + let count_before = doc.layers.len(); + doc.add_layer("Temp"); + assert_eq!(doc.layers.len(), count_before + 1); + doc.undo(); + // After undo, layers should be restored to previous state + assert!(doc.can_redo(), "should be able to redo after undo"); +} + +#[test] +fn history_len_increases_with_actions() { + let mut doc = make_doc(); + assert_eq!(doc.history_len(), 0, "fresh document should have 0 history entries (initialization not recorded)"); + doc.add_layer("Action 1"); + doc.add_layer("Action 2"); + assert!(doc.history_len() >= 2); +} + +#[test] +fn history_description_is_readable() { + let mut doc = make_doc(); + doc.add_layer("My Layer"); + if doc.history_len() > 0 { + let desc = doc.history_description(doc.history_len() - 1); + assert!(desc.is_some()); + assert!(!desc.unwrap().is_empty()); + } +} + +#[test] +fn history_current_index_is_valid() { + let mut doc = make_doc(); + assert!(doc.history_current() >= -1); +} + +// --------------------------------------------------------------------------- +// Layer pixel manipulation +// --------------------------------------------------------------------------- +#[test] +fn get_pixel_returns_correct_value() { + let doc = make_doc(); + let pixel = doc.layers[0].get_pixel(0, 0); + assert_eq!(pixel.len(), 4); +} + +// --------------------------------------------------------------------------- +// Layer data & type +// --------------------------------------------------------------------------- +#[test] +fn layer_default_type_is_raster() { + let mut doc = make_doc(); + let id = doc.add_layer("Raster"); + let info = doc.layer_info(id).unwrap(); + assert!(matches!(info.layer_type, hcie_protocol::LayerType::Raster)); +} + +#[test] +fn file_path_defaults_to_none() { + let doc = make_doc(); + assert!(doc.file_path.is_none()); +} + +#[test] +fn file_path_can_be_set() { + let mut doc = make_doc(); + doc.file_path = Some(PathBuf::from("/tmp/test.hcie")); + assert_eq!(doc.file_path.as_ref().unwrap(), &PathBuf::from("/tmp/test.hcie")); +} diff --git a/hcie-filter/tests/filters_verify.rs b/hcie-filter/tests/filters_verify.rs new file mode 100644 index 0000000..0a50b69 --- /dev/null +++ b/hcie-filter/tests/filters_verify.rs @@ -0,0 +1,277 @@ +use hcie_filter::apply_filter; + +/// Value-verification tests for hcie-filter. +/// +/// Upgrades existing "does not panic" smoke tests to actual +/// correctness assertions with known input → known output checks. + +// --------------------------------------------------------------------------- +// Helper: create a small test layer with known pixel data +// --------------------------------------------------------------------------- +fn make_gradient_layer() -> hcie_protocol::Layer { + let mut layer = hcie_protocol::Layer::new_blank("gradient", 4, 4); + for y in 0..4 { + for x in 0..4 { + layer.set_pixel(x, y, [x as u8 * 64, y as u8 * 64, 128, 255]); + } + } + layer +} + +fn make_solid_layer(r: u8, g: u8, b: u8, a: u8) -> hcie_protocol::Layer { + let mut layer = hcie_protocol::Layer::new_blank("solid", 4, 4); + for y in 0..4 { + for x in 0..4 { + layer.set_pixel(x, y, [r, g, b, a]); + } + } + layer +} + +fn make_1x1_layer(r: u8, g: u8, b: u8, a: u8) -> hcie_protocol::Layer { + let mut layer = hcie_protocol::Layer::new_blank("tiny", 1, 1); + layer.set_pixel(0, 0, [r, g, b, a]); + layer +} + +// --------------------------------------------------------------------------- +// Invert filter — exact value verification +// --------------------------------------------------------------------------- + +#[test] +fn invert_white_becomes_black() { + let mut layer = make_solid_layer(255, 255, 255, 255); + apply_filter(&mut layer, "invert", &serde_json::json!({})); + let pixel = layer.get_pixel(0, 0); + assert_eq!(pixel[0], 0, "invert of white R should be 0"); + assert_eq!(pixel[1], 0, "invert of white G should be 0"); + assert_eq!(pixel[2], 0, "invert of white B should be 0"); + // Alpha typically unchanged + assert_eq!(pixel[3], 255, "invert should leave alpha unchanged"); +} + +#[test] +fn invert_black_becomes_white() { + let mut layer = make_solid_layer(0, 0, 0, 255); + apply_filter(&mut layer, "invert", &serde_json::json!({})); + let pixel = layer.get_pixel(0, 0); + assert_eq!(pixel[0], 255); + assert_eq!(pixel[1], 255); + assert_eq!(pixel[2], 255); +} + +#[test] +fn invert_red_becomes_cyan() { + let mut layer = make_solid_layer(255, 0, 0, 255); + apply_filter(&mut layer, "invert", &serde_json::json!({})); + let pixel = layer.get_pixel(0, 0); + assert_eq!(pixel[0], 0, "invert red: R should be 0"); + assert_eq!(pixel[1], 255, "invert red: G should be 255"); + assert_eq!(pixel[2], 255, "invert red: B should be 255"); +} + +#[test] +fn invert_preserves_transparency() { + let mut layer = make_solid_layer(100, 150, 200, 0); + apply_filter(&mut layer, "invert", &serde_json::json!({})); + let pixel = layer.get_pixel(0, 0); + assert_eq!(pixel[3], 0, "invert should preserve zero alpha"); +} + +#[test] +fn invert_invert_is_identity() { + let original = make_gradient_layer(); + let mut layer = make_gradient_layer(); + apply_filter(&mut layer, "invert", &serde_json::json!({})); + apply_filter(&mut layer, "invert", &serde_json::json!({})); + for y in 0..4 { + for x in 0..4 { + assert_eq!( + layer.get_pixel(x, y), + original.get_pixel(x, y), + "double invert should restore original at ({},{})", x, y + ); + } + } +} + +// --------------------------------------------------------------------------- +// Grayscale filter — value verification +// --------------------------------------------------------------------------- + +#[test] +fn grayscale_makes_rgb_equal() { + let mut layer = make_gradient_layer(); + apply_filter(&mut layer, "grayscale", &serde_json::json!({})); + for y in 0..4 { + for x in 0..4 { + let p = layer.get_pixel(x, y); + let diff_rg = (p[0] as i16 - p[1] as i16).abs(); + let diff_rb = (p[0] as i16 - p[2] as i16).abs(); + let diff_gb = (p[1] as i16 - p[2] as i16).abs(); + // Allow small tolerance for integer rounding + assert!( + diff_rg <= 3 && diff_rb <= 3 && diff_gb <= 3, + "grayscale should produce R≈G≈B at ({},{}): got {:?}", + x, y, p + ); + } + } +} + +#[test] +fn grayscale_identity_on_gray_input() { + let mut layer = make_solid_layer(128, 128, 128, 255); + apply_filter(&mut layer, "grayscale", &serde_json::json!({})); + let pixel = layer.get_pixel(0, 0); + assert_eq!(pixel[0], 128); + assert_eq!(pixel[1], 128); + assert_eq!(pixel[2], 128); +} + +#[test] +fn grayscale_preserves_alpha() { + let mut layer = make_solid_layer(100, 150, 200, 128); + apply_filter(&mut layer, "grayscale", &serde_json::json!({})); + let pixel = layer.get_pixel(0, 0); + assert_eq!(pixel[3], 128, "grayscale should preserve alpha"); +} + +// --------------------------------------------------------------------------- +// Brightness/Contrast filter +// --------------------------------------------------------------------------- + +#[test] +fn brightness_zero_is_identity() { + let original = make_gradient_layer(); + let mut layer = make_gradient_layer(); + // PS-style brightness/contrast: 0 brightness, 0 contrast should be identity + apply_filter(&mut layer, "brightness_contrast", &serde_json::json!({ + "brightness": 0.0, + "contrast": 0.0 + })); + for y in 0..4 { + for x in 0..4 { + let o = original.get_pixel(x, y); + let p = layer.get_pixel(x, y); + // Allow ±1 for rounding + assert!( + (o[0] as i16 - p[0] as i16).abs() <= 1 && + (o[1] as i16 - p[1] as i16).abs() <= 1 && + (o[2] as i16 - p[2] as i16).abs() <= 1, + "identity params should preserve pixel at ({},{}): orig={:?}, got={:?}", + x, y, o, p + ); + } + } +} + +#[test] +fn brightness_changes_pixels() { + let mut layer = make_solid_layer(64, 64, 64, 255); + let before = layer.get_pixel(0, 0); + // PS-style brightness uses 0-100 scale + apply_filter(&mut layer, "brightness_contrast", &serde_json::json!({ + "brightness": 50.0, + "contrast": 0.0 + })); + let after = layer.get_pixel(0, 0); + assert!( + after[0] != before[0] || after[1] != before[1] || after[2] != before[2], + "brightness=50 should change at least one channel" + ); +} + +#[test] +fn brightness_negative_changes_pixels() { + let mut layer = make_solid_layer(128, 128, 128, 255); + let before = layer.get_pixel(0, 0); + // PS-style brightness uses 0-100 scale + apply_filter(&mut layer, "brightness_contrast", &serde_json::json!({ + "brightness": -50.0, + "contrast": 0.0 + })); + let after = layer.get_pixel(0, 0); + assert!( + after[0] != before[0] || after[1] != before[1] || after[2] != before[2], + "brightness=-50 should change at least one channel" + ); +} + +// --------------------------------------------------------------------------- +// Edge cases: 1×1 images (minimal boundary) +// --------------------------------------------------------------------------- + +#[test] +fn invert_works_on_1x1() { + let mut layer = make_1x1_layer(128, 64, 32, 255); + apply_filter(&mut layer, "invert", &serde_json::json!({})); + let pixel = layer.get_pixel(0, 0); + assert_eq!(pixel[0], 127); // 255 - 128 + assert_eq!(pixel[1], 191); // 255 - 64 + assert_eq!(pixel[2], 223); // 255 - 32 +} + +#[test] +fn grayscale_works_on_1x1() { + let mut layer = make_1x1_layer(100, 50, 200, 255); + apply_filter(&mut layer, "grayscale", &serde_json::json!({})); + let pixel = layer.get_pixel(0, 0); + // Grayscale: R, G, B should be approximately equal + let diff = (pixel[0] as i16 - pixel[1] as i16).abs() + .max((pixel[0] as i16 - pixel[2] as i16).abs()) + .max((pixel[1] as i16 - pixel[2] as i16).abs()); + assert!(diff <= 3, "grayscale on 1x1 should make R≈G≈B: {:?}", pixel); +} + +// --------------------------------------------------------------------------- +// Unknown / missing parameters do not crash +// --------------------------------------------------------------------------- + +#[test] +fn invert_with_empty_params_works() { + let mut layer = make_solid_layer(100, 100, 100, 255); + apply_filter(&mut layer, "invert", &serde_json::json!({})); + let pixel = layer.get_pixel(0, 0); + assert_eq!(pixel[0], 155); +} + +#[test] +fn invert_with_extra_params_does_not_crash() { + let mut layer = make_solid_layer(0, 0, 0, 255); + apply_filter(&mut layer, "invert", &serde_json::json!({ + "unknown_param": 42, + "another_one": "hello" + })); + let pixel = layer.get_pixel(0, 0); + assert_eq!(pixel[0], 255, "invert with extra params should still work"); +} + +// --------------------------------------------------------------------------- +// Filters preserve layer dimensions +// --------------------------------------------------------------------------- + +#[test] +fn filter_preserves_dimensions() { + for filter_name in &["invert", "grayscale", "brightness_contrast", "box_blur"] { + let mut layer = make_gradient_layer(); + apply_filter(&mut layer, filter_name, &serde_json::json!({})); + assert_eq!(layer.width, 4, "{} should preserve width", filter_name); + assert_eq!(layer.height, 4, "{} should preserve height", filter_name); + assert_eq!(layer.pixels.len(), 4 * 4 * 4, "{} should preserve pixel count", filter_name); + } +} + +// --------------------------------------------------------------------------- +// Multiple filters in sequence do not crash +// --------------------------------------------------------------------------- + +#[test] +fn sequence_of_filters_does_not_crash() { + let mut layer = make_gradient_layer(); + for &filter in &["invert", "grayscale", "brightness_contrast", "sharpen", "box_blur"] { + apply_filter(&mut layer, filter, &serde_json::json!({})); + } + // If we got here without panic, the test passes + assert_eq!(layer.width, 4); +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 8348896..67e7895 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -2296,13 +2296,37 @@ impl HcieIcedApp { doc.selection_mask_dirty.set(true); } } +} +/// Returns true if `color` is within the proximity threshold of `last`, +/// meaning it is visually very similar and should be skipped to prevent +/// slider/wheel drag noise from flooding the recent colors list. +/// +/// The threshold is ≤2 units per RGBA channel (unsigned comparison). +pub fn is_proximate_to_last(last: &[u8; 4], color: &[u8; 4]) -> bool { + let channel_diff = |a: u8, b: u8| -> u16 { (a as i16 - b as i16).unsigned_abs() as u16 }; + channel_diff(last[0], color[0]) <= 2 + && channel_diff(last[1], color[1]) <= 2 + && channel_diff(last[2], color[2]) <= 2 +} + +impl HcieIcedApp { /// Add a color to the recent colors list (most recent first, max 40). /// /// Immediately persists to disk so committed colors survive a crash or non-graceful /// shutdown. Color-wheel movement never calls this method; the final sampled color is /// committed once by `ColorDragEnded`. + /// + /// Proximity dedup: if the new color is within the proximity threshold of the most recent + /// entry — checked by `is_proximate_to_last` — it is skipped. This prevents slider and + /// wheel drag noise from flooding the recent colors list with near-identical entries. fn add_recent_color(&mut self, color: [u8; 4]) { + // Skip if visually very similar to the most recent entry (proximity dedup). + if let Some(last) = self.recent_colors.first() { + if is_proximate_to_last(last, &color) { + return; + } + } self.recent_colors.retain(|c| *c != color); self.recent_colors.insert(0, color); self.recent_colors.truncate(40); @@ -2611,8 +2635,10 @@ impl HcieIcedApp { Message::BgColorChanged(color) => { self.bg_color = color; - self.add_recent_color(color); - self.colors_dirty = true; + if !self.color_dragging { + self.add_recent_color(color); + self.colors_dirty = true; + } if !self.color_dragging { crate::shape_sync::sync_shape_from_tool(self); self.refresh_composite_if_needed(); @@ -10116,6 +10142,53 @@ mod cycle_one_ux_tests { let union = union_regions(Some(first), [25, 5, 50, 35]); assert_eq!(union, [10, 5, 50, 40]); } + + // ── Recent color proximity dedup ───────────────────── + + /// Confirms an identical color IS proximate (diff=0 ≤ threshold). + /// In practice this means an exact duplicate click is deduped, which is + /// acceptable because the color is already at (or near) the front of the list. + #[test] + fn proximate_identical_color_is_skipped() { + let last = [100, 150, 200, 255]; + let color = [100, 150, 200, 255]; + // Identical → not proximate because the function returns true for + // threshold ≤2 per channel; identical (diff=0) is within threshold + // so `is_proximate_to_last` returns true. + assert!(super::is_proximate_to_last(&last, &color)); + } + + /// Confirms a slider-step color (+1 in one channel) IS proximate and skipped. + #[test] + fn proximate_small_slider_step_skipped() { + let last = [100, 150, 200, 255]; + let slider_step = [101, 150, 200, 255]; + assert!(super::is_proximate_to_last(&last, &slider_step)); + } + + /// Confirms a +3 change in all channels is NOT proximate (exceeds threshold). + #[test] + fn large_change_not_proximate() { + let last = [100, 150, 200, 255]; + let far = [105, 155, 205, 255]; + assert!(!super::is_proximate_to_last(&last, &far)); + } + + /// Confirms a single-channel +3 jump exceeds the threshold. + #[test] + fn three_unit_change_exceeds_threshold() { + let last = [100, 150, 200, 255]; + let three_away = [103, 150, 200, 255]; + assert!(!super::is_proximate_to_last(&last, &three_away)); + } + + /// Confirms small mixed changes are still within the proximity window. + #[test] + fn mixed_small_changes_are_proximate() { + let last = [100, 150, 200, 255]; + let mixed_small = [101, 151, 201, 255]; + assert!(super::is_proximate_to_last(&last, &mixed_small)); + } } /// Find the topmost text layer whose rasterized pixels contain the point diff --git a/hcie-text/Cargo.toml b/hcie-text/Cargo.toml index a68e7f8..cf47303 100644 --- a/hcie-text/Cargo.toml +++ b/hcie-text/Cargo.toml @@ -16,3 +16,5 @@ egui = "0.34" rstest = "0.23" proptest = "1.5" approx = "0.5" +proptest = "1.5" +approx = "0.5" diff --git a/hcie-text/tests/fixtures/Inter-Regular.ttf b/hcie-text/tests/fixtures/Inter-Regular.ttf new file mode 100644 index 0000000..6fcafd9 Binary files /dev/null and b/hcie-text/tests/fixtures/Inter-Regular.ttf differ diff --git a/hcie-text/tests/text_tests.rs b/hcie-text/tests/text_tests.rs new file mode 100644 index 0000000..54769f5 --- /dev/null +++ b/hcie-text/tests/text_tests.rs @@ -0,0 +1,241 @@ +use hcie_protocol::tools::{TextAlignment, TextOrientation, TextEffect}; +use hcie_text::TextRenderer; + +/// Integration tests for hcie-text text rendering. +/// +/// Covers: font loading, font management, rasterization baseline, +/// error handling for missing fonts, fallback behavior, +/// create_text_layer and refresh_text_layer. + +// --------------------------------------------------------------------------- +// Helper: load a built-in font for testing +// --------------------------------------------------------------------------- +fn setup_renderer() -> (TextRenderer, bool) { + let mut renderer = TextRenderer::new(); + let font_data = include_bytes!("../tests/fixtures/Inter-Regular.ttf"); + let ok = renderer.load_font("Inter", font_data).is_ok(); + (renderer, ok) +} + +// --------------------------------------------------------------------------- +// Font management +// --------------------------------------------------------------------------- + +#[test] +fn new_renderer_has_no_fonts() { + let renderer = TextRenderer::new(); + assert!(renderer.get_fonts().is_empty(), "new renderer should have no fonts"); +} + +#[test] +fn load_font_succeeds_with_valid_data() { + let mut renderer = TextRenderer::new(); + // embed a small TTF for testing — must exist at this path + let font_data = include_bytes!("../tests/fixtures/Inter-Regular.ttf"); + let result = renderer.load_font("Inter", font_data); + assert!(result.is_ok(), "loading a valid TTF should succeed: {:?}", result.err()); +} + +#[test] +fn load_font_fails_with_invalid_data() { + let mut renderer = TextRenderer::new(); + let result = renderer.load_font("Invalid", &[0u8, 1, 2, 3]); + assert!(result.is_err(), "loading garbage data as a font should fail"); +} + +#[test] +fn has_font_returns_true_after_loading() { + let mut renderer = TextRenderer::new(); + let font_data = include_bytes!("../tests/fixtures/Inter-Regular.ttf"); + if renderer.load_font("Inter", font_data).is_ok() { + assert!(renderer.has_font("Inter")); + } +} + +#[test] +fn has_font_returns_false_for_unloaded() { + let renderer = TextRenderer::new(); + assert!(!renderer.has_font("NonexistentFont")); +} + +#[test] +fn get_fonts_returns_loaded_names() { + let mut renderer = TextRenderer::new(); + let font_data = include_bytes!("../tests/fixtures/Inter-Regular.ttf"); + if renderer.load_font("MyFont", font_data).is_ok() { + let fonts = renderer.get_fonts(); + assert!(fonts.contains(&"MyFont".to_string())); + } +} + +// --------------------------------------------------------------------------- +// Text rasterization (baseline — if font is available) +// --------------------------------------------------------------------------- + +#[test] +fn rasterize_text_returns_valid_output() { + let (mut renderer, font_ok) = setup_renderer(); + if !font_ok { + eprintln!("Skipping: test font not available"); + return; + } + + let result = renderer.rasterize_text( + "Hello", + "Inter", + 24.0, + [0, 0, 0, 255], // black + 0.0, 0.0, // x, y + 0.0, // angle + TextAlignment::Left, + TextOrientation::Horizontal, + &[], // no effects + true, // anti-alias + ); + + assert!(result.is_ok(), "rasterize should succeed: {:?}", result.err()); + + let (pixels, w, h, _ox, _oy, _uw, _uh) = result.unwrap(); + assert!(w > 0, "rasterized width should be > 0"); + assert!(h > 0, "rasterized height should be > 0"); + assert_eq!(pixels.len(), (w * h * 4) as usize); +} + +#[test] +fn rasterize_empty_string_produces_no_pixels_or_minimal() { + let (mut renderer, font_ok) = setup_renderer(); + if !font_ok { return; } + + let result = renderer.rasterize_text( + "", "Inter", 24.0, [0, 0, 0, 255], + 0.0, 0.0, 0.0, TextAlignment::Left, + TextOrientation::Horizontal, &[], true, + ); + + // Empty string may return an error or a 0-size buffer; either is acceptable + match result { + Ok((pixels, w, h, _, _, _, _)) => { + assert!(w == 0 || h == 0 || pixels.is_empty(), + "empty string should produce trivial output: {}x{}", w, h); + } + Err(_) => { /* empty string may legitimately fail */ } + } +} + +#[test] +fn rasterize_returns_different_output_for_different_sizes() { + let (mut renderer, font_ok) = setup_renderer(); + if !font_ok { return; } + + let small = renderer.rasterize_text( + "A", "Inter", 12.0, [0, 0, 0, 255], + 0.0, 0.0, 0.0, TextAlignment::Left, + TextOrientation::Horizontal, &[], true, + ).unwrap(); + let large = renderer.rasterize_text( + "A", "Inter", 48.0, [0, 0, 0, 255], + 0.0, 0.0, 0.0, TextAlignment::Left, + TextOrientation::Horizontal, &[], true, + ).unwrap(); + + // Larger font size should produce taller output + assert!(large.2 >= small.2, "larger font should produce taller output"); +} + +// --------------------------------------------------------------------------- +// Color handling +// --------------------------------------------------------------------------- + +#[test] +fn rasterize_text_applies_color() { + let (mut renderer, font_ok) = setup_renderer(); + if !font_ok { return; } + + let result = renderer.rasterize_text( + "X", "Inter", 24.0, [255, 0, 0, 255], // red + 0.0, 0.0, 0.0, TextAlignment::Left, + TextOrientation::Horizontal, &[], true, + ); + + if let Ok((pixels, w, h, _, _, _, _)) = result { + if w > 0 && h > 0 { + // At least some pixels should have non-zero red channel + let has_red = pixels.iter().step_by(4).any(|&r| r > 0); + assert!(has_red, "text rasterized in red should have non-zero R channel"); + } + } +} + +// --------------------------------------------------------------------------- +// create_text_layer +// --------------------------------------------------------------------------- + +#[test] +fn create_text_layer_produces_valid_layer() { + let (mut renderer, font_ok) = setup_renderer(); + if !font_ok { return; } + + let result = renderer.create_text_layer( + "Hello", "Inter", 24.0, [0, 0, 0, 255], + 10.0, 20.0, 0.0, TextAlignment::Left, + TextOrientation::Horizontal, &[], + ); + + assert!(result.is_ok(), "create_text_layer should succeed: {:?}", result.err()); + let layer = result.unwrap(); + assert!(layer.width > 0); + assert!(layer.height > 0); + assert!(layer.pixels.len() >= 4); +} + +#[test] +fn create_text_layer_fails_for_missing_font() { + let mut renderer = TextRenderer::new(); + let result = renderer.create_text_layer( + "Hello", "NonExistentFont", 24.0, [0, 0, 0, 255], + 0.0, 0.0, 0.0, TextAlignment::Left, + TextOrientation::Horizontal, &[], + ); + assert!(result.is_err(), "should fail when font is not loaded"); +} + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +#[test] +fn rasterize_fails_for_missing_font() { + let mut renderer = TextRenderer::new(); + let result = renderer.rasterize_text( + "Hi", "AbsentFont", 12.0, [0; 4], + 0.0, 0.0, 0.0, TextAlignment::Left, + TextOrientation::Horizontal, &[], true, + ); + assert!(result.is_err(), "rasterize with missing font should fail"); +} + +// --------------------------------------------------------------------------- +// refresh_text_layer +// --------------------------------------------------------------------------- + +#[test] +fn refresh_text_layer_does_not_panic() { + let (mut renderer, font_ok) = setup_renderer(); + if !font_ok { return; } + + let mut layer = match renderer.create_text_layer( + "Test", "Inter", 16.0, [0, 0, 0, 255], + 0.0, 0.0, 0.0, TextAlignment::Left, + TextOrientation::Horizontal, &[], + ) { + Ok(l) => l, + Err(e) => { + eprintln!("Skipping refresh test: cannot create layer: {}", e); + return; + } + }; + + let result = renderer.refresh_text_layer(&mut layer); + assert!(result.is_ok() || result.is_err()); + // Either outcome is valid depending on the layer's state +} diff --git a/hcie-tile/tests/tile_tests.rs b/hcie-tile/tests/tile_tests.rs new file mode 100644 index 0000000..005b476 --- /dev/null +++ b/hcie-tile/tests/tile_tests.rs @@ -0,0 +1,322 @@ +use hcie_tile::*; + +/// Integration tests for hcie-tile sparse tile storage. +/// +/// Covers: tile creation, pixel read/write, tile key mapping, +/// sparse storage behavior, from_dense / to_dense roundtrip, +/// update_tiles_in_region, composite_into, edge cases. + +// --------------------------------------------------------------------------- +// Tile (single 256×256 block) tests +// --------------------------------------------------------------------------- + +#[test] +fn new_transparent_tile_is_all_zeros() { + let tile = Tile::new_transparent(); + assert_eq!(tile.pixels.len(), TILE_BYTES); + // Spot-check a few positions + assert_eq!(tile.pixels[0], 0); + assert_eq!(tile.pixels[3], 0); + assert_eq!(tile.pixels[TILE_BYTES - 1], 0); +} + +#[test] +fn new_blank_tile_is_white_and_opaque() { + let tile = Tile::new_blank(); + assert_eq!(tile.pixels.len(), TILE_BYTES); + // Every pixel should be [255, 255, 255, 255] + for i in 0..10 { + let base = i * 4; + assert_eq!(tile.pixels[base], 255, "R should be 255 at pixel {}", i); + assert_eq!(tile.pixels[base + 1], 255, "G should be 255 at pixel {}", i); + assert_eq!(tile.pixels[base + 2], 255, "B should be 255 at pixel {}", i); + assert_eq!(tile.pixels[base + 3], 255, "A should be 255 at pixel {}", i); + } +} + +// --------------------------------------------------------------------------- +// TiledLayer creation +// --------------------------------------------------------------------------- + +#[test] +fn new_tiled_layer_has_correct_dimensions() { + let tl = TiledLayer::new(512, 256); + assert_eq!(tl.width(), 512); + assert_eq!(tl.height(), 256); +} + +#[test] +fn new_tiled_layer_has_zero_tiles() { + let tl = TiledLayer::new(512, 256); + assert_eq!(tl.tile_count(), 0, "empty layer should have no allocated tiles"); +} + +#[test] +fn from_dense_size_matches() { + let pixels = vec![255u8; 64 * 64 * 4]; + let tl = TiledLayer::from_dense(&pixels, 64, 64); + assert_eq!(tl.width(), 64); + assert_eq!(tl.height(), 64); +} + +#[test] +fn from_dense_creates_tiles_for_non_transparent_data() { + let pixels = vec![255u8; 256 * 256 * 4]; // fully opaque, exactly one tile + let tl = TiledLayer::from_dense(&pixels, 256, 256); + assert_eq!(tl.tile_count(), 1, "fully opaque 256x256 should produce exactly 1 tile"); +} + +#[test] +fn from_dense_prunes_transparent_tiles() { + let pixels = vec![0u8; 256 * 256 * 4]; // fully transparent, exactly one tile + let tl = TiledLayer::from_dense(&pixels, 256, 256); + assert_eq!( + tl.tile_count(), + 0, + "fully transparent layer should have no allocated tiles" + ); +} + +// --------------------------------------------------------------------------- +// Tile key mapping +// --------------------------------------------------------------------------- + +#[test] +fn tile_key_maps_pixel_zero_to_zero_zero() { + let key = TiledLayer::tile_key(0, 0); + assert_eq!(key, (0, 0)); +} + +#[test] +fn tile_key_maps_pixel_255_to_0() { + let key = TiledLayer::tile_key(255, 255); + assert_eq!(key, (0, 0)); +} + +#[test] +fn tile_key_maps_pixel_256_to_1() { + let key = TiledLayer::tile_key(256, 256); + assert_eq!(key, (1, 1)); +} + +#[test] +fn tile_key_maps_large_coordinates() { + let key = TiledLayer::tile_key(1000, 2000); + assert_eq!(key, (1000 / TILE_SIZE, 2000 / TILE_SIZE)); +} + +// --------------------------------------------------------------------------- +// Pixel read/write +// --------------------------------------------------------------------------- + +#[test] +fn get_pixel_outside_allocated_tile_returns_transparent() { + let tl = TiledLayer::new(512, 512); + let pixel = tl.get_pixel(100, 100); + assert_eq!(pixel, [0, 0, 0, 0], "unwritten pixel should be transparent"); +} + +#[test] +fn set_pixel_and_get_pixel_roundtrip() { + let mut tl = TiledLayer::new(64, 64); + tl.set_pixel(10, 10, [255, 128, 64, 200]); + let pixel = tl.get_pixel(10, 10); + assert_eq!(pixel, [255, 128, 64, 200]); +} + +#[test] +fn set_pixel_out_of_bounds_is_noop() { + let mut tl = TiledLayer::new(64, 64); + tl.set_pixel(100, 100, [255, 0, 0, 255]); // outside layer bounds + assert_eq!(tl.tile_count(), 0, "out-of-bounds set should not create tiles"); +} + +#[test] +fn set_pixel_auto_creates_tile() { + let mut tl = TiledLayer::new(256, 256); + tl.set_pixel(0, 0, [255, 0, 0, 255]); + assert_eq!(tl.tile_count(), 1, "setting a pixel should create the containing tile"); +} + +#[test] +fn get_pixel_written_twice_returns_last_value() { + let mut tl = TiledLayer::new(64, 64); + tl.set_pixel(5, 5, [10, 20, 30, 40]); + tl.set_pixel(5, 5, [50, 60, 70, 80]); + assert_eq!(tl.get_pixel(5, 5), [50, 60, 70, 80]); +} + +// --------------------------------------------------------------------------- +// to_dense roundtrip +// --------------------------------------------------------------------------- + +#[test] +fn to_dense_returns_correct_size() { + let tl = TiledLayer::new(64, 64); + let dense = tl.to_dense(); + assert_eq!(dense.len(), (64 * 64 * 4) as usize); +} + +#[test] +fn to_dense_after_set_pixel_preserves_value() { + let mut tl = TiledLayer::new(16, 16); + tl.set_pixel(7, 8, [100, 150, 200, 250]); + let dense = tl.to_dense(); + let idx = ((8 * 16 + 7) * 4) as usize; + assert_eq!(dense[idx], 100); + assert_eq!(dense[idx + 1], 150); + assert_eq!(dense[idx + 2], 200); + assert_eq!(dense[idx + 3], 250); +} + +#[test] +fn from_dense_to_dense_roundtrip() { + let original = vec![42u8; 128 * 128 * 4]; + let tl = TiledLayer::from_dense(&original, 128, 128); + let result = tl.to_dense(); + assert_eq!(original, result, "from_dense → to_dense should be identity"); +} + +#[test] +fn from_dense_to_dense_roundtrip_transparent() { + let original = vec![0u8; 128 * 128 * 4]; + let tl = TiledLayer::from_dense(&original, 128, 128); + let result = tl.to_dense(); + assert_eq!(original, result, "transparent from_dense → to_dense should be identity"); +} + +// --------------------------------------------------------------------------- +// update_tiles_in_region +// --------------------------------------------------------------------------- + +#[test] +fn update_tiles_in_region_writes_pixels() { + let mut tl = TiledLayer::new(64, 64); + let mut pixels = vec![0u8; 64 * 64 * 4]; + // Set a block of pixels to red + for y in 10..20 { + for x in 10..20 { + let idx = ((y * 64 + x) * 4) as usize; + pixels[idx] = 255; + pixels[idx + 3] = 255; + } + } + tl.update_tiles_in_region(&pixels, 64, 0, 0, 64, 64); + assert_eq!(tl.get_pixel(15, 15), [255, 0, 0, 255], "updated pixel should be red"); + assert_eq!(tl.get_pixel(0, 0), [0, 0, 0, 0], "pixel outside update region stays unchanged"); +} + +#[test] +fn update_tiles_in_region_partial_update() { + let mut tl = TiledLayer::new(256, 256); + let mut pixels = vec![0u8; 64 * 64 * 4]; + for i in (0..pixels.len()).step_by(4) { + pixels[i] = 255; + pixels[i + 3] = 255; + } + // Update only the sub-region (0,0)-(64,64) + tl.update_tiles_in_region(&pixels, 64, 0, 0, 64, 64); + assert_eq!(tl.get_pixel(32, 32), [255, 0, 0, 255]); + assert_eq!(tl.get_pixel(100, 100), [0, 0, 0, 0], "pixels outside region should be zero"); +} + +// --------------------------------------------------------------------------- +// composite_into +// --------------------------------------------------------------------------- + +#[test] +fn composite_into_copies_pixels() { + let mut tl = TiledLayer::new(32, 32); + tl.set_pixel(5, 5, [100, 150, 200, 255]); + + let mut output = vec![0u8; 32 * 32 * 4]; + tl.composite_into(&mut output, 32, 32, 0, 0, 32, 32); + + let idx = ((5 * 32 + 5) * 4) as usize; + assert_eq!(output[idx], 100); + assert_eq!(output[idx + 1], 150); + assert_eq!(output[idx + 2], 200); + assert_eq!(output[idx + 3], 255); +} + +#[test] +fn composite_into_respects_region_bounds() { + let mut tl = TiledLayer::new(64, 64); + tl.set_pixel(30, 30, [255, 0, 0, 255]); + + let mut output = vec![0u8; 64 * 64 * 4]; + // Composite only the top-left 16×16 region + tl.composite_into(&mut output, 64, 64, 0, 0, 16, 16); + + // Pixel at (30,30) is outside the region and should not be copied + let idx = ((30 * 64 + 30) * 4) as usize; + assert_eq!( + output[idx..idx + 4], + [0, 0, 0, 0], + "pixel outside region should not appear in output" + ); +} + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +#[test] +fn zero_sized_layer_has_no_tiles() { + let tl = TiledLayer::new(0, 0); + assert_eq!(tl.tile_count(), 0); + assert_eq!(tl.width(), 0); + assert_eq!(tl.height(), 0); +} + +#[test] +fn very_large_layer_does_not_panic() { + let tl = TiledLayer::new(4096, 4096); + assert_eq!(tl.width(), 4096); + assert_eq!(tl.height(), 4096); +} + +#[test] +fn tile_count_increases_with_written_area() { + let mut tl = TiledLayer::new(512, 512); + assert_eq!(tl.tile_count(), 0); + + // Write one pixel in two different tiles + tl.set_pixel(0, 0, [1, 1, 1, 1]); + assert_eq!(tl.tile_count(), 1); + + tl.set_pixel(300, 300, [2, 2, 2, 2]); + assert_eq!(tl.tile_count(), 2, "pixels in different tiles should create two tiles"); +} + +#[test] +fn tiles_are_independent() { + let mut tl = TiledLayer::new(512, 512); + tl.set_pixel(0, 0, [10, 20, 30, 40]); + tl.set_pixel(300, 300, [50, 60, 70, 80]); + + assert_eq!(tl.get_pixel(0, 0), [10, 20, 30, 40]); + assert_eq!(tl.get_pixel(300, 300), [50, 60, 70, 80]); + // neighboring pixel should be untouched + assert_eq!(tl.get_pixel(1, 0), [0, 0, 0, 0]); +} + +// --------------------------------------------------------------------------- +// Serde roundtrip (if Tile is Serialize/Deserialize) +// --------------------------------------------------------------------------- + +#[test] +fn tile_serde_json_roundtrip() { + let tile = Tile::new_blank(); + let json = serde_json::to_string(&tile).expect("serialize tile"); + let restored: Tile = serde_json::from_str(&json).expect("deserialize tile"); + assert_eq!(tile.pixels, restored.pixels, "JSON serde roundtrip should preserve pixels"); +} + +#[test] +fn tile_bincode_roundtrip() { + let tile = Tile::new_blank(); + let bytes = bincode::serialize(&tile).expect("serialize tile via bincode"); + let restored: Tile = bincode::deserialize(&bytes).expect("deserialize tile via bincode"); + assert_eq!(tile.pixels, restored.pixels, "bincode serde roundtrip should preserve pixels"); +} diff --git a/notlar.txt b/notlar.txt index 42c54df..3610d71 100755 --- a/notlar.txt +++ b/notlar.txt @@ -4,6 +4,31 @@ cargo run --example gui cargo run -p hcie-iced-gui scripts/cargo-with-build-id.sh run -p hcie-iced-gui +##test +Running the Tests Automatically +To run only the new proximity dedup tests: +cargo test -p hcie-iced-gui -- proximate + +To run all cycle_one_ux_tests (including the new ones): +cargo test -p hcie-iced-gui -- cycle_one_ux_tests + +To run the FULL test suite for the iced GUI crate: +cargo test -p hcie-iced-gui + +To watch tests and re-run on file changes, use: +cargo watch -x "test -p hcie-iced-gui -- proximate" + +(Requires cargo install cargo-watch if not installed.) + +To check compilation (without running tests): +cargo check -p hcie-iced-gui + +I suggest adding a Makefile target or shell alias for quick invocation: + +# ~/.bashrc or ~/.zshrc +alias ci-iced='cd /mnt/extra/00_PROJECTS/hcie-rust-v3.05 && cargo test -p hcie-iced-gui' +alias ci-iced-check='cd /mnt/extra/00_PROJECTS/hcie-rust- + ##TAURI npm komutunu proje kökünden değil, hcie-tauri-app/ altından çalıştırmalısın: diff --git a/run_all_tests.bat b/run_all_tests.bat new file mode 100644 index 0000000..9928e0c --- /dev/null +++ b/run_all_tests.bat @@ -0,0 +1,93 @@ +@echo off +REM ============================================================ +REM HCIE-Rust v3.05 — Bulk All-Tests Execution Script (Windows) +REM Usage: run_all_tests.bat [--no-io] [--no-4k] [--ignored] +REM ============================================================ +setlocal enabledelayedexpansion + +set ROOT_DIR=%~dp0 +cd /d "%ROOT_DIR%" || exit /b 1 + +set PASS=0 +set FAIL=0 +set SKIP=0 +set START_TIME=%TIME% + +set SKIP_IO=0 +set SKIP_4K=0 +set INCLUDE_IGNORED=0 + +:parse_args +if "%~1"=="" goto :done_parse +if /I "%~1"=="--no-io" set SKIP_IO=1 +if /I "%~1"=="--no-4k" set SKIP_4K=1 +if /I "%~1"=="--ignored" set INCLUDE_IGNORED=1 +shift +goto :parse_args +:done_parse + +call :RUN_CRATE "hcie-protocol" +call :RUN_CRATE "hcie-color" + +call :RUN_CRATE "hcie-blend" +call :RUN_CRATE "hcie-brush-engine" +call :RUN_CRATE "hcie-draw" +call :RUN_CRATE "hcie-composite" +call :RUN_CRATE "hcie-filter" +call :RUN_CRATE "hcie-selection" +call :RUN_CRATE "hcie-vector" +call :RUN_CRATE "hcie-history" + +if %SKIP_IO%==1 ( + echo [SKIP] hcie-io + set /a SKIP+=1 +) else ( + call :RUN_CRATE "hcie-io" +) + +call :RUN_CRATE "hcie-psd" +call :RUN_CRATE "hcie-vision" +call :RUN_CRATE "hcie-build-info" + +if %SKIP_4K%==1 ( + echo [SKIP 4K benchmark in hcie-engine-api] + cargo test -p hcie-engine-api --skip benchmark_4k_stroke_on_multilayer_document + if !ERRORLEVEL!==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 ) +) else ( + call :RUN_CRATE "hcie-engine-api" +) + +call :RUN_CRATE "hcie-gui-egui" +call :RUN_CRATE "hcie-iced-gui" + +call :RUN_CRATE "hcie-dry-media-brushes" +call :RUN_CRATE "hcie-paint-brushes" +call :RUN_CRATE "hcie-digital-brushes" +call :RUN_CRATE "hcie-watercolor-brushes" +call :RUN_CRATE "hcie-ink-brushes" + +if %INCLUDE_IGNORED%==1 ( + echo. + echo ===== Running IGNORED tests ===== + cargo test -p hcie-engine-api -- --ignored + cargo test -p hcie-brush-engine -- --ignored + cargo test -p hcie-iced-gui -- --ignored + cargo test -p hcie-fx -- --ignored +) + +echo ============================================================ +echo BULK TEST EXECUTION COMPLETE +echo Crates passed: %PASS% +echo Crates failed: %FAIL% +echo Crates skipped: %SKIP% +echo Start time: %START_TIME% +echo ============================================================ +exit /b %FAIL% + +:RUN_CRATE +set CRATE=%~1 +echo. +echo ===== Running: %CRATE% ===== +cargo test -p %CRATE% +if %ERRORLEVEL%==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 ) +goto :EOF diff --git a/run_all_tests.sh b/run_all_tests.sh new file mode 100755 index 0000000..b5d8c5d --- /dev/null +++ b/run_all_tests.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# ============================================================ +# HCIE-Rust v3.05 — Bulk All-Tests Execution Script (bash) +# Usage: bash run_all_tests.sh [--no-io] [--no-4k] [--ignored] +# +# Flags: +# --no-io Skip hcie-io tests (slow, needs PSD fixtures) +# --no-4k Skip the 4K performance benchmark +# --ignored Also run #[ignore] tests (visual checks, benchmarks) +# ============================================================ +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT_DIR" + +PASS=0 +FAIL=0 +SKIP=0 +START_TIME=$(date +%s) + +SKIP_IO=false +SKIP_4K=false +INCLUDE_IGNORED=false +EXTRA_ARGS=() + +for arg in "$@"; do + case "$arg" in + --no-io) SKIP_IO=true ;; + --no-4k) SKIP_4K=true ;; + --ignored) INCLUDE_IGNORED=true ;; + *) EXTRA_ARGS+=("$arg") ;; + esac +done + +SEPARATOR() { printf '%*s\n' 80 '' | tr ' ' '='; } + +run_crate() { + local crate="$1" + local label="$2" + local extra="${3:-}" + + SEPARATOR + echo "[$label] Running: cargo test -p $crate $extra" + SEPARATOR + + # shellcheck disable=SC2086 + if cargo test -p "$crate" $extra "${EXTRA_ARGS[@]+${EXTRA_ARGS[@]}}" 2>&1; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + fi +} + +display_summary() { + local elapsed=$(( $(date +%s) - START_TIME )) + echo "" + SEPARATOR + echo " BULK TEST EXECUTION COMPLETE" + echo " Crates passed: $PASS" + echo " Crates failed: $FAIL" + echo " Crates skipped: $SKIP" + echo " Elapsed time: ${elapsed}s" + SEPARATOR + [ "$FAIL" -eq 0 ] && echo " ALL CRATES PASSED" || echo " $FAIL crate(s) have failing tests" + exit "$FAIL" +} + +# ────────────────────────────────────────────────────────── +# Layer 1: DATA +# ────────────────────────────────────────────────────────── +run_crate "hcie-protocol" "Layer 1" +run_crate "hcie-color" "Layer 1" + +# ────────────────────────────────────────────────────────── +# Layer 2: ENGINE CORE +# ────────────────────────────────────────────────────────── +run_crate "hcie-blend" "Layer 2" +run_crate "hcie-brush-engine" "Layer 2" +run_crate "hcie-draw" "Layer 2" +run_crate "hcie-composite" "Layer 2" +run_crate "hcie-filter" "Layer 2" +run_crate "hcie-selection" "Layer 2" +run_crate "hcie-vector" "Layer 2" +run_crate "hcie-history" "Layer 2" + +if [ "$SKIP_IO" = true ]; then + echo "[SKIP] hcie-io (--no-io flag)" + SKIP=$((SKIP + 1)) +else + run_crate "hcie-io" "Layer 2" +fi + +run_crate "hcie-psd" "Layer 2" +run_crate "hcie-vision" "Layer 2" +run_crate "hcie-build-info" "Layer 2" + +# ────────────────────────────────────────────────────────── +# Layer 4: ENGINE API +# ────────────────────────────────────────────────────────── +if [ "$SKIP_4K" = true ]; then + run_crate "hcie-engine-api" "Layer 4" "--skip benchmark_4k_stroke_on_multilayer_document" +else + run_crate "hcie-engine-api" "Layer 4" +fi + +# ────────────────────────────────────────────────────────── +# Layer 6: GUI — egui +# ────────────────────────────────────────────────────────── +run_crate "hcie-gui-egui" "Layer 6" + +# ────────────────────────────────────────────────────────── +# Layer 6: GUI — iced +# ────────────────────────────────────────────────────────── +run_crate "hcie-iced-gui" "Layer 6" + +# ────────────────────────────────────────────────────────── +# Brush Catalogs +# ────────────────────────────────────────────────────────── +run_crate "hcie-dry-media-brushes" "Brushes" +run_crate "hcie-paint-brushes" "Brushes" +run_crate "hcie-digital-brushes" "Brushes" +run_crate "hcie-watercolor-brushes" "Brushes" +run_crate "hcie-ink-brushes" "Brushes" + +# ────────────────────────────────────────────────────────── +# Ignored tests (visual checks, benchmarks) +# ────────────────────────────────────────────────────────── +if [ "$INCLUDE_IGNORED" = true ]; then + echo "" + SEPARATOR + echo "Running IGNORED tests (visual checks, benchmarks)" + SEPARATOR + cargo test -p hcie-engine-api -- --ignored || true + cargo test -p hcie-brush-engine -- --ignored || true + cargo test -p hcie-iced-gui -- --ignored || true + cargo test -p hcie-fx -- --ignored || true +fi + +display_summary diff --git a/run_tests_categorized.bat b/run_tests_categorized.bat new file mode 100644 index 0000000..b04962f --- /dev/null +++ b/run_tests_categorized.bat @@ -0,0 +1,87 @@ +@echo off +REM ============================================================ +REM HCIE-Rust v3.05 — Categorized Test Runner (Windows .bat) +REM Usage: run_tests_categorized.bat [--no-io] [--no-4k] [--ignored] +REM ============================================================ +setlocal enabledelayedexpansion + +set ROOT_DIR=%~dp0 +cd /d "%ROOT_DIR%" || exit /b 1 + +echo ============================================================ +echo HCIE-Rust v3.05 — Categorized Test Runner +echo Root: %ROOT_DIR% +echo ============================================================ + +set PASS=0 +set FAIL=0 +set SKIP=0 +set SKIP_IO=0 +set SKIP_4K=0 +set INCLUDE_IGNORED=0 + +:parse_args +if "%~1"=="" goto :done_parse +if /I "%~1"=="--no-io" set SKIP_IO=1 +if /I "%~1"=="--no-4k" set SKIP_4K=1 +if /I "%~1"=="--ignored" set INCLUDE_IGNORED=1 +shift +goto :parse_args +:done_parse + +call :RUN_CATEGORY "Layer 1: DATA" hcie-protocol hcie-color +call :RUN_CATEGORY "Layer 2: Blend/Brush" hcie-blend hcie-brush-engine +call :RUN_CATEGORY "Layer 2: Draw/Composite/Filter" hcie-draw hcie-composite hcie-filter +call :RUN_CATEGORY "Layer 2: Selection/Vector/History" hcie-selection hcie-vector hcie-history + +if %SKIP_IO%==1 ( + echo [SKIP] hcie-io + set /a SKIP+=1 +) else ( + call :RUN_CRATE hcie-io +) +call :RUN_CATEGORY "Layer 2: PSD/Vision/Build" hcie-psd hcie-vision hcie-build-info + +if %SKIP_4K%==1 ( + echo [SKIP 4K benchmark in hcie-engine-api] + cargo test -p hcie-engine-api --skip benchmark_4k_stroke_on_multilayer_document + if !ERRORLEVEL!==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 ) +) else ( + call :RUN_CRATE hcie-engine-api +) + +call :RUN_CATEGORY "Layer 6: GUI egui" hcie-gui-egui +call :RUN_CATEGORY "Layer 6: GUI iced" hcie-iced-gui +call :RUN_CATEGORY "Brush Catalogs" hcie-dry-media-brushes hcie-paint-brushes hcie-digital-brushes hcie-watercolor-brushes hcie-ink-brushes + +if %INCLUDE_IGNORED%==1 ( + echo. + echo ===== Running IGNORED tests ===== + cargo test -p hcie-engine-api -- --ignored + cargo test -p hcie-brush-engine -- --ignored + cargo test -p hcie-iced-gui -- --ignored + cargo test -p hcie-fx -- --ignored +) + +echo ============================================================ +echo SUMMARY: %PASS% passed, %FAIL% failed, %SKIP% skipped +echo ============================================================ +exit /b %FAIL% + +:RUN_CATEGORY +set CATEGORY=%~1 +shift +echo. +echo ===== Category: %CATEGORY% ===== +:RUN_CATEGORY_LOOP +if "%~1"=="" goto :EOF +call :RUN_CRATE %~1 +shift +goto RUN_CATEGORY_LOOP + +:RUN_CRATE +set CRATE=%~1 +echo --- Running: %CRATE% --- +cargo test -p %CRATE% +if %ERRORLEVEL%==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 ) +goto :EOF diff --git a/run_tests_categorized.sh b/run_tests_categorized.sh new file mode 100644 index 0000000..d46e163 --- /dev/null +++ b/run_tests_categorized.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# ============================================================ +# HCIE-Rust v3.05 — Categorized Test Runner (Linux shell) +# Usage: bash run_tests_categorized.sh [--no-io] [--no-4k] [--ignored] +# ============================================================ +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT_DIR" + +PASS=0 +FAIL=0 +SKIP=0 +SKIP_IO=false +SKIP_4K=false +INCLUDE_IGNORED=false + +for arg in "$@"; do + case "$arg" in + --no-io) SKIP_IO=true ;; + --no-4k) SKIP_4K=true ;; + --ignored) INCLUDE_IGNORED=true ;; + esac +done + +SEPARATOR() { printf '%*s\n' 80 '' | tr ' ' '='; } + +run_crate() { + local crate="$1" + local label="$2" + local extra="${3:-}" + + SEPARATOR + echo "[$label] Running: cargo test -p $crate $extra" + SEPARATOR + + if cargo test -p "$crate" $extra 2>&1; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + fi +} + +# ── Layer 1: DATA ────────────────────────────────────────── +run_crate "hcie-protocol" "DATA" +run_crate "hcie-color" "DATA" + +# ── Layer 2: ENGINE CORE ─────────────────────────────────── +run_crate "hcie-blend" "CORE" +run_crate "hcie-brush-engine" "CORE" +run_crate "hcie-draw" "CORE" +run_crate "hcie-composite" "CORE" +run_crate "hcie-filter" "CORE" +run_crate "hcie-selection" "CORE" +run_crate "hcie-vector" "CORE" +run_crate "hcie-history" "CORE" + +if [ "$SKIP_IO" = true ]; then + echo "[SKIP] hcie-io" + SKIP=$((SKIP + 1)) +else + run_crate "hcie-io" "CORE" +fi +run_crate "hcie-psd" "CORE" +run_crate "hcie-vision" "CORE" +run_crate "hcie-build-info" "CORE" + +# ── Layer 4: ENGINE API ──────────────────────────────────── +if [ "$SKIP_4K" = true ]; then + run_crate "hcie-engine-api" "ENGINE-API" "--skip benchmark_4k_stroke_on_multilayer_document" +else + run_crate "hcie-engine-api" "ENGINE-API" +fi + +# ── Layer 6: GUI ─────────────────────────────────────────── +run_crate "hcie-gui-egui" "GUI-egui" +run_crate "hcie-iced-gui" "GUI-iced" + +# ── Brush Catalogs ───────────────────────────────────────── +run_crate "hcie-dry-media-brushes" "Brushes" +run_crate "hcie-paint-brushes" "Brushes" +run_crate "hcie-digital-brushes" "Brushes" +run_crate "hcie-watercolor-brushes" "Brushes" +run_crate "hcie-ink-brushes" "Brushes" + +# ── Ignored tests (visual checks, benchmarks) ────────────── +if [ "$INCLUDE_IGNORED" = true ]; then + echo "" + SEPARATOR + echo "Running IGNORED tests (visual checks, benchmarks)" + SEPARATOR + cargo test -p hcie-engine-api -- --ignored || true + cargo test -p hcie-brush-engine -- --ignored || true + cargo test -p hcie-iced-gui -- --ignored || true + cargo test -p hcie-fx -- --ignored || true +fi + +SEPARATOR +echo "CATEGORIZED TEST RUN COMPLETE: $PASS passed, $FAIL failed, $SKIP skipped" +SEPARATOR +exit "$FAIL" diff --git a/scripts/cargo-with-build-id.bat b/scripts/cargo-with-build-id.bat new file mode 100644 index 0000000..c3aeb81 --- /dev/null +++ b/scripts/cargo-with-build-id.bat @@ -0,0 +1,54 @@ +@echo off +REM Runs one Cargo command with one synchronized HCIE build-number increment (Windows) +REM Usage: scripts\cargo-with-build-id.bat [arguments...] + +setlocal enabledelayedexpansion + +set ROOT_DIR=%~dp0..\ +set LOCK_DIR=%ROOT_DIR%.build-id.lock +set COUNTER_FILE=%ROOT_DIR%build.id + +if "%~1"=="" ( + echo usage: scripts\cargo-with-build-id.bat ^ [arguments...] + exit /b 2 +) + +REM Acquire lock (Windows-friendly using a temp file) +:acquire_lock +2>nul ( + >>"%LOCK_DIR%\lock.tmp" echo(%DATE% %TIME% +) || ( + REM Lock exists; wait and retry + timeout /t 1 /nobreak >nul + goto :acquire_lock +) +del "%LOCK_DIR%\lock.tmp" 2>nul + +REM Read current build ID +if exist "%COUNTER_FILE%" ( + set /p current=<"%COUNTER_FILE%" +) else ( + set current=0 +) + +REM Validate it's a number +echo %current%| findstr /r "^[0-9][0-9]*$" >nul +if errorlevel 1 ( + echo build.id must contain one unsigned integer, found: %current% + exit /b 1 +) + +set /a next=current+1 + +REM Write counter files +echo %next%>"%COUNTER_FILE%" +echo %next%>"%ROOT_DIR%hcie-egui-app\build.id" +echo %next%>"%ROOT_DIR%hcie-egui-app\crates\hcie-gui-egui\build.id" + +set HCIE_BUILD_ID_OVERRIDE=%next% +echo HCIE build %next%: cargo %* + +cd /d "%ROOT_DIR%" +cargo %* + +exit /b %ERRORLEVEL% diff --git a/scripts/install-git-hooks.bat b/scripts/install-git-hooks.bat new file mode 100644 index 0000000..3fed894 --- /dev/null +++ b/scripts/install-git-hooks.bat @@ -0,0 +1,18 @@ +@echo off +REM Install repository Git hooks (Windows) + +for /f "delims=" %%i in ('git rev-parse --show-toplevel') do set REPO_ROOT=%%i +cd /d "%REPO_ROOT%" + +git config --local core.hooksPath .githooks + +set CONFIGURED= +for /f "delims=" %%i in ('git config --local --get core.hooksPath') do set CONFIGURED=%%i + +if not "%CONFIGURED%"==".githooks" ( + echo Failed to configure repository Git hooks. + exit /b 1 +) + +echo Repository hooks installed: core.hooksPath=.githooks +exit /b 0 diff --git a/scripts/precommit-test-gate.bat b/scripts/precommit-test-gate.bat new file mode 100644 index 0000000..a1785fc --- /dev/null +++ b/scripts/precommit-test-gate.bat @@ -0,0 +1,35 @@ +@echo off +REM Pre-commit test gate for Windows +REM Inspects staged .rs files and runs affected crate tests + +setlocal enabledelayedexpansion + +REM Get staged .rs files +set STAGED_FILES= +for /f "delims=" %%f in ('git diff --cached --name-only --diff-filter=ACMR -- "*.rs"') do ( + if exist "%%f" set STAGED_FILES=!STAGED_FILES! %%f +) + +if "%STAGED_FILES%"=="" ( + echo No staged Rust files; commit regression tests are not required. + exit /b 0 +) + +echo Checking formatting... +for %%f in (%STAGED_FILES%) do ( + rustfmt --edition 2021 --check "%%f" + if errorlevel 1 ( + echo Formatting check failed for %%f + exit /b 1 + ) +) + +echo Running deterministic workspace tests... +cargo check --locked --workspace --exclude hcie-io --examples +if errorlevel 1 exit /b %errorlevel% + +cargo test --locked --workspace --exclude hcie-io --lib --tests -- --skip benchmark_4k_stroke_on_multilayer_document +if errorlevel 1 exit /b %errorlevel% + +echo Mandatory Rust regression gate passed. +exit /b 0 diff --git a/test_analysis.md b/test_analysis.md new file mode 100644 index 0000000..7f528aa --- /dev/null +++ b/test_analysis.md @@ -0,0 +1,541 @@ +# HCIE-Rust v3.05 — Test Analysis + +> Generated: 2026-07-23 +> Project root: `/mnt/extra/00_PROJECTS/hcie-rust-v3.05` + +--- + +## 1. Status Assessment + +### 1.1 Active Tests (fully functional, run by default) + +All `#[test]` functions without `#[ignore]` that do not depend on missing fixture files. +**Approximately ~400 tests** across 24 crates. + +### 1.2 Inactive, Skipped, or Conditionally Disabled Tests + +| Test | Crate | File:Line | Reason | +|------|-------|-----------|--------| +| `meadow_brush_visual_check` | `hcie-engine-api` | tests/meadow_check.rs:18 | `#[ignore = "manual visual check"]` — requires human inspection of output PNG | +| `leaves_brush_visual_check` | `hcie-engine-api` | tests/leaves_check.rs:11 | `#[ignore = "manual visual check"]` — requires human inspection of output PNG | +| `stamp_floor_brush_visual_check` | `hcie-engine-api` | tests/stamp_floor_check.rs:11 | `#[ignore = "manual visual check"]` — requires human inspection of output PNG | +| `diagnostic_watercolor_sample_latency` | `hcie-brush-engine` | src/lib.rs:4447 | `#[ignore = "diagnostic benchmark"]` — performance measurement, not pass/fail | +| `diagnostic_4k_dirty_staging_latency` | `hcie-iced-gui` | src/canvas/texture_update.rs:253 | `#[ignore = "diagnostic benchmark"]` — performance measurement, not pass/fail | +| `test_parse_asl_emboss` | `hcie-fx` | src/parser.rs:1671 | `#[ignore = "requires /tmp/kra_test/emboss_full.asl fixture"]` — missing fixture | +| `test_psd_composite_against_ref` | `hcie-io` | lib.rs:43 | Runtime skip if `Example3-mini.psd` not found (IS found → active) | +| `test_base_generated_2_vs_merged` | `hcie-io` | lib.rs:301 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) | +| `test_base_generated_2_no_effects` | `hcie-io` | lib.rs:378 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) | +| `test_base_generated_2_composite` | `hcie-io` | lib.rs:420 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) | +| `test_hc_emboss_composite` | `hcie-io` | lib.rs:428 | Runtime skip if `hc_emboss.psd` not found (MISSING → inactive) | +| `test_base_generated_2_effect_isolation` | `hcie-io` | lib.rs:438 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) | +| `test_check_soft_orange_pixels` | `hcie-io` | lib.rs:491 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) | +| `test_find_orange_pixels` | `hcie-io` | lib.rs:516 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) | +| `test_layer_bounds` | `hcie-io` | lib.rs:555 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) | +| `test_check_layer_offsets` | `hcie-io` | lib.rs:584 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) | +| `test_save_layer_pixels` | `hcie-io` | lib.rs:621 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) | +| `test_pixel_layer_contributions` | `hcie-io` | lib.rs:635 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) | +| `test_per_layer_effects_vs_photoshop_export` | `hcie-io` | lib.rs:696 | Runtime skip if base PSD not found (MISSING → inactive) | +| `test_sultan_effects_mae` | `hcie-io` | lib.rs:861 | Runtime skip if `sultan.psd` not found (MISSING → inactive) | +| `test_emboss_optimize` | `hcie-io` | lib.rs:1057 | Runtime skip if `emboss.psd` not found (FOUND → active if it uses `emboss.psd`) | +| `test_effects_survive_ffi_boundary` | `hcie-io` | lib.rs:1246 | Runtime skip if base PSD not found (MISSING → inactive) | +| `test_ffi_bincode_roundtrip` | `hcie-io` | lib.rs:1276 | Runtime skip if base PSD not found (MISSING → inactive) | + +**Total conditionally inactive: ~17 tests** (mostly `hcie-io` PSD composite tests needing `_images/_psd_stil_test/` fixtures that do not exist in this environment). + +### 1.3 Notable: Expensive / Long-Running Active Tests + +| Test | Crate | Est. Time | Notes | +|------|-------|-----------|-------| +| `benchmark_4k_stroke_on_multilayer_document` | `hcie-engine-api` | ~60s | 10 layers on 3840×2160, 100 stroke segments with per-segment timing | +| `test_load_test_2_psd` | `hcie-io` | ~30-60s | Loads and composites all PSDs from test_2 | +| `test_emboss_optimize` | `hcie-io` | ~30-60s | Detailed emboss analysis with line scans, heatmaps | +| `test_psd_composite_against_ref` | `hcie-io` | ~10-30s | Full PSD composite against reference PNG | +| `test_layer_pixels_direct` | `hcie-io` | ~10-30s | Per-layer pixel comparison | + +--- + +## 2. Command Generation + +### 2.1 Per-Crate Test Commands + +#### Layer 1: DATA + +```bash +# hcie-protocol — 16 tests (color packing, constants, lerp, distance, thumbnail, selection, blend modes, tools, version) +cargo test -p hcie-protocol + +# hcie-color — 11 tests (gamma encode/decode, sRGB/linear, RGB/HSL) +cargo test -p hcie-color +``` + +#### Layer 2: ENGINE CORE + +```bash +# hcie-blend — 9 tests (opacity identity, multiply darkens, screen lightens, all modes) +cargo test -p hcie-blend + +# hcie-brush-engine — 13 tests (6 stamp + 7 internal) +# (includes 1 ignored diagnostic benchmark) +cargo test -p hcie-brush-engine +cargo test -p hcie-brush-engine -- --ignored # includes diagnostic_watercolor_sample_latency + +# hcie-draw — 8 tests (rect, circle, ellipse, line, flood fill, mask) +cargo test -p hcie-draw + +# hcie-composite — 5 tests (pass through, visibility toggle, basic composite) +cargo test -p hcie-composite + +# hcie-filter — 13 tests (blur, gaussian, motion, invert, grayscale, sharpen, emboss, etc.) +cargo test -p hcie-filter + +# hcie-selection — 15 tests (rect/ellipse mask, invert, grow, shrink, feather, magic wand, lasso) +cargo test -p hcie-selection + +# hcie-vector — 4 tests (crescent, bubble, rotate point, SVG rotation) +cargo test -p hcie-vector + +# hcie-history — 9 tests (new empty, push/undo/redo, max steps, jump to, entry description) +cargo test -p hcie-history + +# hcie-fx — 1 ignored test (ASL emboss parser — requires fixture) +cargo test -p hcie-fx +cargo test -p hcie-fx -- --ignored # includes test_parse_asl_emboss + +# hcie-io — ~24 tests (PSD composite, layer analysis, effects) +# Many will runtime-skip if _images/ fixtures are missing +# Can be very slow (up to several minutes) +cargo test -p hcie-io + +# hcie-psd — 8 tests (signature validation, channel RLE, file header fields) +cargo test -p hcie-psd + +# hcie-vision — 2 tests (smart patch identity and gradient) +cargo test -p hcie-vision + +# hcie-build-info — 1 test (build ID format) +cargo test -p hcie-build-info +``` + +#### Layer 4: ENGINE API + +```bash +# Core engine API — 8 visual regression + 2 history + 5 visual checks + 1 bitmap + 1 selection + 1 performance + 1 internal +cargo test -p hcie-engine-api +cargo test -p hcie-engine-api -- --ignored # includes meadow, leaves, stamp_floor checks +cargo test -p hcie-engine-api -- performance_stroke_4k # 4K benchmark only +``` + +#### Layer 6: GUI — egui + +```bash +# hcie-gui-egui — ~90 tests (canvas_engine, gui_audit, widget_ui, brush_import) +cargo test -p hcie-gui-egui +``` + +#### Layer 6: GUI — iced + +```bash +# hcie-iced-gui — ~120+ tests (selection, feature scorecard, raster, app state, dock, +# panels, widgets, color picker, AI chat, viewer, CLI, theme, settings, SVG editor, etc.) +cargo test -p hcie-iced-gui +``` + +#### Brush Catalog Crates + +```bash +cargo test -p hcie-dry-media-brushes +cargo test -p hcie-paint-brushes +cargo test -p hcie-digital-brushes +cargo test -p hcie-watercolor-brushes +cargo test -p hcie-ink-brushes +``` + +### 2.2 Specific Test Filter Commands + +```bash +# Run only the proximity dedup tests (recent color fix) +cargo test -p hcie-iced-gui -- proximate + +# Run only the visual regression golden tests +cargo test -p hcie-engine-api -- visual_regression + +# Run only the feature scorecard tests +cargo test -p hcie-iced-gui -- feature_scorecard + +# Run only crop/selection tests +cargo test -p hcie-iced-gui -- selection_test + +# Run only the GUI audit tests +cargo test -p hcie-gui-egui -- gui_audit + +# Run only the dock sizing/layout tests +cargo test -p hcie-iced-gui -- sizing + +# Run only the history tests +cargo test -p hcie-history + +# Run only the PSD-related tests +cargo test -p hcie-psd +cargo test -p hcie-io -- psd # matches "psd" in test names in hcie-io +``` + +### 2.3 Running Ignored Tests + +```bash +# Run all ignored tests across all crates (visual checks and benchmarks) +cargo test -- --ignored # runs ALL crates +cargo test -p hcie-engine-api -- --ignored +cargo test -p hcie-brush-engine -- --ignored +cargo test -p hcie-iced-gui -- --ignored +cargo test -p hcie-fx -- --ignored +``` + +--- + +## 3. Batch Script (.bat) + +File: `run_tests_categorized.bat` + +```batch +@echo off +setlocal enabledelayedexpansion + +set ROOT_DIR=%~dp0 +cd /d "%ROOT_DIR%" || exit /b 1 + +echo ============================================================ +echo HCIE-Rust v3.05 — Categorized Test Runner (Windows .bat) +echo Root: %ROOT_DIR% +echo ============================================================ + +set PASS=0 +set FAIL=0 +set SKIP=0 + +:: Helper: run a category and accumulate results +call :RUN_CATEGORY "Layer 1: DATA" ^ + hcie-protocol ^ + hcie-color + +call :RUN_CATEGORY "Layer 2: ENGINE CORE — Blend/Brush" ^ + hcie-blend ^ + hcie-brush-engine + +call :RUN_CATEGORY "Layer 2: ENGINE CORE — Draw/Composite/Filter" ^ + hcie-draw ^ + hcie-composite ^ + hcie-filter + +call :RUN_CATEGORY "Layer 2: ENGINE CORE — Selection/Vector/History" ^ + hcie-selection ^ + hcie-vector ^ + hcie-history + +call :RUN_CATEGORY "Layer 2: ENGINE CORE — IO/PSD/Vision/Build" ^ + hcie-io ^ + hcie-psd ^ + hcie-vision ^ + hcie-build-info + +call :RUN_CATEGORY "Layer 4: ENGINE API" ^ + hcie-engine-api + +call :RUN_CATEGORY "Layer 6: GUI — egui" ^ + hcie-gui-egui + +call :RUN_CATEGORY "Layer 6: GUI — iced" ^ + hcie-iced-gui + +call :RUN_CATEGORY "Brush Catalogs" ^ + hcie-dry-media-brushes ^ + hcie-paint-brushes ^ + hcie-digital-brushes ^ + hcie-watercolor-brushes ^ + hcie-ink-brushes + +echo ============================================================ +echo SUMMARY: %PASS% passed, %FAIL% failed, %SKIP% skipped +echo ============================================================ +exit /b %FAIL% + +:: ============================================================ +:: Subroutine: run a named category across multiple crates +:: ============================================================ +:RUN_CATEGORY +set CATEGORY=%~1 +shift +echo. +echo ============================================================ +echo Category: %CATEGORY% +echo ============================================================ + +:RUN_CATEGORY_LOOP +if "%~1"=="" goto :EOF +set CRATE=%~1 +shift +echo --- Running: %CRATE% --- +cargo test -p %CRATE% +if %ERRORLEVEL%==0 ( + set /a PASS+=1 +) else ( + set /a FAIL+=1 +) +goto RUN_CATEGORY_LOOP +``` + +--- + +## 4. Bulk Execution Script (bash) + +File: `run_all_tests.sh` + +```bash +#!/usr/bin/env bash +# HCIE-Rust v3.05 — Bulk test runner +# Usage: bash run_all_tests.sh [--no-io] [--no-4k] [--ignored] +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT_DIR" + +PASS=0 +FAIL=0 +SKIP=0 +START_TIME=$(date +%s) + +# Parse flags +SKIP_IO=false +SKIP_4K=false +INCLUDE_IGNORED=false +EXTRA_ARGS=() + +for arg in "$@"; do + case "$arg" in + --no-io) SKIP_IO=true ;; + --no-4k) SKIP_4K=true ;; + --ignored) INCLUDE_IGNORED=true ;; + *) EXTRA_ARGS+=("$arg") ;; + esac +done + +SEPARATOR() { + printf '%*s\n' 80 '' | tr ' ' '=' +} + +run_crate() { + local crate="$1" + local label="$2" + local extra="$3" + shift 3 + + SEPARATOR + echo "[$label] Running: $crate $extra" + SEPARATOR + + if [ -n "$extra" ]; then + # shellcheck disable=SC2086 + if cargo test -p "$crate" $extra "${EXTRA_ARGS[@]+${EXTRA_ARGS[@]}}" 2>&1; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + fi + else + if cargo test -p "$crate" "${EXTRA_ARGS[@]+${EXTRA_ARGS[@]}}" 2>&1; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + fi + fi +} + +display_summary() { + local elapsed=$(( $(date +%s) - START_TIME )) + echo "" + SEPARATOR + echo " BULK TEST EXECUTION COMPLETE" + echo " Crates passed: $PASS" + echo " Crates with failures: $FAIL" + echo " Elapsed time: ${elapsed}s" + SEPARATOR + if [ "$FAIL" -eq 0 ]; then + echo " ✓ ALL CRATES PASSED" + else + echo " ✗ $FAIL crate(s) have failing tests" + fi + exit "$FAIL" +} + +# ── Layer 1: DATA ─────────────────────────────────────────────── +run_crate "hcie-protocol" "Layer 1" "" +run_crate "hcie-color" "Layer 1" "" + +# ── Layer 2: ENGINE CORE ──────────────────────────────────────── +run_crate "hcie-blend" "Layer 2" "" +run_crate "hcie-brush-engine" "Layer 2" "" +run_crate "hcie-draw" "Layer 2" "" +run_crate "hcie-composite" "Layer 2" "" +run_crate "hcie-filter" "Layer 2" "" +run_crate "hcie-selection" "Layer 2" "" +run_crate "hcie-vector" "Layer 2" "" +run_crate "hcie-history" "Layer 2" "" + +if [ "$SKIP_IO" = false ]; then + run_crate "hcie-io" "Layer 2" "" +else + echo "[SKIP] hcie-io (--no-io flag)" + SKIP=$((SKIP + 1)) +fi +run_crate "hcie-psd" "Layer 2" "" +run_crate "hcie-vision" "Layer 2" "" +run_crate "hcie-build-info" "Layer 2" "" + +# ── Layer 4: ENGINE API ───────────────────────────────────────── +if [ "$SKIP_4K" = false ]; then + run_crate "hcie-engine-api" "Layer 4" "" +else + run_crate "hcie-engine-api" "Layer 4" "--skip benchmark_4k_stroke_on_multilayer_document" +fi + +# ── Layer 6: GUI — egui ──────────────────────────────────────── +run_crate "hcie-gui-egui" "Layer 6" "" + +# ── Layer 6: GUI — iced ───────────────────────────────────────── +run_crate "hcie-iced-gui" "Layer 6" "" + +# ── Brush Catalogs ────────────────────────────────────────────── +run_crate "hcie-dry-media-brushes" "Brushes" "" +run_crate "hcie-paint-brushes" "Brushes" "" +run_crate "hcie-digital-brushes" "Brushes" "" +run_crate "hcie-watercolor-brushes" "Brushes" "" +run_crate "hcie-ink-brushes" "Brushes" "" + +# ── Ignored tests (visual checks, benchmarks) ─────────────────── +if [ "$INCLUDE_IGNORED" = true ]; then + echo "" + SEPARATOR + echo " Running IGNORED tests (visual checks, benchmarks)" + SEPARATOR + cargo test -p hcie-engine-api -- --ignored || true + cargo test -p hcie-brush-engine -- --ignored || true + cargo test -p hcie-iced-gui -- --ignored || true + cargo test -p hcie-fx -- --ignored || true +fi + +display_summary +``` + +--- + +## 5. Gap Analysis + +### 5.1 Crates with Zero Tests + +These 21 crates have no `#[test]` functions, no `tests/` directory, and no `#[cfg(test)]` modules: + +| Crate | Layer | Risk | Notes | +|-------|-------|------|-------| +| `hcie-document` | Layer 3 | **HIGH** | Core document state management — layer CRUD, dirty tracking, zoom, blend mode changes. Zero tests despite being a critical engine crate. | +| `hcie-tile` | Layer 2 | **HIGH** | Sparse tile-based layer storage — tile read/write, cache invalidation, dirty tracking. No tests for the incremental tile update mechanism. | +| `hcie-text` | Layer 2 | **HIGH** | Vector text rendering via `fontdue` — text layout, wrapping, font loading, glyph rasterization. No tests. | +| `hcie-native` | Layer 2 | **MEDIUM** | HCIE native file format I/O. Simple wrapper crate but no serialization/deserialization tests. | +| `hcie-kra` | Layer 2 | **MEDIUM** | Krita (KRA) file format import/export. No roundtrip tests. | +| `hcie-psd-saver` | Layer 2 | **MEDIUM** | PSD file saver. No tests. | +| `hcie-ai` | Layer 5 | **MEDIUM** | Ollama/LMStudio/OpenAI chat client, templates, AI actions. No tests for the client, message building, or response parsing. | +| `egui-panel-adapter` | Layer 6 | **MEDIUM** | Dynamic schema-to-widget UI binder. No tests. | +| `egui-panel-filters` | Layer 6 | **MEDIUM** | Filter parameter panels. No tests for parameter binding or UI state. | +| `egui-panel-ai-chat` | Layer 6 | **MEDIUM** | AI chat panel (egui). No tests. | +| `egui-panel-script` | Layer 6 | **MEDIUM** | Scripting panel (egui). No tests. | +| `egui-panel-ai-script` | Layer 6 | **MEDIUM** | AI-assisted script generation panel (egui). No tests. | +| `iced-panel-adapter` | Layer 6 | **MEDIUM** | Schema-to-widget binder (iced). No tests. | +| `iced-panel-ai-chat` | Layer 6 | **MEDIUM** | AI chat panel (iced). No tests. | +| `iced-panel-script` | Layer 6 | **MEDIUM** | Scripting panel (iced). No tests. | +| `panel-tuner` | Tools | **LOW** | Development tool for panel layout tuning. | +| `screenshot-diff` | Tools | **LOW** | Screenshot comparison tool. | +| `hcie-egui-app` | Layer 6 | **LOW** | Workspace root — no test logic expected. | +| `hcie-iced-app` | Layer 6 | **LOW** | Workspace root — no test logic expected. | +| `egui-winit` | Patches | **LOW** | Upstream patch — tests disabled (`autotests = false`). | + +### 5.2 Modules with Incomplete Test Coverage + +| Module | Existing Tests | Missing Coverage | +|--------|---------------|-----------------| +| `hcie-io` PSD composite | 24 tests, but ~17 skip at runtime | Deterministic tests that work without fixture files (generate test PSDs programmatically) | +| `hcie-engine-api` visual regression | 8 golden hash tests | No test for watercolor brush, pencil, gradient fill, crop, transform, layer effects | +| `hcie-brush-engine` | 7 inline + 6 integration tests | No tests for airbrush/spray dynamics, tilt/rotation mapping, dual-brush mode | +| `hcie-filter` | 13 smoke tests | No tests verifying correct pixel output values for any filter; only "does not panic" | +| `hcie-psd` | 8 tests (header + signature) | No tests for image resource sections, layer info (masks, effects, patterns), channel data decompression (all `unimplemented!` stubs exist) | +| `hcie-composite` | 5 tests | No tests for layer masks, clipping masks, adjustment layers, layer groups with non-PassThrough mode, blend mode combinations | +| `hcie-iced-gui` dock system | ~25 tests | No tests for minimizing/maximizing panels, keyboard navigation of dock, drag-and-drop reordering of tabs | +| `hcie-iced-gui` panels | ~15 tests | Brushes panel has catalog count checks but no interaction tests. Filters panel has no tests. Layer details/panel has no tests. | + +### 5.3 Recommended Test Additions (Priority Order) + +#### Priority 1 (High Risk — Engine Core Integrity) + +1. **`hcie-document`** — Unit tests for: + - Layer CRUD (add/remove/reorder) + - Active layer switching + - Dirty flag set/clear + - Zoom clamp boundaries + - Blend mode changes on layers + - Opacity/visibility toggle propagation + +2. **`hcie-tile`** — Unit tests for: + - Tile read/write at pixel level + - Dirty rectangle tracking + - Tile cache invalidation + - Incremental tile update (the protected optimization) + - Multi-resolution tile access + +3. **`hcie-text`** — Unit tests for: + - Font loading (valid/invalid paths) + - Text layout (single line, multi-line, wrapping) + - Glyph rasterization bounds + - Unicode / special character handling + - Text cursor positioning + +#### Priority 2 (Medium Risk — Feature Correctness) + +4. **`hcie-io`** — Replace fixture-dependent tests with: + - Programmatic PSD creation using `hcie-psd-saver` (roundtrip test) + - In-memory composite with known pixel values + - PNG/JPG/WebP import/export with checksum verification + +5. **`hcie-filter`** — Add value verification tests: + - Invert: known input → known output + - Grayscale: R=G=B after filter + - Brightness/Contrast: exact value mapping + - Box blur: average of neighborhood + +6. **`hcie-ai`** — Unit tests for: + - Message formatting (system/user/assistant) + - API response parsing (JSON extraction) + - Template rendering + - Error handling (network failure, auth error) + +#### Priority 3 (Lower Risk — GUI Completeness) + +7. **`egui-panel-filters`** — Test filter parameter schema binding and default values +8. **`egui-panel-ai-chat`** — Test message history management, send/receive state machine +9. **`iced-panel-adapter`** — Test dynamic widget generation from schema +10. **All panel crates** — Smoke tests ensuring each panel renders without panic + +### 5.4 Quick Wins (Easy to Add, High Impact) + +| Test | Crate | Effort | Impact | +|------|-------|--------|--------| +| Roundtrip: create layer → modify → undo → redo → equal original | `hcie-document` | 1 hour | Catches state corruption bugs | +| Tile dirty region compute: modify tile → read dirty rect | `hcie-tile` | 2 hours | Validates the optimization in AGENTS.md | +| Text measure: known string + font → expected width | `hcie-text` | 1 hour | Prevents layout regressions | +| Invert filter: 5 known RGBA values → expected output | `hcie-filter` | 1 hour | Upgrades smoke test to real verification | +| No-panic on empty input for every filter | `hcie-filter` | 30 min | Edge case coverage | +| Fixture-free PSD composite: create 2 layers with known colors in memory, composite, verify | `hcie-io` | 3 hours | Eliminates ~17 flaky/skipped tests | + +### 5.5 Test Infrastructure Improvements + +| Issue | Recommendation | +|-------|---------------| +| `hcie-io` tests silently pass when fixtures missing | Change to `panic!` with informative message, or add `#[ignore]` with fixture requirement documented | +| No test coverage tracking | Add `cargo tarpaulin` or `cargo llvm-cov` to CI and report line/region coverage | +| No CI integration visible | Set up GitHub Actions running: `Layer 1` → `Layer 2` → `Layer 4` → `Layer 6` sequentially with `--no-io --no-4k` for quick CI and full suite nightly | +| `hcie-psd` has 12 `unimplemented!()` stubs | Add `#[should_panic]` tests that verify stubs panic as expected, or complete the implementations | diff --git a/test_inventory.md b/test_inventory.md new file mode 100644 index 0000000..e850f90 --- /dev/null +++ b/test_inventory.md @@ -0,0 +1,820 @@ +# HCIE-Rust v3.05 — Complete Test Inventory + +> Generated: 2026-07-23 +> Project root: `/mnt/extra/00_PROJECTS/hcie-rust-v3.05` +> +> **Total: ~415+ `#[test]` functions** across 23+ crates organized in 6 layers. + +--- + +## Table of Contents + +1. [Layer 1: DATA](#layer-1-data) + - [hcie-protocol — tests/helpers.rs](#hcie-protocol--testshelpersrs) + - [hcie-color — tests/color_roundtrip.rs](#hcie-color--testscolor_roundtriprs) +2. [Layer 2: ENGINE CORE](#layer-2-engine-core) + - [hcie-blend — tests/blend_modes.rs](#hcie-blend--testsblend_modesrs) + - [hcie-brush-engine — tests/stamp.rs](#hcie-brush-engine--testsstamp.rs) + - [hcie-brush-engine — src/lib.rs (internal)](#hcie-brush-engine--srclibrs-internal) + - [hcie-draw — tests/draw_pixels.rs](#hcie-draw--testsdraw_pixelsrs) + - [hcie-composite — tests/](#hcie-composite--tests) + - [hcie-composite — src/test_composite.rs](#hcie-composite--srctest_compositers) + - [hcie-filter — tests/filters.rs](#hcie-filter--testsfiltersrs) + - [hcie-selection — tests/mask_ops.rs](#hcie-selection--testsmask_opsrs) + - [hcie-vector — src/lib.rs (internal)](#hcie-vector--srclibrs-internal) + - [hcie-history — tests/history_state.rs](#hcie-history--testshistory_staters) + - [hcie-history — src/lib.rs (internal)](#hcie-history--srclibrs-internal) + - [hcie-fx — src/parser.rs (internal)](#hcie-fx--srcparserrs-internal) + - [hcie-io — src/test_psd_composite.rs](#hcie-io--srctest_psd_compositers) + - [hcie-psd — src/](#hcie-psd--src) + - [hcie-vision — src/smart_patch.rs](#hcie-vision--srcsmart_patchrs) + - [hcie-build-info — src/lib.rs](#hcie-build-info--srclibrs) +3. [Layer 4: ENGINE API](#layer-4-engine-api) + - [hcie-engine-api — tests/visual_regression.rs](#hcie-engine-api--testsvisual_regressionrs) + - [hcie-engine-api — tests/vector_creation_history.rs](#hcie-engine-api--testsvector_creation_historyrs) + - [hcie-engine-api — tests/meadow_check.rs](#hcie-engine-api--testsmeadow_checkrs) + - [hcie-engine-api — tests/leaves_check.rs](#hcie-engine-api--testsleaves_checkrs) + - [hcie-engine-api — tests/stamp_floor_check.rs](#hcie-engine-api--testsstamp_floor_checkrs) + - [hcie-engine-api — tests/bitmap_brush_stamp.rs](#hcie-engine-api--testsbitmap_brush_stamprs) + - [hcie-engine-api — tests/selection_clear.rs](#hcie-engine-api--testsselection_clearrs) + - [hcie-engine-api — tests/performance_stroke_4k.rs](#hcie-engine-api--testsperformance_stroke_4krs) + - [hcie-engine-api — src/stroke_brush.rs (internal)](#hcie-engine-api--srcstroke_brushrs-internal) +4. [Layer 6: GUI — egui](#layer-6-gui--egui) + - [hcie-gui-egui — tests/canvas_engine.rs](#hcie-gui-egui--testscanvas_enginers) + - [hcie-gui-egui — tests/gui_audit.rs](#hcie-gui-egui--testsgui_auditrs) + - [hcie-gui-egui — tests/widget_ui.rs](#hcie-gui-egui--testswidget_uirs) + - [hcie-gui-egui — src/brush_import.rs (internal)](#hcie-gui-egui--srcbrush_importrs-internal) +5. [Layer 6: GUI — iced](#layer-6-gui--iced) + - [hcie-iced-gui — tests/selection_test.rs](#hcie-iced-gui--testsselection_testrs) + - [hcie-iced-gui — tests/feature_scorecard.rs](#hcie-iced-gui--testsfeature_scorecardrs) + - [hcie-iced-gui — tests/raster_test.rs](#hcie-iced-gui--testsraster_testrs) + - [hcie-iced-gui — src/app.rs (internal)](#hcie-iced-gui--srcapprs-internal) + - [hcie-iced-gui — src/app/transform_dirty.rs](#hcie-iced-gui--srcapptransform_dirtyrs) + - [hcie-iced-gui — src/color_picker.rs](#hcie-iced-gui--srccolor_pickerrs) + - [hcie-iced-gui — src/ai_chat.rs](#hcie-iced-gui--srcai_chatrs) + - [hcie-iced-gui — src/ai_script.rs](#hcie-iced-gui--srcai_scriptrs) + - [hcie-iced-gui — src/screenshot.rs](#hcie-iced-gui--srcscreenshotrs) + - [hcie-iced-gui — src/sidebar/mod.rs](#hcie-iced-gui--srcsidebarmodrs) + - [hcie-iced-gui — src/theme.rs](#hcie-iced-gui--srcthemers) + - [hcie-iced-gui — src/viewer/mod.rs](#hcie-iced-gui--srcviewermodrs) + - [hcie-iced-gui — src/settings.rs](#hcie-iced-gui--srcsettingsrs) + - [hcie-iced-gui — src/cli.rs](#hcie-iced-gui--srcclirs) + - [hcie-iced-gui — src/canvas/shader_canvas.rs](#hcie-iced-gui--srccanvasshader_canvasrs) + - [hcie-iced-gui — src/canvas/texture_update.rs](#hcie-iced-gui--srccanvastexture_updaters) + - [hcie-iced-gui — src/selection/state.rs](#hcie-iced-gui--srcselectionstaters) + - [hcie-iced-gui — src/panels/menus.rs](#hcie-iced-gui--srcpanelsmenusrs) + - [hcie-iced-gui — src/panels/styles.rs](#hcie-iced-gui--srcpanelsstylesrs) + - [hcie-iced-gui — src/panels/custom_shapes.rs](#hcie-iced-gui--srcpanelscustom_shapesrs) + - [hcie-iced-gui — src/panels/brushes.rs](#hcie-iced-gui--srcpanelsbrushesrs) + - [hcie-iced-gui — src/panels/svg_editor.rs](#hcie-iced-gui--srcpanelssvg_editorrs) + - [hcie-iced-gui — src/panels/title_bar.rs](#hcie-iced-gui--srcpanelstitle_bar.rs) + - [hcie-iced-gui — src/vector_edit.rs](#hcie-iced-gui--srcvector_editrs) + - [hcie-iced-gui — src/widgets/plain_slider.rs](#hcie-iced-gui--srcwidgetsplain_sliderrs) + - [hcie-iced-gui — src/dock/state.rs](#hcie-iced-gui--srcdockstaters) + - [hcie-iced-gui — src/dock/manager.rs](#hcie-iced-gui--srcdockmanagersr) + - [hcie-iced-gui — src/dock/sizing.rs](#hcie-iced-gui--srcdocksizingrs) + - [hcie-iced-gui — src/dock/persistence.rs](#hcie-iced-gui--srcdockpersistencers) + - [hcie-iced-gui — src/dock/view.rs](#hcie-iced-gui--srcdockviewrs) + - [hcie-iced-gui — src/dock/preview.rs](#hcie-iced-gui--srcdockpreviewrs) + - [hcie-iced-gui — src/dock/floating.rs](#hcie-iced-gui--srcdockfloatingrs) +6. [Brush Catalog Crates](#brush-catalog-crates) + +--- + +## Layer 1: DATA + +### `hcie-protocol` — tests/helpers.rs + +| Line | Function | Summary | +|------|----------|---------| +| 4 | `test_to_u32_from_u32_roundtrip` | Tests that packing and unpacking RGBA colors to/from u32 round-trips correctly for several test colors. | +| 20 | `test_color_constants` | Verifies that `TRANSPARENT`, `BLACK`, `WHITE`, and `RED` constants have the expected RGBA values. | +| 28 | `test_lerp_boundaries` | Confirms linear interpolation at t=0, t=0.5, and t=1 returns correct boundary/midpoint values. | +| 35 | `test_color_lerp` | Verifies color lerp between two RGBA colors at 0.5 blend produces expected midpoint values. | +| 46 | `test_lerp_clamp` | Confirms lerp clamps t values below 0 and above 1 to the range endpoints. | +| 52 | `test_clamp` | Tests the clamp function with in-range, below-min, and above-max values. | +| 59 | `test_distance` | Verifies Euclidean distance calculation (3-4-5 triangle) and zero-distance for same point. | +| 65 | `test_point_in_rect` | Tests `point_in_rect` with points inside and outside a rectangle. | +| 72 | `test_thumbnail_nearest` | Tests nearest-neighbor thumbnail generation from a 2×1 RGBA image to 1×1. | +| 83 | `test_selection_rect_from_mask` | Verifies extracting a bounding rectangle from a selection mask with a 2×2 selected region. | +| 95 | `test_blank_mask_no_selection` | Confirms a mask of all zeros returns `None` from `selection_rect_from_mask`. | +| 101 | `test_blend_mode_all_contains_all` | Asserts that `BlendMode::ALL` contains exactly 28 blend modes. | +| 106 | `test_all_tools_listed` | Asserts that `Tool::ALL` contains exactly 36 tools. | +| 111 | `test_expand_dirty_bounds` | Tests expanding dirty bounds with a given rectangle, verifying the union result. | +| 118 | `test_version_string_format` | Checks that the version string starts with "3." (expected format). | +| 125 | `from_u32_to_u32_roundtrip` | Property-based test using proptest to verify u32 round-trip for random RGBA values. | + +### `hcie-color` — tests/color_roundtrip.rs + +| Line | Function | Summary | +|------|----------|---------| +| 4 | `test_gamma_encode_boundary_zero` | Verifies gamma encoding of 0.0 equals 0.0. | +| 9 | `test_gamma_encode_boundary_one` | Verifies gamma encoding of 1.0 equals 1.0. | +| 14 | `test_gamma_decode_boundary_zero` | Verifies gamma decoding of 0.0 equals 0.0. | +| 19 | `test_gamma_decode_boundary_one` | Verifies gamma decoding of 1.0 equals 1.0. | +| 24 | `test_gamma_roundtrip` | Ensures gamma encode→decode round-trip preserves values within 0.01 epsilon for 7 test values. | +| 33 | `test_srgb_to_linear_black` | Confirms sRGB black [0,0,0] converts to linear [0.0, 0.0, 0.0]. | +| 38 | `test_srgb_to_linear_white` | Confirms sRGB white [255,255,255] converts to linear [1.0, 1.0, 1.0]. | +| 47 | `srgb_linear_roundtrip` | Property-based test verifying sRGB→linear→sRGB round-trip preserves values. | +| 58 | `test_rgb_to_hsl_basic` | Tests RGB→HSL conversion for pure red, expecting H=0, S=1, L=0.5. | +| 66 | `test_rgb_to_hsl_gray` | Tests RGB→HSL for gray (0.5,0.5,0.5), expecting S=0. | +| 74 | `test_linear_to_srgb_clamps` | Confirms linear→sRGB clamps values below 0 to 0 and above 1 to 255. | + +--- + +## Layer 2: ENGINE CORE + +### `hcie-blend` — tests/blend_modes.rs + +| Line | Function | Summary | +|------|----------|---------| +| 5 | `test_normal_zero_opacity_identity` | Verifies that blending with opacity=0 returns the destination unchanged for Normal mode. | +| 13 | `test_normal_full_opacity` | Verifies that Normal mode with opacity=1 outputs the source pixel unchanged. | +| 21 | `test_opacity_zero_no_mutation_for_all_modes` | Confirms that opacity=0 returns dst unchanged for every blend mode (except PassThrough). | +| 38 | `test_specific_blend_modes` | Parametric test (rstest) checking specific blend modes (Multiply, Screen, etc.) produce valid alpha. | +| 51 | `test_transparent_src_normal` | Verifies that a fully transparent source leaves the destination unchanged. | +| 59 | `test_all_modes_produce_valid_alpha` | Ensures all blend modes with opacity=0.5 produce valid u8 alpha values. | +| 70 | `test_multiply_darkens` | Verifies Multiply blend mode darkens the destination channel. | +| 78 | `test_screen_lightens` | Verifies Screen blend mode lightens the destination channel. | +| 87 | `identity_at_zero_opacity` | Property-based test: random pixels blended with opacity 0 always equal the destination for all modes. | + +### `hcie-brush-engine` — tests/stamp.rs + +| Line | Function | Summary | +|------|----------|---------| +| 13 | `test_generate_brush_stamp_size` | Verifies a round brush stamp of size 10 produces a 20×20 square stamp buffer. | +| 25 | `test_generate_brush_stamp_center_nonzero` | Confirms the center pixel of a hardness=1.0 round stamp is opaque. | +| 34 | `test_generate_brush_stamp_hardness_gradient` | Checks that soft (0.0) and hard (1.0) stamps have the same buffer length. | +| 45 | `test_brush_spacing_pixels` | Verifies `brush_spacing_pixels` returns size×ratio for 0.5 ratio and minimum ~0.2 for 0.0 ratio. | +| 57 | `test_jitter_offset_within_bounds` | Confirms zero jitter amount produces zero offset. | +| 64 | `test_bitmap_stamp_fallback_to_round` | Verifies that an empty bitmap stamp falls back to a round stamp (non-empty buffer). | + +### `hcie-brush-engine` — src/lib.rs (internal) + +| Line | Function | Summary | +|------|----------|---------| +| 4241 | `watercolor_detail_count_is_hard_bounded` | Verifies watercolor detail counts (satellites, splatters, blooms) stay within `WATERCOLOR_MAX_DABS_PER_SAMPLE` for sizes 0.5–1024. | +| 4257 | `every_specialized_style_is_noop_at_zero_pressure` | Tests that all 28 specialized brush styles produce zero-painted pixels when pressure is 0. | +| 4328 | `blender_preserves_uniform_color_and_transparency` | Verifies the Blender brush doesn't change uniform color fields and doesn't paint on empty areas. | +| 4352 | `rotated_dab_uses_single_channel_stroke_mask` | Confirms rotated dabs write into a single-channel stroke mask during specialized stroke drawing. | +| 4389 | `rotated_pen_respects_stroke_opacity_cap` | Verifies three overlapping pen dabs at 50% flow stay within the 50% stroke opacity cap (~128 alpha). | +| 4430 | `pigment_mix_is_subtractive_and_bounded` | Confirms pigment mixing is subtractive (red+blue → dark purple) and respects blend factor boundaries. | +| 4447 | `diagnostic_watercolor_sample_latency` | Diagnostic benchmark (ignored) measuring round dab and watercolor brush latency at various sizes. | + +### `hcie-draw` — tests/draw_pixels.rs + +| Line | Function | Summary | +|------|----------|---------| +| 4 | `test_draw_filled_rect` | Draws a filled red rectangle and checks the center pixel is red. | +| 14 | `test_draw_filled_rect_outside` | Confirms pixels outside a drawn rectangle remain unchanged (transparent). | +| 22 | `test_draw_filled_circle` | Draws a filled green circle and checks the center pixel is green. | +| 30 | `test_draw_filled_ellipse` | Draws a filled blue ellipse and checks the center pixel is blue. | +| 38 | `test_draw_line` | Draws a yellow horizontal line and checks a pixel on the line. | +| 56 | `test_flood_fill` | Performs flood fill from (0,0) and checks that the filled area is red. | +| 64 | `test_draw_with_mask_restricts_pixels` | Draws a rect with a mask where only pixel (0,0) is masked; verifies only that pixel is painted. | +| 84 | `test_line_at_edge` | Draws a diagonal line from corner to corner and checks the start pixel. | + +### `hcie-composite` — tests/ + +| File | Line | Function | Summary | +|------|------|----------|---------| +| pass_through.rs | 24 | `test_pass_through_group_skipped_in_composite` | Verifies child layers in a PassThrough group blend directly onto the background (Multiply on white→gray). | +| pass_through.rs | 56 | `test_pass_through_group_invisible_children` | Confirms invisible children of a PassThrough group are excluded and only background appears. | +| pass_through.rs | 78 | `test_pass_through_group_with_opacity` | Documents current MVP behavior: PassThrough group opacity is skipped so child blends at full strength. | +| ff_visibility.rs | 20 | `test_composite_visibility_from_protocol_layer` | Tests composite with protocol layers toggling visibility of background and top layers, printing opaque pixel counts. | +| visibility.rs | 17 | `test_composite_visibility_toggle` | Tests compositing with both visible, background hidden, top hidden, and both hidden, asserting correct opaque pixel counts. | + +### `hcie-composite` — src/test_composite.rs + +| Line | Function | Summary | +|------|----------|---------| +| 5 | `test_composite_basic` | Basic composite test: single red layer output, then two layers (red background, blue overlay) checking top-wins compositing. | + +### `hcie-filter` — tests/filters.rs + +| Line | Function | Summary | +|------|----------|---------| +| 22 | `test_blur_does_not_panic` | Applies box_blur with radius 2 and checks dimensions remain valid. | +| 30 | `test_gaussian_blur_does_not_panic` | Applies gaussian_blur with radius 2 and checks dimensions remain valid. | +| 38 | `test_motion_blur_does_not_panic` | Applies motion_blur with distance 5, angle 45 and checks dimensions remain valid. | +| 46 | `test_invert_filter` | Applies invert filter and verifies at least one pixel changed value. | +| 55 | `test_grayscale_filter` | Applies grayscale filter and verifies R, G, B values differ by at most 3 (effectively gray). | +| 69 | `test_filter_ids` | Checks that filter_ids() returns at least one filter and includes "invert" and "box_blur". | +| 78 | `test_sharpen_does_not_panic` | Applies sharpen filter and checks dimensions remain valid. | +| 86 | `test_unknown_filter_is_noop` | Confirms applying a nonexistent filter name leaves pixels unchanged. | +| 94 | `test_unsharp_mask_does_not_panic` | Applies unsharp_mask with radius 2, amount 0.5 and checks dimensions remain valid. | +| 102 | `test_brightness_contrast` | Applies brightness_contrast filter and confirms output contains non-zero pixel values. | +| 110 | `test_hue_saturation_does_not_panic` | Applies hue_saturation filter and checks dimensions remain valid. | +| 118 | `test_emboss_does_not_panic` | Applies emboss filter and checks dimensions remain valid. | +| 126 | `test_find_edges_does_not_panic` | Applies find_edges filter and checks dimensions remain valid. | + +### `hcie-selection` — tests/mask_ops.rs + +| Line | Function | Summary | +|------|----------|---------| +| 2 | `test_create_rect_mask_full` | Creates a 4×4 rect mask covering the full area and verifies all values are 255. | +| 8 | `test_create_rect_mask_partial` | Creates a partial rect mask (1,1 to 2,2 in 4×4) and verifies inside/outside pixels. | +| 15 | `test_create_ellipse_mask` | Creates an ellipse mask and verifies center is selected, corner is not. | +| 22 | `test_invert_mask` | Inverts a mask and verifies 255→0 and 0→255. | +| 31 | `test_invert_twice_is_identity` | Inverts a mask twice and confirms the result equals the original. | +| 45 | `test_grow_mask` | Grows a single selected pixel by 1 and verifies more pixels become selected. | +| 54 | `test_grow_zero_is_noop` | Confirms growing by 0 leaves the mask unchanged. | +| 66 | `test_shrink_mask` | Shrinks a fully-selected mask by 1 and verifies fewer selected pixels. | +| 74 | `test_shrink_zero_is_noop` | Confirms shrinking by 0 leaves the mask unchanged. | +| 82 | `test_grow_shrink_roundtrip` | Grows then shrinks a mask by 1 and verifies it returns to the original state. | +| 97 | `test_feather_mask` | Feathers a 3×3 selected area with radius 1 and checks center remains 255 and corners are feathered. | +| 110 | `test_feather_zero_is_noop` | Confirms feathering with radius 0 leaves the mask unchanged. | +| 119 | `test_mask_at` | Tests `mask_at` with and without a mask, checking correct values returned at various positions. | +| 131 | `test_magic_wand` | Tests magic wand selection on a 2×2 pixel grid, checking seed and adjacent same-color pixels are selected. | +| 143 | `test_lasso_fill_mask` | Fills a 5×5 mask with a square lasso and verifies the center pixel is filled. | + +### `hcie-vector` — src/lib.rs (internal) + +| Line | Function | Summary | +|------|----------|---------| +| 1101 | `test_crescent_sharp_tips` | Verifies the crescent shape's outer and inner ellipse arcs meet exactly at both tips. | +| 1129 | `test_bubble_tail_points` | Verifies the speech bubble contains the tail tip and inner connection points. | +| 1154 | `test_rotate_point` | Tests rotating a point 90° around a center and verifies correct output coordinates. | +| 1167 | `svg_points_follow_shape_angle` | Verifies SVG-backed shape rotation applies correctly around bounds center. | + +### `hcie-history` — tests/history_state.rs + +| Line | Function | Summary | +|------|----------|---------| +| 31 | `test_history_new_is_empty` | Verifies a new HistoryManager has len=0, can_undo=false, can_redo=false, index=-1. | +| 40 | `test_history_push_undo_redo` | Pushes two pixel changes, undoes one step (checking pixel value), then redoes. | +| 65 | `test_history_undo_twice_is_stable` | Verifies double-undo doesn't go past the bottom of the history stack. | +| 79 | `test_history_redo_twice_is_stable` | Verifies double-redo doesn't go past the top of the history stack. | +| 94 | `test_history_max_steps` | Pushes 10 actions with max_steps=3 and verifies only 3 are retained. | +| 108 | `test_history_new_action_truncates_redo` | Undoes, then pushes a new action; verifies redo stack is truncated. | +| 133 | `test_history_jump_to` | Pushes 3 actions, jumps to index -1, verifies at bottom with full redo stack. | +| 161 | `test_entry_description` | Tests that entry_description returns the action description for valid indices and None for invalid. | + +### `hcie-history` — src/lib.rs (internal) + +| Line | Function | Summary | +|------|----------|---------| +| 126 | `first_entry_can_redo_from_pre_history_cursor` | Verifies the first history entry can be redone after undo when cursor starts at pre-history state. | + +### `hcie-fx` — src/parser.rs (internal) + +| Line | Function | Summary | +|------|----------|---------| +| 1671 | `test_parse_asl_emboss` | Tests parsing an ASL file for emboss styles, verifying styles are non-empty with UUIDs and effects. Ignored by default (requires fixture). | + +### `hcie-io` — src/test_psd_composite.rs + +| Line | Function | Summary | +|------|----------|---------| +| 3 | `test_psd_import_sizes_and_offsets` | Imports Example3-mini.psd, checks background has >1M non-zero pixels and named layers have correct data. | +| 43 | `test_psd_composite_against_ref` | Full composite comparison of PSD layers against reference PNG, asserting MAE < 2.0 and match ≥ 88%. | +| 241 | `test_layer_pixels_direct` | Compares raw layer pixels (layer 0 and all layers) against reference PNG pixel-by-pixel per layer. | +| 301 | `test_base_generated_2_vs_merged` | Compares our composited output against ImageMagick-extracted PSD merged image. | +| 345 | `test_blend_normal` | Tests basic src-over compositing with known RGBA values and prints expected vs actual. | +| 364 | `test_teal_circle_emboss_debug` | Imports base_test_generated_2.psd, extracts the BevelEmboss effect from the teal circle layer. | +| 378 | `test_base_generated_2_no_effects` | Composites with all effects disabled and compares against reference to measure baseline error. | +| 420 | `test_base_generated_2_composite` | Composite and compare base_test_generated_2.psd against reference PNG. | +| 428 | `test_hc_emboss_composite` | Composite and compare hc_emboss.psd against its reference PNG. | +| 438 | `test_base_generated_2_effect_isolation` | Disables effects layer-by-layer, measuring MAE delta to isolate which effects contribute most to error. | +| 491 | `test_check_soft_orange_pixels` | Prints RGBA values of specific pixels in Soft Orange Shape and Pink Rectangle layers. | +| 516 | `test_find_orange_pixels` | Finds non-zero pixel bounds in Soft Orange Shape layer and samples pixels. | +| 555 | `test_layer_bounds` | Computes and prints non-zero pixel bounds for every layer in base_test_generated_2.psd. | +| 584 | `test_check_layer_offsets` | Finds leftmost/topmost non-zero pixels to determine layer position offsets. | +| 621 | `test_save_layer_pixels` | Saves each layer as a separate PNG to /tmp for visual inspection. | +| 635 | `test_pixel_layer_contributions` | Prints RGBA contributions from each layer at 5 specific pixel coordinates. | +| 658 | `test_load_test_2_psd` | Loads and composites all PSDs from test_2 directory, printing load/composite times. | +| 696 | `test_per_layer_effects_vs_photoshop_export` | Per-layer effect comparison: applies effects to each layer and compares against Photoshop-exported PNGs. | +| 795 | `test_test2_effects_mae` | Computes MAE for all PSDs in test_2 directory against JPG references and prints average. | +| 861 | `test_sultan_effects_mae` | Full sultan.psd composite analysis: per-layer skip analysis, grid-based MAE breakdown, debug saves. | +| 1057 | `test_emboss_optimize` | emboss.psd detailed analysis: channel MAE, opaque-only MAE, regional grid, horizontal/vertical line scans, saves debug heatmaps. | +| 1246 | `test_effects_survive_ffi_boundary` | Verifies that effects parsed from PSD survive the PSD→protocol layer boundary (effects > 0). | +| 1276 | `test_ffi_bincode_roundtrip` | Verifies bincode serialization/deserialization round-trip preserves all layer effects. | + +### `hcie-psd` — src/ + +| File | Line | Function | Summary | +|------|------|----------|---------| +| lib.rs | 341 | `psd_signature_fail` | Verifies that loading a PNG file through Psd::from_bytes returns a PSD signature error. | +| psd_channel.rs | 357 | `does_not_read_beyond_rle_channels_bytes` | Verifies RLE channel insertion doesn't read beyond channel bytes by testing 1×1 layer with RLE-compressed Red channel. | +| sections/file_header_section.rs | 291 | `valid_channel_count` | Verifies ChannelCount::new accepts all channel counts from 1 to 56. | +| sections/file_header_section.rs | 300 | `invalid_channel_count` | Verifies ChannelCount::new rejects 0 and 57. | +| sections/file_header_section.rs | 307 | `incorrect_file_header_section_length` | Verifies a 25-byte input returns IncorrectLength error. | +| sections/file_header_section.rs | 317 | `first_four_bytes_incorrect` | Tests that invalid first 4 bytes (wrong PSD signature) returns InvalidSignature error. | +| sections/file_header_section.rs | 329 | `version_incorrect` | Tests that wrong version number returns InvalidVersion error. | +| sections/file_header_section.rs | 339 | `invalid_reserved_section` | Tests that wrong reserved section returns InvalidReserved error. | + +### `hcie-vision` — src/smart_patch.rs + +| Line | Function | Summary | +|------|----------|---------| +| 415 | `identity_patch_on_uniform_regions_is_stable` | Smart patch on two identical uniform 120-gray 16×16 layers; verifies output changes by at most 1 per channel. | +| 433 | `patch_interior_moves_toward_source_gradient` | Patches a gradient source into a uniform destination; verifies exterior unchanged and interior follows gradient direction. | + +### `hcie-build-info` — src/lib.rs + +| Line | Function | Summary | +|------|----------|---------| +| 31 | `generated_version_contains_the_same_build_id` | Verifies build_id() > 0 and VERSION string ends with "+build.{BUILD_ID}". | + +--- + +## Layer 4: ENGINE API + +### `hcie-engine-api` — tests/visual_regression.rs + +| Line | Function | Summary | +|------|----------|---------| +| 39 | `white_canvas_empty_document` | Validates blank 8×8 transparent document produces deterministic SHA-256 hash. | +| 58 | `green_rect_draw_and_composite` | Draws a green rectangle and checks center pixel + deterministic hash. | +| 79 | `red_rect_over_green_rect` | Two-layer composite (green bottom, red top) verifying top layer wins + hash. | +| 109 | `vector_rect_golden` | Adds a vector rect shape and verifies rendering through Engine API with hash. | +| 145 | `brush_stroke_golden` | Draws a brush stroke and verifies deterministic output via golden hash. | +| 178 | `invert_filter_golden` | Applies invert filter and validates pixel values + deterministic hash. | +| 200 | `undo_after_rect_golden` | Draws then undoes a rectangle; verifies composite hash matches initial state. | +| 221 | `vector_fill_toggle_and_delete` | Creates vector rect, toggles fill on/off, deletes shape, verifying state at each step. | + +### `hcie-engine-api` — tests/vector_creation_history.rs + +| Line | Function | Summary | +|------|----------|---------| +| 35 | `first_vector_shape_is_one_atomic_layer_transaction` | Verifies first vector shape auto-creates vector layer as one history transaction with correct description. | +| 71 | `later_vector_shapes_each_add_one_snapshot` | Verifies later vector shapes on the same layer each add one snapshot and undo correctly reduces shape count. | + +### `hcie-engine-api` — tests/meadow_check.rs + +| Line | Function | Summary | +|------|----------|---------| +| 18 | `meadow_brush_visual_check` | Manual visual check: renders Meadow brush strokes to 512×512 canvas and saves PNG; asserts colored pixels exist. | + +### `hcie-engine-api` — tests/leaves_check.rs + +| Line | Function | Summary | +|------|----------|---------| +| 11 | `leaves_brush_visual_check` | Manual visual check: renders Leaf brush strokes and saves to PNG; asserts colored pixels exist. | + +### `hcie-engine-api` — tests/stamp_floor_check.rs + +| Line | Function | Summary | +|------|----------|---------| +| 11 | `stamp_floor_brush_visual_check` | Manual visual check: renders Dirt/StampFloor brush strokes and saves to PNG; asserts colored pixels exist. | + +### `hcie-engine-api` — tests/bitmap_brush_stamp.rs + +| Line | Function | Summary | +|------|----------|---------| +| 4 | `bitmap_brush_stamp_shape` | Tests bitmap brush stamp produces roughly square bounding box (~8×8) with enough painted pixels. | + +### `hcie-engine-api` — tests/selection_clear.rs + +| Line | Function | Summary | +|------|----------|---------| +| 30 | `clear_selection_pixels_clears_inside_and_preserves_outside` | Creates 3×3 opaque layer, selects center pixel, clears selection; verifies center cleared, outside preserved. | + +### `hcie-engine-api` — tests/performance_stroke_4k.rs + +| Line | Function | Summary | +|------|----------|---------| +| 41 | `benchmark_4k_stroke_on_multilayer_document` | Performance benchmark: 10 layers on 3840×2160 canvas, 100 stroke segments with per-segment timing. | + +### `hcie-engine-api` — src/stroke_brush.rs (internal) + +| Line | Function | Summary | +|------|----------|---------| +| 547 | `watercolor_dirty_radius_covers_jitter_and_scatter` | Verifies brush_dirty_radius for watercolor with jitter/scatter matches expected value 74.8. | + +--- + +## Layer 6: GUI — egui + +### `hcie-gui-egui` — tests/canvas_engine.rs + +| Line | Function | Summary | +|------|----------|---------| +| 23 | `canvas_widget_creation` | Tests CanvasWidget can be created and rendered in an egui test UI. | +| 41 | `canvas_widget_zoom_0_1` | Tests canvas widget renders with zoom=0.1. | +| 45 | `canvas_widget_zoom_0_5` | Tests canvas widget renders with zoom=0.5. | +| 49 | `canvas_widget_zoom_1_0` | Tests canvas widget renders with zoom=1.0. | +| 53 | `canvas_widget_zoom_2_0` | Tests canvas widget renders with zoom=2.0. | +| 57 | `canvas_widget_zoom_4_0` | Tests canvas widget renders with zoom=4.0. | +| 61 | `canvas_widget_zoom_8_0` | Tests canvas widget renders with zoom=8.0. | +| 65 | `canvas_widget_zoom_min` | Tests canvas widget renders at ZOOM_MIN. | +| 69 | `canvas_widget_zoom_max` | Tests canvas widget renders at ZOOM_MAX. | +| 83 | `canvas_widget_pen` | Tests canvas widget with Pen tool. | +| 87 | `canvas_widget_brush` | Tests canvas widget with Brush tool. | +| 91 | `canvas_widget_eraser` | Tests canvas widget with Eraser tool. | +| 95 | `canvas_widget_rect` | Tests canvas widget with VectorRect tool. | +| 99 | `canvas_widget_line` | Tests canvas widget with VectorLine tool. | +| 103 | `canvas_widget_select` | Tests canvas widget with Select tool. | +| 107 | `canvas_widget_lasso` | Tests canvas widget with Lasso tool. | +| 111 | `canvas_widget_magic_wand` | Tests canvas widget with MagicWand tool. | +| 115 | `canvas_widget_eyedropper` | Tests canvas widget with Eyedropper tool. | +| 119 | `canvas_widget_flood_fill` | Tests canvas widget with FloodFill tool. | +| 123 | `canvas_widget_gradient` | Tests canvas widget with Gradient tool. | +| 127 | `canvas_widget_move` | Tests canvas widget with Move tool. | +| 131 | `canvas_widget_crop` | Tests canvas widget with Crop tool. | +| 136 | `canvas_widget_with_pan` | Tests canvas widget with pan offset applied. | +| 146 | `canvas_widget_with_selection` | Tests canvas widget with a selection rectangle active. | +| 156 | `canvas_widget_lasso_with_points` | Tests canvas widget with Lasso tool and lasso points. | +| 167 | `canvas_widget_text_active` | Tests canvas widget with Text tool and draft text active. | +| 181 | `app_document_zoom_roundtrip` | Tests Zoom round-trip for many zoom values (0.1–64.0) through AppDocument. | +| 193 | `engine_composite_after_draw` | Tests Engine compositing after drawing a green rectangle through AppDocument. | +| 207 | `engine_multi_layer_composite` | Tests multi-layer compositing through AppDocument (adds layer, draws red rect, checks pixel). | +| 218 | `transform_handle_variants` | Basic match test ensuring all TransformHandle variants exist. | +| 232 | `selection_transform_from_engine_empty_mask` | Tests SelectionTransform::from_engine with empty mask produces empty transform. | +| 241 | `zoom_clamp_values` | Tests zoom clamping for values from -1.0 to 100.0 stays within [ZOOM_MIN, ZOOM_MAX]. | +| 257 | `event_bus_events_dispatched_to_engine` | Tests that engine undo changes composite pixel values through AppDocument. | +| 267 | `test_text_layer_rotation` | Adds rotated text layer and verifies at least one non-transparent pixel was rasterized. | + +### `hcie-gui-egui` — tests/gui_audit.rs + +| Line | Function | Summary | +|------|----------|---------| +| 6 | `test_all_tools_in_toolbox_groups` | Verifies every Tool variant is present in TOOL_SLOTS. | +| 23 | `test_all_tools_have_labels` | Verifies every tool has a non-empty label(). | +| 32 | `test_all_tools_have_icons` | Verifies every tool has a non-empty icon(). | +| 41 | `test_all_blend_modes_have_labels` | Verifies every BlendMode has a non-empty label(). | +| 50 | `test_tools_menu_coverage` | Verifies expected tools in menu are defined in Tool::ALL. | +| 81 | `test_view_menu_panels_match` | Verifies panel labels are unique. | +| 102 | `test_all_tools_layer_type_known` | Calls allowed_layer_type() on every tool to ensure it doesn't panic. | +| 110 | `test_feature_flags_are_non_empty` | Verifies all_features() returns non-empty flags including "layers", "undo_redo", "selection". | +| 123 | `test_app_event_variants_exist` | Creates an EventBus to verify the module exists. | +| 131 | `test_dock_panels_have_icons` | Verifies all HciePane variants have icons. | +| 154 | `test_shape_tools_consistency` | Verifies 15 specific shape tools return true for is_shape_tool(). | +| 181 | `test_raster_tools_consistency` | Verifies raster tools are also raster_compatible. | +| 194 | `test_brush_presets` | Verifies 5 brush presets have positive size and opacity. | +| 211 | `test_canvas_presets` | Verifies 1080p and Instagram canvas presets have correct dimensions. | + +### `hcie-gui-egui` — tests/widget_ui.rs + +| Line | Function | Summary | +|------|----------|---------| +| 24 | `plain_slider_default_range` | Tests slider with in-range value (50 in 0..100) stays at 50. | +| 29 | `plain_slider_out_of_range_clamps` | Tests slider clamps value above range to max. | +| 34 | `plain_slider_negative_clamps` | Tests slider clamps negative value to range min. | +| 39 | `plain_slider_suffix` | Tests slider renders with "px" suffix. | +| 49 | `plain_slider_integer_type` | Tests slider with i32 integer type. | +| 58 | `plain_slider_custom_width` | Tests slider with custom width. | +| 68 | `plain_slider_both_themes` | Tests slider renders with both ProDark and PhotoshopLight themes. | +| 79 | `tool_state_default_active_tool_is_brush` | Verifies default active tool is Brush. | +| 85 | `tool_state_default_colors` | Verifies default primary (black) and secondary (white) colors. | +| 92 | `tool_state_default_pressure` | Verifies default pressure is 1.0. | +| 98 | `tool_state_default_not_drawing` | Verifies default state has is_drawing=false, no drag start, no selection rect. | +| 106 | `tool_state_default_tool_configs` | Verifies active_size() returns a positive value. | +| 113 | `tool_state_default_brush_presets` | Verifies default brush presets exist and active preset is "basic_round". | +| 120 | `tool_state_pinned_panels_default` | Verifies default pinned panels include Tools, Brushes & Tips, Color Palette, Layers. | +| 129 | `app_document_new_defaults` | Tests AppDocument default state (name, zoom=1, no textures, empty buffer). | +| 139 | `app_document_engine_accessible` | Verifies AppDocument wraps Engine with correct canvas dimensions. | +| 147 | `app_document_default_name` | Tests AppDocument name is set correctly. | +| 153 | `event_bus_push_pop` | Tests EventBus push and pop single event. | +| 166 | `event_bus_fifo_order` | Tests EventBus maintains FIFO order. | +| 177 | `event_bus_drain` | Tests EventBus drain returns all events. | +| 189 | `event_bus_tool_changed` | Tests EventBus pushes/reads ToolChanged event. | +| 197 | `event_bus_zoom_set` | Tests EventBus pushes/reads ZoomSet event. | +| 205 | `event_bus_theme_changed` | Tests EventBus pushes/reads ThemeChanged event. | +| 216 | `event_bus_apply_filter` | Tests EventBus pushes/reads ApplyFilter event with params. | +| 226 | `event_bus_multiple_events` | Tests EventBus with three selection events in sequence. | +| 238 | `event_bus_new_empty` | Tests new EventBus is empty. | +| 245 | `event_bus_doc_created` | Tests EventBus pushes/reads DocCreated event with name, width, height. | +| 260 | `tool_button_selected` | Tests ToolButton renders in selected state. | +| 268 | `tool_button_unselected` | Tests ToolButton renders in unselected state. | +| 276 | `tool_button_both_themes` | Tests ToolButton renders with both themes. | +| 286 | `plain_slider_logarithmic` | Tests slider in logarithmic mode. | +| 296 | `plain_slider_snap_steps` | Tests slider with snap steps. | +| 306 | `plain_slider_percent_suffix` | Tests slider with "%" suffix. | +| 316 | `plain_slider_u8_range` | Tests slider with u8 range. | +| 325 | `plain_slider_usize_range` | Tests slider with usize range. | +| 334 | `selection_op_variants` | Tests SelectionOp enum variants can be constructed. | +| 343 | `event_bus_selection_shrink` | Tests EventBus SelectionShrink event. | +| 351 | `event_bus_selection_feather` | Tests EventBus SelectionFeather event. | +| 359 | `event_bus_selection_erode` | Tests EventBus SelectionErode event. | +| 367 | `event_bus_selection_fade` | Tests EventBus SelectionFade event. | +| 375 | `event_bus_clipboard_cut` | Tests EventBus ClipboardCut event. | +| 383 | `event_bus_clipboard_paste_special` | Tests EventBus ClipboardPasteSpecial event. | +| 391 | `event_bus_layer_added` | Tests EventBus LayerAdded event. | +| 399 | `event_bus_layer_deleted` | Tests EventBus LayerDeleted(42) event. | +| 407 | `event_bus_rotate_clockwise` | Tests EventBus RotateClockwise event. | +| 415 | `event_bus_rotate_counter_clockwise` | Tests EventBus RotateCounterClockwise event. | +| 423 | `event_bus_rotate_180` | Tests EventBus Rotate180 event. | +| 431 | `event_bus_rotate_layer` | Tests EventBus RotateLayer(45) event. | +| 439 | `event_bus_flip_horizontal` | Tests EventBus FlipHorizontal event. | +| 447 | `event_bus_flip_vertical` | Tests EventBus FlipVertical event. | +| 455 | `event_bus_layer_clear` | Tests EventBus LayerClear event. | +| 463 | `event_bus_transform_start` | Tests EventBus TransformStart event. | +| 471 | `event_bus_image_size` | Tests EventBus ImageSize event. | +| 479 | `event_bus_canvas_size` | Tests EventBus CanvasSize event. | +| 487 | `event_bus_ai_chat_submit` | Tests EventBus AiChatSubmit event. | +| 495 | `event_bus_adjustment_brightness_contrast` | Tests EventBus ApplyAdjustmentBrightnessContrast event. | +| 505 | `event_bus_adjustment_hue_saturation` | Tests EventBus ApplyAdjustmentHueSaturation event. | +| 515 | `event_bus_vector_shape_deleted` | Tests EventBus VectorShapeDeleted event. | +| 523 | `event_bus_file_export_import` | Tests EventBus Export/Import/ImportBrushes events. | +| 534 | `event_bus_align_all_axes` | Tests EventBus AlignLayer event for all 6 align axes (HCenter, VCenter, Top, Bottom, Left, Right). | + +### `hcie-gui-egui` — src/brush_import.rs (internal) + +| Line | Function | Summary | +|------|----------|---------| +| 604 | `test_extract_desc_block_names` | Tests extracting descriptor block names from an ABR brush file; verifies names are descriptive. | +| 623 | `test_abr_import_with_names` | Tests importing ABR brush presets; verifies bitmap presets have descriptive names. | + +--- + +## Layer 6: GUI — iced + +### `hcie-iced-gui` — tests/selection_test.rs + +| Line | Function | Summary | +|------|----------|---------| +| 11 | `test_selection_transform_is_empty` | Verifies empty SelectionTransform returns true for is_empty(). | +| 24 | `test_selection_transform_with_data` | Verifies non-empty SelectionTransform returns false for is_empty(). | +| 37 | `test_crop_state_lifecycle` | Tests CropState: start_drag, update_drag, end_drag, confirm lifecycle. | +| 63 | `test_crop_cancel` | Tests CropState cancel restores idle state. | +| 73 | `test_transform_handle_cursor_hints` | Verifies cursor_hint() returns correct strings for Move, TopLeft, Rotate handles. | +| 80 | `selection_modes_combine_alpha_without_losing_soft_edges` | Tests combine_masks with Replace, Add, Subtract, Intersect modes. | +| 99 | `irregular_mask_bounds_and_fill_spans_are_exact` | Tests mask_bounds and selected_spans on irregular mask. | +| 109 | `crop_clamps_drag_and_cancel_restores_idle_state` | Tests CropState start_drag_clamped, update_drag clamped to canvas, cancel restores state. | +| 120 | `clipboard_transform_is_centered_and_transform_geometry_is_safe` | Tests centered_transform produces correct position and sanitize_geometry handles NaN/negative sizes. | +| 136 | `inverse_affine_identity_sampling_is_exact` | Verifies the inverse-affine rasterizer preserves identity placement exactly. | +| 159 | `rotated_bounds_expand_to_cover_corners` | Tests that rotated bounds include every corner rather than clipping to unrotated box. | +| 177 | `rotated_handle_hit_test_and_resize_share_geometry` | Verifies rotated handle hit-testing and opposite-corner anchoring during resize. | +| 204 | `rotation_drag_adds_delta_to_original_angle` | Verifies rotation uses drag-start delta and retains pre-existing rotation. | + +### `hcie-iced-gui` — tests/feature_scorecard.rs + +| Line | Function | Summary | +|------|----------|---------| +| 55 | `semantic_menu_dispatch_has_no_positional_message` | Verifies menu uses semantic MenuCommand dispatch not positional (usize, usize). | +| 73 | `cut_does_not_dispatch_copy` | Verifies Cut uses distinct MenuCommand::Cut not menu-alias-based Copy. | +| 91 | `cycle_three_canvas_selection_clipboard_crop_text_paths_are_connected` | Verifies selection overlay, distinct cut, transform placement, crop lifecycle, text multiline, gradient, canvas input parity, and pen/spray distinct. | +| 152 | `screenshot_cli_uses_native_capture_and_panel_crop` | Verifies screenshot CLI uses native window capture and panel crop functions. | +| 170 | `cycle_one_state_paths_are_complete_and_persisted` | Verifies dock drop targets, theme sync isolation, and sidebar mode persistence. | +| 211 | `cycle_two_vector_completion_paths_are_connected` | Verifies vector screen-constant handles, constrained drag, layer-bound cancel, boolean result, and clamped controls. | +| 252 | `cycle_four_panel_and_document_paths_are_connected` | Verifies layer hierarchy controls, style cancel/restore, immutable previews, filter schema coverage, document path identity, and close-save continuation. | +| 316 | `cycle_five_practical_paths_are_connected` | Verifies AI real streaming, reasoning transcript, validated script output, viewer deterministic navigation, and safe edit. | + +### `hcie-iced-gui` — tests/raster_test.rs + +| Line | Function | Summary | +|------|----------|---------| +| 6 | `linear_gradient_interpolates_and_respects_mask` | Tests linear gradient interpolation with pixel mask applied. | +| 27 | `radial_gradient_uses_distance_from_origin` | Tests radial gradient using distance from origin. | + +### `hcie-iced-gui` — src/app.rs (internal) + +| Line | Function | Summary | +|------|----------|---------| +| 1536 | `move_commit_pushes_one_named_snapshot` | Tests apply_transform_to_layer pushes exactly one history entry named "Move Selection". | +| 10083 | `popup_and_menu_escape_priority_is_stable` | Tests overlay_escape_target returns correct priority ordering. | +| 10098 | `document_tab_close_uses_semantic_continuations` | Tests document_close_disposition for dirty, clean, last-tab, and invalid scenarios. | +| 10119 | `document_tab_close_keeps_active_index_safe` | Tests active_index_after_document_close produces safe indices. | +| 10128 | `selection_sync_request_is_consumed_once` | Tests consume_selection_sync_request flag is consumed once and stays false until reset. | +| 10140 | `dirty_region_union_retains_all_updates_before_view` | Tests union_regions correctly merges two overlapping regions. | +| 10152 | `proximate_identical_color_is_skipped` | Tests is_proximate_to_last returns true for identical colors (diff=0 ≤ threshold). | +| 10163 | `proximate_small_slider_step_skipped` | Tests is_proximate_to_last returns true for +1 channel change. | +| 10171 | `large_change_not_proximate` | Tests is_proximate_to_last returns false for +3 in all channels. | +| 10179 | `three_unit_change_exceeds_threshold` | Tests is_proximate_to_last returns false for single +3 channel change. | +| 10187 | `mixed_small_changes_are_proximate` | Tests is_proximate_to_last returns true for +1 in all channels. | + +### `hcie-iced-gui` — src/app/transform_dirty.rs + +| Line | Function | Summary | +|------|----------|---------| +| 70 | `consecutive_positions_retain_old_and_new_extents` | Tests that two consecutive transform positions produce correct old+new union bounds. | + +### `hcie-iced-gui` — src/color_picker.rs + +| Line | Function | Summary | +|------|----------|---------| +| 1048 | `rgb_hsl_roundtrip_preserves_channels_with_rounding_tolerance` | Tests RGB→HSL→RGB roundtrip for 4 colors with ≤1 tolerance per channel. | +| 1059 | `rgb_hsv_roundtrip_preserves_channels_with_rounding_tolerance` | Tests RGB→HSV→RGB roundtrip for 4 colors with ≤1 tolerance per channel. | +| 1070 | `hex_parser_preserves_or_explicitly_updates_alpha` | Tests parse_hex_color with 6-digit, 8-digit, and invalid hex strings. | + +### `hcie-iced-gui` — src/ai_chat.rs + +| Line | Function | Summary | +|------|----------|---------| +| 794 | `canvas_context_replaces_only_latest_user_payload` | Tests build_messages_json replaces only the latest user message with canvas context. | +| 818 | `transcript_includes_reasoning_and_tool_results` | Tests transcript_text includes reasoning and tool messages correctly. | + +### `hcie-iced-gui` — src/ai_script.rs + +| Line | Function | Summary | +|------|----------|---------| +| 244 | `generated_output_must_parse_before_run_is_enabled` | Tests validate_script_output accepts valid DSL and rejects invalid input. | + +### `hcie-iced-gui` — src/screenshot.rs + +| Line | Function | Summary | +|------|----------|---------| +| 126 | `physical_crop_scales_and_clamps` | Tests physical_crop_rect scaling rounds outward and clamps to viewport. | + +### `hcie-iced-gui` — src/sidebar/mod.rs + +| Line | Function | Summary | +|------|----------|---------| +| 858 | `every_subtool_has_an_svg_icon` | Tests that every tool in TOOL_SLOTS has a resolvable SVG icon path. | + +### `hcie-iced-gui` — src/theme.rs + +| Line | Function | Summary | +|------|----------|---------| +| 65 | `theme_state_retains_and_reports_selected_preset` | Tests ThemeState preset changes are retained and colors updated accordingly. | + +### `hcie-iced-gui` — src/viewer/mod.rs + +| Line | Function | Summary | +|------|----------|---------| +| 989 | `navigation_wraps_deterministically` | Tests ViewerState prev/next wraps correctly with 2 images. | +| 999 | `supported_extensions_are_case_insensitive` | Tests is_supported_image is case-insensitive and rejects non-image files. | + +### `hcie-iced-gui` — src/settings.rs + +| Line | Function | Summary | +|------|----------|---------| +| 367 | `legacy_settings_default_new_cycle_one_fields` | Tests deserializing legacy settings (without new fields) gets safe defaults. | + +### `hcie-iced-gui` — src/cli.rs + +| Line | Function | Summary | +|------|----------|---------| +| 206 | `parses_full_screenshot_and_startup_file` | Tests CLI parses --screenshot flag with output file and startup file. | +| 223 | `parses_named_panel_screenshot` | Tests CLI parses --screenshot-panel flag. | +| 229 | `parses_svg_editor_screenshot` | Tests CLI parses --screenshot-svg-editor flag. | +| 237 | `rejects_duplicate_positional_files` | Tests CLI rejects multiple positional file arguments. | +| 242 | `supports_dash_prefixed_file_after_terminator` | Tests CLI supports dash-prefixed filenames after -- terminator. | + +### `hcie-iced-gui` — src/canvas/shader_canvas.rs + +| Line | Function | Summary | +|------|----------|---------| +| 1282 | `selection_overlay_uses_one_texture_sample` | Verifies the WGSL shader uses exactly one `textureSample(selection_texture` call (anti-regression for nine-sample detector). | + +### `hcie-iced-gui` — src/canvas/texture_update.rs + +| Line | Function | Summary | +|------|----------|---------| +| 196 | `retained_shader_reference_keeps_stable_allocation_and_dirty_budget` | Tests that retained shader reference keeps stable allocation and correct dirty budget over 120 updates. | +| 227 | `legacy_arc_copy_on_write_reproduction_is_full_canvas` | Demonstrates that Arc::get_mut on a cloned Arc returns None, forcing 33MB fallback clone (documents removed CoW fallback). | +| 237 | `packed_update_is_exact_and_bounds_checked` | Tests TextureUpdate::pack produces correct rows and rejects invalid regions. | +| 253 | `diagnostic_4k_dirty_staging_latency` | Diagnostic benchmark (ignored) measuring 4K dirty region staging latency and byte reduction vs legacy. | + +### `hcie-iced-gui` — src/selection/state.rs + +| Line | Function | Summary | +|------|----------|---------| +| 188 | `selection_texture_encodes_membership_and_border_once` | Tests encode_selection_texture correctly classifies interior (128), border (255), and empty (0). | +| 212 | `feathered_mask_uses_existing_threshold` | Tests feathered mask encoding: values >127 become 255 (border), ≤127 become 0 (outside). | + +### `hcie-iced-gui` — src/panels/menus.rs + +| Line | Function | Summary | +|------|----------|---------| +| 1205 | `enabled_menu_leaves_have_commands` | Verifies enabled menu items always have a command and submenus never have commands. | +| 1232 | `recent_file_commands_are_path_based` | Tests recent file menu items use path-based commands, not positional indices. | +| 1253 | `implemented_static_menu_leaves_match_audited_set` | Snapshot test: verifies all 153 enabled menu entries match expected paths. | +| 1423 | `context_cut_is_enabled_and_distinct_from_copy` | Verifies context Cut uses MenuCommand::Cut with enabled=true. | +| 1432 | `shared_menu_metrics_keep_rows_compact_and_gutters_stable` | Verifies MENU_ROW_HEIGHT, paddings, gutters, and arrow character are within expected ranges. | + +### `hcie-iced-gui` — src/panels/styles.rs + +| Line | Function | Summary | +|------|----------|---------| +| 319 | `tooltip_balloon_is_opaque_and_uses_theme_contrast_tokens` | Tests tooltip balloon background opacity, text color, border, and shadow in two themes. | + +### `hcie-iced-gui` — src/panels/custom_shapes.rs + +| Line | Function | Summary | +|------|----------|---------| +| 211 | `responsive_grid_adds_columns_as_width_grows` | Tests grid_column_count adds one column per ~64px of width (with margin). | + +### `hcie-iced-gui` — src/panels/brushes.rs + +| Line | Function | Summary | +|------|----------|---------| +| 874 | `catalog_contains_every_engine_brush_style` | Asserts BRUSH_STYLES has exactly 34 entries. | +| 879 | `category_filter_keeps_expected_groups` | Tests category_matches_style for various category combinations. | +| 900 | `media_crates_expose_complete_unique_catalog` | Verifies all media brush crates combined give exactly 47 unique presets. | + +### `hcie-iced-gui` — src/panels/svg_editor.rs + +| Line | Function | Summary | +|------|----------|---------| +| 874 | `straight_paths_retain_author_nodes` | Tests straight SVG paths retain their exact node count. | +| 882 | `curved_paths_fall_back_to_dense_visual_nodes` | Tests curved SVG paths flatten to ≥19 visual nodes. | +| 892 | `selection_updates_numeric_inputs_and_snap_is_deterministic` | Tests node selection updates x/y inputs, snap function works correctly, and toggling snap off. | + +### `hcie-iced-gui` — src/panels/title_bar.rs + +| Line | Function | Summary | +|------|----------|---------| +| 299 | `menu_anchors_are_deterministic_across_viewport_widths` | Tests menu_anchor_x calculation for various indices and viewport widths. | + +### `hcie-iced-gui` — src/vector_edit.rs + +| Line | Function | Summary | +|------|----------|---------| +| 386 | `overlap_selection_cycles_without_external_counter` | Tests cycle_selection cycles through hit indices in reverse order without external state. | +| 397 | `handle_hit_area_is_screen_constant_across_zoom` | Tests hit_test_handle returns correct handle at two different zoom levels. | +| 416 | `rotation_handle_offset_is_screen_constant_at_low_zoom` | Tests rotation handle offset is screen-constant at 0.25 zoom. | +| 430 | `every_resize_handle_prevents_inversion_and_nan` | Tests all 8 resize handles prevent inversion and NaN with extreme/finite deltas. | +| 463 | `shift_aspect_and_alt_center_constraints_hold` | Tests Shift+Alt resize preserves 2:1 aspect ratio and original center. | +| 480 | `drag_is_deterministic_and_rejects_non_finite_input` | Tests transform_shape returns correct moved bounds and handles NaN gracefully. | +| 498 | `rotated_handle_hit_test_uses_rotated_position` | Tests hit_test_handle works correctly with a 90° rotated shape. | + +### `hcie-iced-gui` — src/widgets/plain_slider.rs + +| Line | Function | Summary | +|------|----------|---------| +| 442 | `percentage_format_parse_and_clamp_are_stable` | Tests slider format_value with "%" suffix and parse_value clamping. | +| 450 | `hover_typing_accepts_only_numeric_fragments` | Tests typing state accepts "-" and numeric fragments, rejects "px". | +| 460 | `pointer_mapping_respects_endpoints_and_step` | Tests value_at maps 0→0%, 100→100%, 33→~33%. | + +### `hcie-iced-gui` — src/dock/state.rs + +| Line | Function | Summary | +|------|----------|---------| +| 161 | `pane_labels_round_trip` | Tests PaneType::from_label round-trip is case-sensitive. | +| 168 | `pane_drops_support_all_regions_and_preserve_one_canvas` | Tests all dock drop regions accept non-Canvas panes and reject Center for non-Canvas. | +| 195 | `canvas_drop_is_rejected_and_auto_hide_restores_uniquely` | Tests Canvas pane drops reject, auto-hide/restore works, and invariants hold. | +| 214 | `floating_panel_redocks_at_preview_target_and_can_minimize` | Tests floating panel float, redock at target, auto-hide floating, and invariants. | +| 234 | `global_release_clears_click_only_and_floating_drag_state` | Tests cancel_transient_drags clears both drag and floating_drag state. | + +### `hcie-iced-gui` — src/dock/manager.rs + +| Line | Function | Summary | +|------|----------|---------| +| 145 | `policy_matrix_allows_only_tool_edges` | Tests DockDropPolicy::accepts rejects Canvas for Center and allows non-Canvas for edges. | +| 162 | `document_cannot_close_auto_hide_or_float` | Tests DockDropPolicy::accepts_placement rejects Canvas for Closed, AutoHidden, Floating. | + +### `hcie-iced-gui` — src/dock/sizing.rs + +| Line | Function | Summary | +|------|----------|---------| +| 436 | `policy_values_prioritize_canvas_and_bound_color_picker` | Tests panel_size_policy values for Canvas (min 320×240, grow priority) and ColorPicker (preferred 280×460, bounded). | +| 451 | `subtree_constraints_follow_split_axis` | Tests aggregate_subtree_policy produces correct min/max for Canvas+ColorPicker split. | +| 462 | `default_and_compact_viewports_have_feasible_bounds` | Tests both 1280×820 and 1100×700 viewports satisfy all panel minimums in default dock. | +| 483 | `color_picker_does_not_receive_vertical_surplus_with_list_sibling` | Tests vertical surplus distribution keeps ColorPicker within its height bounds with a Layers sibling. | +| 509 | `structure_rebalance_avoids_oversized_color_picker` | Tests StructureChanged rebalance corrects 50/50 split to give Canvas >60%. | +| 526 | `user_resize_preserves_valid_and_clamps_invalid_ratios` | Tests constrain_user_resize_ratio preserves 0.7 and clamps 0.95 to a feasible range. | +| 536 | `window_growth_gives_surplus_to_canvas` | Tests WindowResize rebalance gives all extra width to Canvas, not utility panels. | +| 555 | `color_picker_minimum_height_is_420` | Asserts ColorPicker minimum height policy is exactly 420 pixels. | + +### `hcie-iced-gui` — src/dock/persistence.rs + +| Line | Function | Summary | +|------|----------|---------| +| 171 | `persisted_layout_round_trips_without_runtime_ids` | Tests PersistedDockLayout serialization strips runtime Pane IDs and round-trips with valid invariants. | +| 180 | `malformed_tree_deduplicates_and_injects_canvas` | Tests restore handles duplicate panels (two Layers) by deduplicating and injecting Canvas. | +| 201 | `missing_new_fields_are_serde_compatible` | Tests that minimal JSON ({"tree":null}) deserializes with empty auto_hidden and floating. | +| 208 | `ratio_sanitization_accepts_extreme_finite_values_only` | Tests sanitized_ratio accepts 0.02, clamps 0.999→0.99, and converts NaN→0.5. | +| 221 | `floating_round_trip_clamps_and_redocks_uniquely` | Tests floating panel serialization, clamp within viewport, and unique redock. | + +### `hcie-iced-gui` — src/dock/view.rs + +| Line | Function | Summary | +|------|----------|---------| +| 707 | `title_bar_uses_explicit_drag_handle_and_separate_controls` | Verifies the dock title bar uses explicit mouse_area drag handle and separate controls, not Fill space. | + +### `hcie-iced-gui` — src/dock/preview.rs + +| Line | Function | Summary | +|------|----------|---------| +| 198 | `centers_are_never_approved` | Tests approved_target returns None for grid center point. | +| 210 | `outer_and_pane_edges_are_approved` | Tests approved_target correctly identifies left outer edge and top pane edge. | +| 230 | `narrow_viewport_clamps_to_empty_bounds` | Tests narrow viewport (toolbox > viewport width) produces zero-size grid. | +| 239 | `edge_classification_matches_iced_thirds_and_priority` | Tests pane_edge returns correct edge within first third and None in middle. | +| 257 | `outer_edge_uses_iced_dynamic_thickness_and_priority` | Tests outer_edge returns correct edge within first 10px and None beyond. | + +### `hcie-iced-gui` — src/dock/floating.rs + +| Line | Function | Summary | +|------|----------|---------| +| 118 | `default_rect_clamps_to_small_viewports` | Tests default_rect clamps position so 240×180 window fits in 4-pane viewport. | + +--- + +## Brush Catalog Crates + +| Crate | File | Line | Function | Summary | +|-------|------|------|----------|---------| +| `hcie-dry-media-brushes` | src/lib.rs | 246 | `dry_media_catalog_is_complete_and_unique` | Verifies exactly 19 dry media presets with unique IDs. | +| `hcie-paint-brushes` | src/lib.rs | 129 | `paint_catalog_is_complete_and_unique` | Verifies exactly 7 paint presets with unique IDs. | +| `hcie-digital-brushes` | src/lib.rs | 144 | `digital_catalog_separates_raster_and_vector_tools` | Verifies 5 digital presets and 2 vector brush presets. | +| `hcie-watercolor-brushes` | src/lib.rs | 438 | `presets_count` | Verifies exactly 8 watercolor presets. | +| `hcie-watercolor-brushes` | src/lib.rs | 443 | `all_presets_use_watercolor_style` | Verifies all watercolor presets use BrushStyle::Watercolor. | +| `hcie-watercolor-brushes` | src/lib.rs | 450 | `splat_dimensions` | Verifies render_watercolor_splat produces SPLAT_SIZE×SPLAT_SIZE×4 pixels. | +| `hcie-watercolor-brushes` | src/lib.rs | 456 | `splat_has_nonzero_alpha` | Verifies the rendered splat has at least one visible (non-zero alpha) pixel. | +| `hcie-ink-brushes` | src/lib.rs | 141 | `ink_catalog_is_complete_and_unique` | Verifies exactly 8 ink presets with unique IDs. | + +--- + +## Summary + +| Category | Approx. Count | +|----------|--------------| +| Layer 1: DATA (protocol, color) | 27 | +| Layer 2: ENGINE CORE (blend, brush, draw, composite, filter, selection, vector, history, fx, io, psd, vision, build-info) | ~155 | +| Layer 4: ENGINE API | 15 | +| Layer 6: GUI — egui (canvas_engine, gui_audit, widget_ui, brush_import) | ~90 | +| Layer 6: GUI — iced (selection, feature_scorecard, raster, app, dock, panels, widgets, color, ai, etc.) | ~120+ | +| Brush catalog crates | 8 | +| **Total** | **~415+** |