From d3c3e294357758f24e2b98d86715b898bf563bcf Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:18:16 +0100 Subject: [PATCH 1/3] test: expand coverage for input state, keybindings, and UI helpers --- src/config/action_meta/tests.rs | 15 + src/config/keybindings/tests.rs | 89 ++++ src/draw/render/selection.rs | 35 ++ src/draw/shape/bounds.rs | 38 ++ src/draw/shape/step_marker.rs | 33 ++ src/draw/shape/text_cache.rs | 55 +++ src/input/board_mode.rs | 57 +++ src/input/state/core/base/types.rs | 42 ++ src/input/state/core/board_picker/mod.rs | 73 +++ src/input/state/core/board_picker/search.rs | 163 ++++++- .../state/core/command_palette/layout.rs | 72 +++ src/input/state/core/command_palette/mod.rs | 189 ++++++++ .../state/core/command_palette/search.rs | 34 ++ src/input/state/core/history.rs | 169 +++++++ src/input/state/core/menus/shortcuts.rs | 113 +++++ .../apply_selection/actions/arrow.rs | 95 ++++ .../apply_selection/actions/color.rs | 101 ++++ .../apply_selection/actions/fill.rs | 83 ++++ .../apply_selection/actions/text.rs | 93 ++++ .../properties/apply_selection/helpers.rs | 251 ++++++++++ src/input/state/core/properties/entries.rs | 192 ++++++++ src/input/state/core/properties/panel.rs | 143 ++++++ .../core/properties/panel_layout/focus.rs | 120 +++++ src/input/state/core/properties/summary.rs | 197 ++++++++ src/input/state/core/properties/utils.rs | 49 ++ .../core/selection_actions/delete/tests.rs | 34 ++ src/input/state/core/utility/frozen_zoom.rs | 87 ++++ src/input/state/core/utility/help_overlay.rs | 126 +++++ src/input/state/core/utility/interaction.rs | 105 ++++ src/input/state/core/utility/pending.rs | 100 ++++ src/input/state/core/utility/toasts.rs | 118 +++++ src/input/state/tests/action_bindings.rs | 83 ++++ src/input/state/tests/arrow_labels.rs | 46 ++ src/input/state/tests/board_picker.rs | 456 +++++++++++++++++- src/input/state/tests/boards.rs | 95 ++++ src/input/state/tests/delete_restore.rs | 138 ++++++ src/input/state/tests/menus/context_menu.rs | 230 +++++++++ src/input/state/tests/menus/history.rs | 75 +++ src/input/state/tests/mod.rs | 6 + src/input/state/tests/pages.rs | 134 +++++ src/input/state/tests/presenter_mode.rs | 54 +++ src/input/state/tests/properties_panel.rs | 203 ++++++++ src/input/state/tests/selection/duplicate.rs | 108 +++++ src/input/state/tests/step_markers.rs | 21 + src/input/state/tests/tool_controls.rs | 133 +++++ src/input/tablet/mod.rs | 114 +++++ src/label_format.rs | 32 ++ .../page_panel/thumbnail/icons.rs | 21 + src/ui/constants.rs | 35 ++ src/ui/help_overlay/sections/bindings.rs | 82 ++++ src/ui/toolbar/apply/delays.rs | 93 ++++ src/ui/toolbar/bindings.rs | 142 ++++++ src/util/colors.rs | 44 ++ src/util/geometry.rs | 45 ++ src/util/tests.rs | 30 ++ src/util/text.rs | 10 + 56 files changed, 5499 insertions(+), 2 deletions(-) create mode 100644 src/input/state/tests/action_bindings.rs create mode 100644 src/input/state/tests/boards.rs create mode 100644 src/input/state/tests/delete_restore.rs create mode 100644 src/input/state/tests/pages.rs create mode 100644 src/input/state/tests/properties_panel.rs create mode 100644 src/input/state/tests/tool_controls.rs diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index ef237971..63e6802c 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -224,3 +224,18 @@ fn action_meta_covers_surface_actions() { meta.in_command_palette }); } + +#[test] +fn action_display_label_strips_toggle_prefix() { + assert_eq!(action_display_label(Action::ToggleStatusBar), "Status Bar"); +} + +#[test] +fn action_display_label_strips_mode_suffix() { + assert_eq!(action_display_label(Action::ToggleWhiteboard), "Whiteboard"); +} + +#[test] +fn action_display_label_uses_short_label_for_ellipse_tool() { + assert_eq!(action_display_label(Action::SelectEllipseTool), "Circle"); +} diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 260eeba9..7db8d630 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -52,6 +52,36 @@ fn test_parse_with_spaces() { assert!(binding.shift); } +#[test] +fn test_parse_plus_key() { + let binding = KeyBinding::parse("Ctrl+Shift++").unwrap(); + assert_eq!(binding.key, "+"); + assert!(binding.ctrl); + assert!(binding.shift); + assert!(!binding.alt); +} + +#[test] +fn test_parse_control_alias() { + let binding = KeyBinding::parse("Control+Alt+Delete").unwrap(); + assert_eq!(binding.key, "Delete"); + assert!(binding.ctrl); + assert!(binding.alt); + assert!(!binding.shift); +} + +#[test] +fn test_parse_requires_non_modifier_key() { + let err = KeyBinding::parse("Ctrl+Shift").unwrap_err(); + assert!(err.contains("No key specified")); +} + +#[test] +fn test_display_normalizes_modifier_order() { + let binding = KeyBinding::parse("Shift+Ctrl+W").unwrap(); + assert_eq!(binding.to_string(), "Ctrl+Shift+W"); +} + #[test] fn test_matches() { let binding = KeyBinding::parse("Ctrl+Shift+W").unwrap(); @@ -170,3 +200,62 @@ fn test_duplicate_with_different_modifier_order() { assert!(err_msg.contains("Duplicate keybinding")); assert!(err_msg.contains("Shift+Ctrl+W")); } + +#[test] +fn test_parse_plus_key_without_modifiers() { + let binding = KeyBinding::parse("+").unwrap(); + assert_eq!(binding.key, "+"); + assert!(!binding.ctrl); + assert!(!binding.shift); + assert!(!binding.alt); +} + +#[test] +fn test_parse_trims_surrounding_whitespace() { + let binding = KeyBinding::parse(" Escape ").unwrap(); + assert_eq!(binding.key, "Escape"); + assert!(!binding.ctrl); + assert!(!binding.shift); + assert!(!binding.alt); +} + +#[test] +fn test_matches_requires_exact_alt_state() { + let binding = KeyBinding::parse("Alt+X").unwrap(); + assert!(binding.matches("x", false, false, true)); + assert!(!binding.matches("x", false, false, false)); +} + +#[test] +fn test_build_action_bindings_preserves_declared_binding_order() { + let config = KeybindingsConfig::default(); + let bindings = config.build_action_bindings().unwrap(); + + assert_eq!( + bindings.get(&Action::ToggleHelp), + Some(&vec![ + KeyBinding::parse("F10").unwrap(), + KeyBinding::parse("F1").unwrap(), + ]) + ); + assert_eq!( + bindings.get(&Action::Redo), + Some(&vec![ + KeyBinding::parse("Ctrl+Shift+Z").unwrap(), + KeyBinding::parse("Ctrl+Y").unwrap(), + ]) + ); +} + +#[test] +fn test_build_action_bindings_reports_duplicate_keybindings() { + let mut config = KeybindingsConfig::default(); + config.core.exit = vec!["Ctrl+Z".to_string()]; + config.core.undo = vec!["Ctrl+Z".to_string()]; + + let result = config.build_action_bindings(); + assert!(result.is_err()); + let err_msg = result.unwrap_err(); + assert!(err_msg.contains("Duplicate keybinding")); + assert!(err_msg.contains("Ctrl+Z")); +} diff --git a/src/draw/render/selection.rs b/src/draw/render/selection.rs index 04dffcb3..db3a71b2 100644 --- a/src/draw/render/selection.rs +++ b/src/draw/render/selection.rs @@ -327,3 +327,38 @@ pub fn selection_handle_rects(bounds: &Rect) -> [Rect; 8] { }), ] } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selection_handle_rects_cover_all_corners() { + let bounds = Rect::new(10, 20, 30, 40).unwrap(); + let handles = selection_handle_rects(&bounds); + + assert!(handles[0].contains(10, 20)); + assert!(handles[1].contains(40, 20)); + assert!(handles[2].contains(10, 60)); + assert!(handles[3].contains(40, 60)); + } + + #[test] + fn selection_handle_rects_cover_edge_midpoints() { + let bounds = Rect::new(10, 20, 30, 40).unwrap(); + let handles = selection_handle_rects(&bounds); + + assert!(handles[4].contains(25, 20)); + assert!(handles[5].contains(25, 60)); + assert!(handles[6].contains(10, 40)); + assert!(handles[7].contains(40, 40)); + } + + #[test] + fn selection_handle_rects_remain_valid_for_tiny_bounds() { + let bounds = Rect::new(0, 0, 1, 1).unwrap(); + let handles = selection_handle_rects(&bounds); + + assert!(handles.iter().all(|handle| handle.width > 0 && handle.height > 0)); + } +} diff --git a/src/draw/shape/bounds.rs b/src/draw/shape/bounds.rs index c44d202c..cb506532 100644 --- a/src/draw/shape/bounds.rs +++ b/src/draw/shape/bounds.rs @@ -198,3 +198,41 @@ pub(crate) fn ensure_positive_rect_f64( let max_y = max_y.ceil() as i32; ensure_positive_rect(min_x, min_y, max_x, max_y) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounding_box_for_points_returns_none_for_empty_input() { + assert_eq!(bounding_box_for_points(&[], 2.0), None); + } + + #[test] + fn bounding_box_for_rect_handles_negative_drag_dimensions() { + assert_eq!( + bounding_box_for_rect(10, 20, -4, -6, 1.0), + Rect::new(5, 13, 6, 8) + ); + } + + #[test] + fn ensure_positive_rect_makes_degenerate_integer_bounds_visible() { + assert_eq!(ensure_positive_rect(5, 7, 5, 7), Rect::new(5, 7, 1, 1)); + } + + #[test] + fn ensure_positive_rect_f64_uses_floor_and_ceil_bounds() { + assert_eq!( + ensure_positive_rect_f64(1.2, 3.8, 4.1, 6.0), + Rect::new(1, 3, 4, 3) + ); + } + + #[test] + fn stroke_padding_never_drops_below_one_pixel() { + assert_eq!(stroke_padding(0.0), 1); + assert_eq!(stroke_padding(1.1), 1); + assert_eq!(stroke_padding(2.1), 2); + } +} diff --git a/src/draw/shape/step_marker.rs b/src/draw/shape/step_marker.rs index 9e090cb6..d599046f 100644 --- a/src/draw/shape/step_marker.rs +++ b/src/draw/shape/step_marker.rs @@ -37,3 +37,36 @@ pub(crate) fn step_marker_bounds( let max_y = (y as f64 + total).ceil() as i32; Rect::from_min_max(min_x, min_y, max_x, max_y) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn step_marker_outline_thickness_has_minimum_floor() { + assert_eq!(step_marker_outline_thickness(1.0), 1.5); + assert_eq!(step_marker_outline_thickness(20.0), 2.4); + } + + #[test] + fn step_marker_radius_grows_with_font_size() { + let font = FontDescriptor::default(); + assert!(step_marker_radius(1, 32.0, &font) > step_marker_radius(1, 12.0, &font)); + } + + #[test] + fn step_marker_radius_grows_for_multi_digit_labels() { + let font = FontDescriptor::default(); + assert!(step_marker_radius(88, 18.0, &font) >= step_marker_radius(8, 18.0, &font)); + } + + #[test] + fn step_marker_bounds_are_centered_around_marker_position() { + let font = FontDescriptor::default(); + let bounds = step_marker_bounds(50, 75, 3, 18.0, &font).expect("step marker bounds"); + + assert!(bounds.contains(50, 75)); + assert!(bounds.width > 0); + assert!(bounds.height > 0); + } +} diff --git a/src/draw/shape/text_cache.rs b/src/draw/shape/text_cache.rs index 762dd858..458d5c9f 100644 --- a/src/draw/shape/text_cache.rs +++ b/src/draw/shape/text_cache.rs @@ -198,6 +198,16 @@ pub fn invalidate_text_cache() { mod tests { use super::*; + fn measurement(width: f64) -> TextMeasurement { + TextMeasurement { + ink_x: 0.0, + ink_y: 0.0, + ink_width: width, + ink_height: 10.0, + baseline: 8.0, + } + } + #[test] fn test_cache_returns_same_measurement() { let text = "Hello World"; @@ -248,6 +258,51 @@ mod tests { assert_eq!(m2.ink_height, m2_cached.ink_height); } + #[test] + fn test_cache_evicts_oldest_entry_at_capacity() { + let mut cache = TextMeasurementCache::new(2); + let key_a = TextCacheKey::new("A", "Sans", 12.0, None); + let key_b = TextCacheKey::new("B", "Sans", 12.0, None); + let key_c = TextCacheKey::new("C", "Sans", 12.0, None); + + cache.insert(key_a.clone(), measurement(10.0)); + cache.insert(key_b.clone(), measurement(20.0)); + cache.insert(key_c.clone(), measurement(30.0)); + + assert!(cache.get(&key_a).is_none()); + assert_eq!(cache.get(&key_b).unwrap().ink_width, 20.0); + assert_eq!(cache.get(&key_c).unwrap().ink_width, 30.0); + } + + #[test] + fn test_get_refreshes_lru_order_before_eviction() { + let mut cache = TextMeasurementCache::new(2); + let key_a = TextCacheKey::new("A", "Sans", 12.0, None); + let key_b = TextCacheKey::new("B", "Sans", 12.0, None); + let key_c = TextCacheKey::new("C", "Sans", 12.0, None); + + cache.insert(key_a.clone(), measurement(10.0)); + cache.insert(key_b.clone(), measurement(20.0)); + assert_eq!(cache.get(&key_a).unwrap().ink_width, 10.0); + cache.insert(key_c.clone(), measurement(30.0)); + + assert!(cache.get(&key_b).is_none()); + assert_eq!(cache.get(&key_a).unwrap().ink_width, 10.0); + assert_eq!(cache.get(&key_c).unwrap().ink_width, 30.0); + } + + #[test] + fn test_insert_existing_key_updates_cached_measurement() { + let mut cache = TextMeasurementCache::new(2); + let key = TextCacheKey::new("A", "Sans", 12.0, None); + + cache.insert(key.clone(), measurement(10.0)); + cache.insert(key.clone(), measurement(42.0)); + + assert_eq!(cache.get(&key).unwrap().ink_width, 42.0); + assert_eq!(cache.entries.len(), 1); + } + #[test] fn test_empty_text_returns_none() { let result = measure_text_cached("", "Sans 12", 12.0, None); diff --git a/src/input/board_mode.rs b/src/input/board_mode.rs index 0eda04b7..0da2ade4 100644 --- a/src/input/board_mode.rs +++ b/src/input/board_mode.rs @@ -154,4 +154,61 @@ mod tests { ); assert!(BoardMode::from_str("invalid").is_err()); } + + #[test] + fn test_background_color_uses_custom_config_values() { + let mut config = BoardConfig::default(); + config.whiteboard_color = [0.2, 0.3, 0.4]; + config.blackboard_color = [0.7, 0.6, 0.5]; + + assert_eq!( + BoardMode::Whiteboard.background_color(&config), + Some(Color { + r: 0.2, + g: 0.3, + b: 0.4, + a: 1.0, + }) + ); + assert_eq!( + BoardMode::Blackboard.background_color(&config), + Some(Color { + r: 0.7, + g: 0.6, + b: 0.5, + a: 1.0, + }) + ); + } + + #[test] + fn test_default_pen_color_uses_custom_config_values() { + let mut config = BoardConfig::default(); + config.whiteboard_pen_color = [0.9, 0.8, 0.7]; + config.blackboard_pen_color = [0.1, 0.2, 0.3]; + + assert_eq!( + BoardMode::Whiteboard.default_pen_color(&config), + Some(Color { + r: 0.9, + g: 0.8, + b: 0.7, + a: 1.0, + }) + ); + assert_eq!( + BoardMode::Blackboard.default_pen_color(&config), + Some(Color { + r: 0.1, + g: 0.2, + b: 0.3, + a: 1.0, + }) + ); + } + + #[test] + fn test_from_str_rejects_whitespace_padded_values() { + assert!(BoardMode::from_str(" whiteboard ").is_err()); + } } diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index e2e74594..0ef3ff83 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -352,3 +352,45 @@ pub(crate) struct PendingOnboardingUsage { pub used_help_overlay: bool, pub used_command_palette: bool, } + +#[cfg(test)] +mod tests { + use super::CompositorCapabilities; + + #[test] + fn compositor_capabilities_all_available_requires_every_flag() { + assert!(!CompositorCapabilities::default().all_available()); + assert!(CompositorCapabilities { + layer_shell: true, + screencopy: true, + pointer_constraints: true, + } + .all_available()); + } + + #[test] + fn compositor_capabilities_limitations_summary_returns_none_when_fully_available() { + assert_eq!( + CompositorCapabilities { + layer_shell: true, + screencopy: true, + pointer_constraints: true, + } + .limitations_summary(), + None + ); + } + + #[test] + fn compositor_capabilities_limitations_summary_lists_missing_features_in_order() { + assert_eq!( + CompositorCapabilities { + layer_shell: false, + screencopy: true, + pointer_constraints: false, + } + .limitations_summary(), + Some("Toolbars limited, Pointer lock unavailable".to_string()) + ); + } +} diff --git a/src/input/state/core/board_picker/mod.rs b/src/input/state/core/board_picker/mod.rs index 1ba06ef9..e1ef16c8 100644 --- a/src/input/state/core/board_picker/mod.rs +++ b/src/input/state/core/board_picker/mod.rs @@ -285,3 +285,76 @@ pub enum BoardPickerCursorHint { /// Text editing cursor (I-beam) for name/hex editing. Text, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_search_label_preserves_short_values() { + assert_eq!(truncate_search_label("Blueprint", 24), "Blueprint"); + } + + #[test] + fn truncate_search_label_counts_characters_before_ellipsizing() { + assert_eq!(truncate_search_label("ééééé", 4), "é..."); + } + + #[test] + fn parse_hex_color_accepts_three_digit_notation() { + let color = parse_hex_color("#0f8").expect("3-digit color"); + assert_eq!(color.r, 0.0); + assert_eq!(color.g, 1.0); + assert_eq!(color.b, 136.0 / 255.0); + assert_eq!(color.a, 1.0); + } + + #[test] + fn parse_hex_color_accepts_prefixed_six_digit_notation() { + let color = parse_hex_color(" 0x3366CC ").expect("6-digit color"); + assert_eq!(color.r, 51.0 / 255.0); + assert_eq!(color.g, 102.0 / 255.0); + assert_eq!(color.b, 204.0 / 255.0); + } + + #[test] + fn parse_hex_color_rejects_invalid_digits() { + assert_eq!(parse_hex_color("#12xz89"), None); + } + + #[test] + fn color_to_hex_rounds_and_uppercases_components() { + let color = Color { + r: 0.2, + g: 0.4, + b: 0.6, + a: 0.5, + }; + + assert_eq!(color_to_hex(color), "#336699"); + } + + #[test] + fn contrast_color_prefers_black_for_light_backgrounds() { + let contrast = contrast_color(Color { + r: 0.9, + g: 0.9, + b: 0.9, + a: 1.0, + }); + + assert_eq!(contrast, BLACK); + } + + #[test] + fn contrast_color_prefers_white_for_dark_backgrounds() { + let contrast = contrast_color(Color { + r: 0.1, + g: 0.1, + b: 0.1, + a: 1.0, + }); + + assert_eq!(contrast, WHITE); + } +} diff --git a/src/input/state/core/board_picker/search.rs b/src/input/state/core/board_picker/search.rs index 7083d789..6badae98 100644 --- a/src/input/state/core/board_picker/search.rs +++ b/src/input/state/core/board_picker/search.rs @@ -184,7 +184,58 @@ fn fuzzy_score_with_single_swap(needle: &str, haystack: &str) -> Option { #[cfg(test)] mod tests { - use super::{fuzzy_score, fuzzy_score_relaxed}; + use super::{ + BOARD_PICKER_SEARCH_TIMEOUT, BoardPickerFocus, fuzzy_score, fuzzy_score_relaxed, + }; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode, InputState}; + use std::time::{Duration, Instant}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + let action_bindings = keybindings + .build_action_bindings() + .expect("default keybindings bindings"); + + let mut state = InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ); + state.set_action_bindings(action_bindings); + state + } #[test] fn fuzzy_score_relaxed_handles_transpose() { @@ -196,4 +247,114 @@ mod tests { fn fuzzy_score_relaxed_rejects_unrelated() { assert!(fuzzy_score_relaxed("zz", "blackboard").is_none()); } + + #[test] + fn board_picker_match_index_accepts_numeric_board_selection() { + let mut state = make_state(); + state.open_board_picker(); + + assert_eq!(state.board_picker_match_index("3"), state.board_picker_row_for_board(2)); + } + + #[test] + fn board_picker_match_index_trims_surrounding_whitespace() { + let mut state = make_state(); + state.open_board_picker(); + let blueprint_index = state + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "blueprint") + .expect("blueprint board"); + + assert_eq!( + state.board_picker_match_index(" blue "), + state.board_picker_row_for_board(blueprint_index) + ); + } + + #[test] + fn board_picker_match_index_rejects_whitespace_only_queries() { + let mut state = make_state(); + state.open_board_picker(); + + assert_eq!(state.board_picker_match_index(" \t "), None); + } + + #[test] + fn board_picker_append_search_returns_focus_to_board_list() { + let mut state = make_state(); + state.open_board_picker(); + state.board_picker_set_focus(BoardPickerFocus::PagePanel); + assert_eq!(state.board_picker_focus(), BoardPickerFocus::PagePanel); + + state.board_picker_append_search('b'); + + assert_eq!(state.board_picker_focus(), BoardPickerFocus::BoardList); + assert_eq!(state.board_picker_page_focus_index(), None); + } + + #[test] + fn board_picker_append_search_resets_stale_query_before_appending() { + let mut state = make_state(); + state.open_board_picker(); + state.board_picker_search = "old".to_string(); + state.board_picker_search_last_input = Some( + Instant::now() - BOARD_PICKER_SEARCH_TIMEOUT - Duration::from_millis(1), + ); + + state.board_picker_append_search('b'); + + assert_eq!(state.board_picker_search, "b"); + assert!(state.board_picker_search_last_input.is_some()); + } + + #[test] + fn board_picker_clear_search_reports_when_work_was_done() { + let mut state = make_state(); + state.board_picker_search = "blue".to_string(); + state.board_picker_search_last_input = Some(Instant::now()); + state.needs_redraw = false; + + assert!(state.board_picker_clear_search()); + assert!(state.board_picker_search.is_empty()); + assert!(state.board_picker_search_last_input.is_none()); + assert!(state.needs_redraw); + assert!(!state.board_picker_clear_search()); + } + + #[test] + fn board_picker_backspace_search_clears_timestamp_when_last_character_removed() { + let mut state = make_state(); + state.board_picker_search = "b".to_string(); + state.board_picker_search_last_input = Some(Instant::now()); + + assert!(state.board_picker_backspace_search()); + assert!(state.board_picker_search.is_empty()); + assert!(state.board_picker_search_last_input.is_none()); + } + + #[test] + fn board_picker_append_search_rejects_input_past_max_len() { + let mut state = make_state(); + state.board_picker_search = "x".repeat(super::BOARD_PICKER_SEARCH_MAX_LEN); + + assert!(!state.board_picker_append_search('y')); + assert_eq!( + state.board_picker_search.len(), + super::BOARD_PICKER_SEARCH_MAX_LEN + ); + } + + #[test] + fn fuzzy_score_prefers_prefix_matches_over_internal_matches() { + assert!(fuzzy_score("blue", "blueprint") > fuzzy_score("blue", "my blueprint")); + } + + #[test] + fn fuzzy_score_relaxed_penalizes_transposed_matches_vs_exact_matches() { + let exact = fuzzy_score_relaxed("blackboard", "blackboard").unwrap(); + let swapped = fuzzy_score_relaxed("balckboard", "blackboard").unwrap(); + assert!(exact > swapped); + } } diff --git a/src/input/state/core/command_palette/layout.rs b/src/input/state/core/command_palette/layout.rs index c99d8f02..ef9bcdfe 100644 --- a/src/input/state/core/command_palette/layout.rs +++ b/src/input/state/core/command_palette/layout.rs @@ -166,3 +166,75 @@ impl InputState { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_geometry() -> CommandPaletteGeometry { + CommandPaletteGeometry { + x: 100.0, + y: 200.0, + width: 400.0, + height: 300.0, + inner_x: COMMAND_PALETTE_PADDING, + inner_width: 376.0, + input_top: COMMAND_PALETTE_PADDING, + input_bottom: COMMAND_PALETTE_PADDING + COMMAND_PALETTE_INPUT_HEIGHT, + items_top: COMMAND_PALETTE_PADDING + + COMMAND_PALETTE_INPUT_HEIGHT + + COMMAND_PALETTE_LIST_GAP, + visible_count: 3, + } + } + + #[test] + fn visible_count_caps_at_max_visible() { + assert_eq!(command_palette_visible_count(0), 0); + assert_eq!(command_palette_visible_count(3), 3); + assert_eq!(command_palette_visible_count(99), COMMAND_PALETTE_MAX_VISIBLE); + } + + #[test] + fn command_palette_height_clamps_to_maximum() { + assert!(command_palette_height(1) < COMMAND_PALETTE_MAX_HEIGHT); + assert_eq!(command_palette_height(COMMAND_PALETTE_MAX_VISIBLE), COMMAND_PALETTE_MAX_HEIGHT); + } + + #[test] + fn contains_local_includes_edges_and_rejects_outside_points() { + let geometry = sample_geometry(); + assert!(geometry.contains_local(0.0, 0.0)); + assert!(geometry.contains_local(geometry.width, geometry.height)); + assert!(!geometry.contains_local(-0.1, 0.0)); + assert!(!geometry.contains_local(0.0, geometry.height + 0.1)); + } + + #[test] + fn local_in_input_checks_inner_bounds() { + let geometry = sample_geometry(); + assert!(geometry.local_in_input(geometry.inner_x + 1.0, geometry.input_top + 1.0)); + assert!(!geometry.local_in_input(geometry.inner_x - 1.0, geometry.input_top + 1.0)); + assert!(!geometry.local_in_input(geometry.inner_x + 1.0, geometry.input_bottom + 1.0)); + } + + #[test] + fn visible_item_at_maps_rows_and_rejects_outside_items() { + let geometry = sample_geometry(); + let x = geometry.inner_x + 10.0; + + assert_eq!(geometry.visible_item_at(x, geometry.items_top + 1.0), Some(0)); + assert_eq!( + geometry.visible_item_at(x, geometry.items_top + COMMAND_PALETTE_ITEM_HEIGHT + 1.0), + Some(1) + ); + assert_eq!(geometry.visible_item_at(geometry.inner_x - 1.0, geometry.items_top + 1.0), None); + assert_eq!( + geometry.visible_item_at( + x, + geometry.items_top + geometry.visible_count as f64 * COMMAND_PALETTE_ITEM_HEIGHT + 1.0, + ), + None + ); + } +} diff --git a/src/input/state/core/command_palette/mod.rs b/src/input/state/core/command_palette/mod.rs index 2d0e4c2c..2dccfd07 100644 --- a/src/input/state/core/command_palette/mod.rs +++ b/src/input/state/core/command_palette/mod.rs @@ -146,6 +146,195 @@ mod tests { assert!(actions.contains(&crate::config::keybindings::Action::FocusPrevOutput)); } + #[test] + fn alias_query_matches_radial_menu_command() { + let mut state = make_state(); + state.command_palette_query = "pie menu".to_string(); + + let results = state.filtered_commands(); + assert!(!results.is_empty()); + assert_eq!( + results[0].action, + crate::config::keybindings::Action::ToggleRadialMenu + ); + } + + #[test] + fn short_label_query_matches_configurator_command() { + let mut state = make_state(); + state.command_palette_query = "config ui".to_string(); + + let results = state.filtered_commands(); + assert!(!results.is_empty()); + assert_eq!( + results[0].action, + crate::config::keybindings::Action::OpenConfigurator + ); + } + + #[test] + fn slash_separated_tokens_match_capture_file_command() { + let mut state = make_state(); + state.command_palette_query = "capture/file".to_string(); + + let results = state.filtered_commands(); + assert!(!results.is_empty()); + assert_eq!( + results[0].action, + crate::config::keybindings::Action::CaptureFileFull + ); + } + + #[test] + fn toggle_command_palette_opens_and_tracks_usage() { + let mut state = make_state(); + assert!(!state.command_palette_open); + assert!(!state.pending_onboarding_usage.used_command_palette); + + state.toggle_command_palette(); + + assert!(state.command_palette_open); + assert!(state.pending_onboarding_usage.used_command_palette); + assert_eq!(state.command_palette_selected, 0); + assert_eq!(state.command_palette_scroll, 0); + } + + #[test] + fn backspace_resets_selection_and_scroll_when_query_changes() { + let mut state = make_state(); + state.toggle_command_palette(); + state.command_palette_query = "zoom".to_string(); + state.command_palette_selected = 4; + state.command_palette_scroll = 3; + + assert!(state.handle_command_palette_key(crate::input::Key::Backspace)); + assert_eq!(state.command_palette_query, "zoo"); + assert_eq!(state.command_palette_selected, 0); + assert_eq!(state.command_palette_scroll, 0); + } + + #[test] + fn down_key_scrolls_once_selection_moves_past_visible_window() { + let mut state = make_state(); + state.toggle_command_palette(); + assert!(state.filtered_commands().len() > COMMAND_PALETTE_MAX_VISIBLE); + + for _ in 0..COMMAND_PALETTE_MAX_VISIBLE { + assert!(state.handle_command_palette_key(crate::input::Key::Down)); + } + + assert_eq!(state.command_palette_selected, COMMAND_PALETTE_MAX_VISIBLE); + assert_eq!(state.command_palette_scroll, 1); + } + + #[test] + fn repeated_recent_action_moves_to_front_without_duplication() { + let mut state = make_state(); + state.record_command_palette_action(crate::config::keybindings::Action::CaptureFileFull); + state.record_command_palette_action(crate::config::keybindings::Action::ToggleHelp); + state.record_command_palette_action(crate::config::keybindings::Action::CaptureFileFull); + + assert_eq!( + state.command_palette_recent, + vec![ + crate::config::keybindings::Action::CaptureFileFull, + crate::config::keybindings::Action::ToggleHelp, + ] + ); + } + + #[test] + fn escape_key_closes_command_palette() { + let mut state = make_state(); + state.toggle_command_palette(); + assert!(state.command_palette_open); + + assert!(state.handle_command_palette_key(crate::input::Key::Escape)); + assert!(!state.command_palette_open); + } + + #[test] + fn return_key_executes_selected_command_and_records_it() { + let mut state = make_state(); + state.toggle_command_palette(); + state.command_palette_query = "status bar".to_string(); + let selected = state.selected_command().expect("selected command"); + assert_eq!(selected.action, crate::config::keybindings::Action::ToggleStatusBar); + assert!(state.show_status_bar); + + assert!(state.handle_command_palette_key(crate::input::Key::Return)); + assert!(!state.command_palette_open); + assert!(!state.show_status_bar); + assert_eq!( + state.command_palette_recent.first().copied(), + Some(crate::config::keybindings::Action::ToggleStatusBar) + ); + } + + #[test] + fn clicking_outside_palette_closes_it() { + let mut state = make_state(); + state.toggle_command_palette(); + + assert!(state.handle_command_palette_click(0, 0, 1920, 1000)); + assert!(!state.command_palette_open); + } + + #[test] + fn char_key_appends_query_and_resets_selection_and_scroll() { + let mut state = make_state(); + state.toggle_command_palette(); + state.command_palette_selected = 3; + state.command_palette_scroll = 2; + + assert!(state.handle_command_palette_key(crate::input::Key::Char('z'))); + assert_eq!(state.command_palette_query, "z"); + assert_eq!(state.command_palette_selected, 0); + assert_eq!(state.command_palette_scroll, 0); + } + + #[test] + fn clicking_input_region_keeps_palette_open_without_executing() { + let mut state = make_state(); + state.toggle_command_palette(); + state.command_palette_query = "status".to_string(); + state.command_palette_selected = 1; + let filtered = state.filtered_commands(); + let geometry = state.command_palette_geometry(1920, 1000, filtered.len()); + let x = (geometry.x + geometry.inner_x + 4.0) as i32; + let y = (geometry.y + geometry.input_top + 4.0) as i32; + + assert!(state.handle_command_palette_click(x, y, 1920, 1000)); + assert!(state.command_palette_open); + assert_eq!(state.command_palette_selected, 1); + assert!(state.ui_toast.is_none()); + } + + #[test] + fn clicking_visible_item_executes_selected_command_and_sets_toast() { + let mut state = make_state(); + state.toggle_command_palette(); + state.command_palette_query = "status bar".to_string(); + state.command_palette_selected = 0; + let filtered = state.filtered_commands(); + let selected = filtered.first().expect("selected command"); + assert_eq!( + selected.action, + crate::config::keybindings::Action::ToggleStatusBar + ); + let geometry = state.command_palette_geometry(1920, 1000, filtered.len()); + let x = (geometry.x + geometry.inner_x + 4.0) as i32; + let y = (geometry.y + geometry.items_top + COMMAND_PALETTE_ITEM_HEIGHT * 0.5) as i32; + + assert!(state.show_status_bar); + assert!(state.handle_command_palette_click(x, y, 1920, 1000)); + assert!(!state.command_palette_open); + assert!(!state.show_status_bar); + let toast = state.ui_toast.as_ref().expect("command toast"); + assert_eq!(toast.kind, crate::input::state::core::base::UiToastKind::Info); + assert_eq!(toast.message, selected.label); + } + #[test] fn cursor_hint_rejects_strip_below_clamped_panel_height() { let mut state = make_state(); diff --git a/src/input/state/core/command_palette/search.rs b/src/input/state/core/command_palette/search.rs index 6f9937d2..776444bc 100644 --- a/src/input/state/core/command_palette/search.rs +++ b/src/input/state/core/command_palette/search.rs @@ -177,3 +177,37 @@ fn action_category_name(category: ActionCategory) -> &'static str { ActionCategory::Presets => "presets", } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_query_trims_and_lowercases() { + assert_eq!(normalize_query(" Ctrl+K / Zoom "), "ctrl+k / zoom"); + } + + #[test] + fn query_tokens_split_on_whitespace_plus_and_slash() { + assert_eq!( + query_tokens("ctrl+shift/file open"), + vec!["ctrl", "shift", "file", "open"] + ); + } + + #[test] + fn fuzzy_score_prefers_prefix_matches_over_subsequence_matches() { + assert!(fuzzy_score("cap", "capture to file") > fuzzy_score("cap", "clipboard action")); + } + + #[test] + fn fuzzy_score_prefers_word_boundary_matches_over_plain_substrings() { + assert!(fuzzy_score("bar", "status bar") > fuzzy_score("bar", "crowbar")); + } + + #[test] + fn action_category_name_covers_palette_categories() { + assert_eq!(action_category_name(ActionCategory::Capture), "capture"); + assert_eq!(action_category_name(ActionCategory::Presets), "presets"); + } +} diff --git a/src/input/state/core/history.rs b/src/input/state/core/history.rs index ac02f90e..68aecc5b 100644 --- a/src/input/state/core/history.rs +++ b/src/input/state/core/history.rs @@ -66,3 +66,172 @@ impl InputState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor, Shape, frame::ShapeSnapshot}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + let mut state = InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ); + state.update_screen_dimensions(200, 120); + let _ = state.take_dirty_regions(); + state + } + + fn rect(x: i32, y: i32) -> Shape { + Shape::Rect { + x, + y, + w: 10, + h: 12, + fill: false, + color: Color { + r: 0.2, + g: 0.4, + b: 0.8, + a: 1.0, + }, + thick: 2.0, + } + } + + #[test] + fn apply_action_side_effects_clears_selection_and_sets_redraw_flags() { + let mut state = make_state(); + let shape_id = state.boards.active_frame_mut().add_shape(rect(10, 20)); + let drawn = state + .boards + .active_frame() + .shape(shape_id) + .expect("shape") + .clone(); + state.set_selection(vec![shape_id]); + let _ = state.take_dirty_regions(); + state.needs_redraw = false; + state.session_dirty = false; + + state.apply_action_side_effects(&UndoAction::Create { + shapes: vec![(0, drawn)], + }); + + assert!(state.selected_shape_ids().is_empty()); + assert!(state.needs_redraw); + assert!(state.session_dirty); + assert!(!state.take_dirty_regions().is_empty()); + } + + #[test] + fn apply_action_side_effects_closes_properties_panel_after_modify() { + let mut state = make_state(); + let shape_id = state.boards.active_frame_mut().add_shape(rect(10, 20)); + state.set_selection(vec![shape_id]); + assert!(state.show_properties_panel()); + assert!(state.is_properties_panel_open()); + let _ = state.take_dirty_regions(); + + state.apply_action_side_effects(&UndoAction::Modify { + shape_id, + before: ShapeSnapshot { + shape: rect(10, 20), + locked: false, + }, + after: ShapeSnapshot { + shape: rect(30, 40), + locked: false, + }, + }); + + assert!(!state.is_properties_panel_open()); + assert!(state.selected_shape_ids().is_empty()); + assert!(state.needs_redraw); + assert!(!state.take_dirty_regions().is_empty()); + } + + #[test] + fn apply_action_side_effects_marks_dirty_for_reorder_when_shape_still_exists() { + let mut state = make_state(); + let shape_id = state.boards.active_frame_mut().add_shape(rect(5, 5)); + let _ = state.take_dirty_regions(); + state.needs_redraw = false; + + state.apply_action_side_effects(&UndoAction::Reorder { + shape_id, + from: 0, + to: 1, + }); + + assert!(state.needs_redraw); + assert!(!state.take_dirty_regions().is_empty()); + } + + #[test] + fn apply_action_side_effects_marks_dirty_for_compound_actions() { + let mut state = make_state(); + let first_id = state.boards.active_frame_mut().add_shape(rect(0, 0)); + let second_id = state.boards.active_frame_mut().add_shape(rect(40, 40)); + let first = state + .boards + .active_frame() + .shape(first_id) + .expect("first shape") + .clone(); + let second = state + .boards + .active_frame() + .shape(second_id) + .expect("second shape") + .clone(); + let _ = state.take_dirty_regions(); + + state.apply_action_side_effects(&UndoAction::Compound(vec![ + UndoAction::Create { + shapes: vec![(0, first)], + }, + UndoAction::Delete { + shapes: vec![(1, second)], + }, + ])); + + assert!(state.session_dirty); + assert!(!state.take_dirty_regions().is_empty()); + } +} diff --git a/src/input/state/core/menus/shortcuts.rs b/src/input/state/core/menus/shortcuts.rs index 888deb25..29357205 100644 --- a/src/input/state/core/menus/shortcuts.rs +++ b/src/input/state/core/menus/shortcuts.rs @@ -39,3 +39,116 @@ impl InputState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + BoardsConfig, KeyBinding, KeybindingsConfig, PresenterModeConfig, RadialMenuMouseBinding, + }; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + use std::collections::HashMap; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + let action_bindings = keybindings + .build_action_bindings() + .expect("default keybindings bindings"); + + let mut state = InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ); + state.set_action_bindings(action_bindings); + state + } + + #[test] + fn shortcut_for_help_prefers_f1_over_other_bindings() { + let state = make_state(); + assert_eq!(state.shortcut_for_action(Action::ToggleHelp), Some("F1".to_string())); + } + + #[test] + fn shortcut_for_radial_menu_uses_mouse_binding_when_no_key_binding_exists() { + let state = make_state(); + assert_eq!( + state.shortcut_for_action(Action::ToggleRadialMenu), + Some("Middle Click".to_string()) + ); + } + + #[test] + fn shortcut_for_radial_menu_combines_mouse_and_keyboard_bindings() { + let mut state = make_state(); + let mut action_bindings = HashMap::new(); + action_bindings.insert( + Action::ToggleRadialMenu, + vec![KeyBinding::parse("Ctrl+R").expect("binding")], + ); + state.set_action_bindings(action_bindings); + + assert_eq!( + state.shortcut_for_action(Action::ToggleRadialMenu), + Some("Middle Click / Ctrl+R".to_string()) + ); + } + + #[test] + fn shortcut_for_radial_menu_returns_keyboard_only_when_mouse_binding_disabled() { + let mut state = make_state(); + state.radial_menu_mouse_binding = RadialMenuMouseBinding::Disabled; + let mut action_bindings = HashMap::new(); + action_bindings.insert( + Action::ToggleRadialMenu, + vec![KeyBinding::parse("Ctrl+R").expect("binding")], + ); + state.set_action_bindings(action_bindings); + + assert_eq!( + state.shortcut_for_action(Action::ToggleRadialMenu), + Some("Ctrl+R".to_string()) + ); + } + + #[test] + fn shortcut_for_radial_menu_returns_none_when_fully_unbound() { + let mut state = make_state(); + state.radial_menu_mouse_binding = RadialMenuMouseBinding::Disabled; + state.set_action_bindings(HashMap::new()); + + assert_eq!(state.shortcut_for_action(Action::ToggleRadialMenu), None); + } +} diff --git a/src/input/state/core/properties/apply_selection/actions/arrow.rs b/src/input/state/core/properties/apply_selection/actions/arrow.rs index 213d1386..21ccda69 100644 --- a/src/input/state/core/properties/apply_selection/actions/arrow.rs +++ b/src/input/state/core/properties/apply_selection/actions/arrow.rs @@ -90,3 +90,98 @@ impl InputState { self.report_selection_apply_result(result, "arrow angle") } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + fn add_arrow(state: &mut InputState, head_at_end: bool, arrow_angle: f64) -> crate::draw::ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 0, + y1: 0, + x2: 20, + y2: 10, + color: state.current_color, + thick: 3.0, + arrow_length: 24.0, + arrow_angle, + head_at_end, + label: None, + }) + } + + #[test] + fn apply_selection_arrow_head_on_mixed_selection_sets_heads_to_end() { + let mut state = make_state(); + let first = add_arrow(&mut state, true, 30.0); + let second = add_arrow(&mut state, false, 30.0); + state.set_selection(vec![first, second]); + + assert!(state.apply_selection_arrow_head(0)); + + for id in [first, second] { + match &state.boards.active_frame().shape(id).expect("arrow").shape { + Shape::Arrow { head_at_end, .. } => assert!(*head_at_end), + other => panic!("expected arrow, got {other:?}"), + } + } + } + + #[test] + fn apply_selection_arrow_angle_clamps_to_maximum() { + let mut state = make_state(); + let arrow_id = add_arrow(&mut state, true, MAX_ARROW_ANGLE - 1.0); + state.set_selection(vec![arrow_id]); + + assert!(state.apply_selection_arrow_angle(1)); + assert!(!state.apply_selection_arrow_angle(1)); + + match &state.boards.active_frame().shape(arrow_id).expect("arrow").shape { + Shape::Arrow { arrow_angle, .. } => assert_eq!(*arrow_angle, MAX_ARROW_ANGLE), + other => panic!("expected arrow, got {other:?}"), + } + } +} diff --git a/src/input/state/core/properties/apply_selection/actions/color.rs b/src/input/state/core/properties/apply_selection/actions/color.rs index 94eee80d..71efd3cc 100644 --- a/src/input/state/core/properties/apply_selection/actions/color.rs +++ b/src/input/state/core/properties/apply_selection/actions/color.rs @@ -134,3 +134,104 @@ impl InputState { self.report_selection_apply_result(result, "color") } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::FontDescriptor; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + #[test] + fn apply_selection_color_value_preserves_marker_alpha() { + let mut state = make_state(); + let marker_id = state.boards.active_frame_mut().add_shape(Shape::MarkerStroke { + points: vec![(0, 0), (10, 10)], + color: Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 0.25, + }, + thick: 8.0, + }); + state.set_selection(vec![marker_id]); + + assert!(state.apply_selection_color_value(RED)); + + match &state.boards.active_frame().shape(marker_id).expect("marker").shape { + Shape::MarkerStroke { color, .. } => assert_eq!( + *color, + Color { + r: RED.r, + g: RED.g, + b: RED.b, + a: 0.25, + } + ), + other => panic!("expected marker stroke, got {other:?}"), + } + } + + #[test] + fn apply_selection_color_wraps_palette_forward_from_black_to_red() { + let mut state = make_state(); + let rect_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: crate::draw::BLACK, + thick: 2.0, + }); + state.set_selection(vec![rect_id]); + + assert!(state.apply_selection_color(0)); + + match &state.boards.active_frame().shape(rect_id).expect("rect").shape { + Shape::Rect { color, .. } => assert_eq!(*color, RED), + other => panic!("expected rect, got {other:?}"), + } + } +} diff --git a/src/input/state/core/properties/apply_selection/actions/fill.rs b/src/input/state/core/properties/apply_selection/actions/fill.rs index 5784501b..4b6fb8f0 100644 --- a/src/input/state/core/properties/apply_selection/actions/fill.rs +++ b/src/input/state/core/properties/apply_selection/actions/fill.rs @@ -38,3 +38,86 @@ impl InputState { self.report_selection_apply_result(result, "fill") } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + #[test] + fn apply_selection_fill_on_mixed_selection_turns_all_fills_on() { + let mut state = make_state(); + let rect_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: 2.0, + }); + let ellipse_id = state.boards.active_frame_mut().add_shape(Shape::Ellipse { + cx: 26, + cy: 27, + rx: 6, + ry: 7, + fill: true, + color: state.current_color, + thick: 2.0, + }); + state.set_selection(vec![rect_id, ellipse_id]); + + assert!(state.apply_selection_fill(0)); + + match &state.boards.active_frame().shape(rect_id).expect("rect").shape { + Shape::Rect { fill, .. } => assert!(*fill), + other => panic!("expected rect, got {other:?}"), + } + match &state.boards.active_frame().shape(ellipse_id).expect("ellipse").shape { + Shape::Ellipse { fill, .. } => assert!(*fill), + other => panic!("expected ellipse, got {other:?}"), + } + } +} diff --git a/src/input/state/core/properties/apply_selection/actions/text.rs b/src/input/state/core/properties/apply_selection/actions/text.rs index f43eabb2..3df88b98 100644 --- a/src/input/state/core/properties/apply_selection/actions/text.rs +++ b/src/input/state/core/properties/apply_selection/actions/text.rs @@ -69,3 +69,96 @@ impl InputState { self.report_selection_apply_result(result, "text background") } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + #[test] + fn apply_selection_text_background_warns_when_no_text_shapes_are_selected() { + let mut state = make_state(); + let rect_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: 2.0, + }); + state.set_selection(vec![rect_id]); + + assert!(!state.apply_selection_text_background(0)); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("No text shapes selected.") + ); + } + + #[test] + fn apply_selection_font_size_clamps_to_maximum() { + let mut state = make_state(); + let text_id = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 10, + y: 20, + text: "Note".to_string(), + color: state.current_color, + size: MAX_FONT_SIZE - 1.0, + font_descriptor: state.font_descriptor.clone(), + background_enabled: false, + wrap_width: None, + }); + state.set_selection(vec![text_id]); + + assert!(state.apply_selection_font_size(1)); + assert!(!state.apply_selection_font_size(1)); + + match &state.boards.active_frame().shape(text_id).expect("text").shape { + Shape::Text { size, .. } => assert_eq!(*size, MAX_FONT_SIZE), + other => panic!("expected text, got {other:?}"), + } + } +} diff --git a/src/input/state/core/properties/apply_selection/helpers.rs b/src/input/state/core/properties/apply_selection/helpers.rs index fde0843b..3c675db0 100644 --- a/src/input/state/core/properties/apply_selection/helpers.rs +++ b/src/input/state/core/properties/apply_selection/helpers.rs @@ -172,3 +172,254 @@ impl InputState { true } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + let mut state = InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ); + state.update_screen_dimensions(200, 120); + let _ = state.take_dirty_regions(); + state + } + + fn add_rect(state: &mut InputState, color: Color, fill: bool, locked: bool) -> crate::draw::ShapeId { + let id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 10, + y: 20, + w: 30, + h: 40, + fill, + color, + thick: 2.0, + }); + if locked { + let index = state.boards.active_frame().find_index(id).expect("shape index"); + state.boards.active_frame_mut().shapes[index].locked = true; + } + id + } + + #[test] + fn selection_primary_color_skips_locked_shapes() { + let mut state = make_state(); + let locked = add_rect( + &mut state, + Color { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, + false, + true, + ); + let unlocked = add_rect( + &mut state, + Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + false, + false, + ); + state.set_selection(vec![locked, unlocked]); + + assert_eq!( + state.selection_primary_color(), + Some(Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }) + ); + } + + #[test] + fn selection_bool_target_returns_true_for_mixed_or_locked_only_values() { + let mut state = make_state(); + let first = add_rect(&mut state, Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, false, false); + let second = add_rect(&mut state, Color { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }, true, false); + state.set_selection(vec![first, second]); + + assert_eq!( + state.selection_bool_target(|shape| match shape { + Shape::Rect { fill, .. } => Some(*fill), + _ => None, + }), + Some(true) + ); + + let mut locked_state = make_state(); + let locked = add_rect( + &mut locked_state, + Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + false, + true, + ); + locked_state.set_selection(vec![locked]); + assert_eq!( + locked_state.selection_bool_target(|shape| match shape { + Shape::Rect { fill, .. } => Some(*fill), + _ => None, + }), + Some(true) + ); + } + + #[test] + fn selection_bool_target_flips_uniform_unlocked_value() { + let mut state = make_state(); + let first = add_rect(&mut state, Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, false, false); + let second = add_rect(&mut state, Color { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }, false, false); + state.set_selection(vec![first, second]); + + assert_eq!( + state.selection_bool_target(|shape| match shape { + Shape::Rect { fill, .. } => Some(*fill), + _ => None, + }), + Some(true) + ); + + let frame = state.boards.active_frame_mut(); + if let Shape::Rect { fill, .. } = &mut frame.shape_mut(first).expect("first shape").shape { + *fill = true; + } + if let Shape::Rect { fill, .. } = &mut frame.shape_mut(second).expect("second shape").shape { + *fill = true; + } + + assert_eq!( + state.selection_bool_target(|shape| match shape { + Shape::Rect { fill, .. } => Some(*fill), + _ => None, + }), + Some(false) + ); + } + + #[test] + fn apply_selection_change_reports_applicable_locked_and_changed_counts() { + let mut state = make_state(); + let unlocked = add_rect( + &mut state, + Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + false, + false, + ); + let locked = add_rect( + &mut state, + Color { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, + false, + true, + ); + state.set_selection(vec![unlocked, locked]); + state.needs_redraw = false; + state.session_dirty = false; + + let result = state.apply_selection_change( + |shape| matches!(shape, Shape::Rect { .. }), + |shape| match shape { + Shape::Rect { fill, .. } => { + *fill = true; + true + } + _ => false, + }, + ); + + assert_eq!(result.applicable, 2); + assert_eq!(result.locked, 1); + assert_eq!(result.changed, 1); + assert!(state.needs_redraw); + assert!(state.session_dirty); + assert_eq!(state.boards.active_frame().undo_stack_len(), 1); + assert!(!state.take_dirty_regions().is_empty()); + } + + #[test] + fn report_selection_apply_result_emits_expected_toasts() { + let mut state = make_state(); + + assert!(!state.report_selection_apply_result( + SelectionApplyResult { + changed: 0, + locked: 0, + applicable: 0, + }, + "fill", + )); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("No fill to edit in selection.") + ); + + assert!(!state.report_selection_apply_result( + SelectionApplyResult { + changed: 0, + locked: 2, + applicable: 2, + }, + "color", + )); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("All color shapes are locked.") + ); + + assert!(!state.report_selection_apply_result( + SelectionApplyResult { + changed: 0, + locked: 1, + applicable: 2, + }, + "fill", + )); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("No changes applied.") + ); + + assert!(state.report_selection_apply_result( + SelectionApplyResult { + changed: 1, + locked: 2, + applicable: 3, + }, + "fill", + )); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("2 locked shape(s) unchanged.") + ); + } +} diff --git a/src/input/state/core/properties/entries.rs b/src/input/state/core/properties/entries.rs index 028fb7c7..f31f86e8 100644 --- a/src/input/state/core/properties/entries.rs +++ b/src/input/state/core/properties/entries.rs @@ -218,3 +218,195 @@ impl InputState { entries } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + true, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + fn entry<'a>(entries: &'a [SelectionPropertyEntry], label: &str) -> &'a SelectionPropertyEntry { + entries.iter().find(|entry| entry.label == label).expect(label) + } + + #[test] + fn property_entries_report_mixed_color_for_different_rectangles() { + let mut state = make_state(); + let first = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + thick: 2.0, + }); + let second = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 20, + y: 20, + w: 10, + h: 10, + fill: false, + color: Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 1.0, + }, + thick: 2.0, + }); + + let entries = state.build_selection_property_entries(&[first, second]); + let color = entry(&entries, "Color"); + + assert_eq!(color.value, "Mixed"); + assert!(!color.disabled); + } + + #[test] + fn property_entries_mark_locked_text_properties_as_locked() { + let mut state = make_state(); + let text_id = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 40, + y: 60, + text: "Locked".to_string(), + color: state.current_color, + size: 18.0, + font_descriptor: state.font_descriptor.clone(), + background_enabled: true, + wrap_width: None, + }); + let index = state.boards.active_frame().find_index(text_id).expect("text index"); + state.boards.active_frame_mut().shapes[index].locked = true; + + let entries = state.build_selection_property_entries(&[text_id]); + + assert_eq!(entry(&entries, "Color").value, "Locked"); + assert!(entry(&entries, "Color").disabled); + assert_eq!(entry(&entries, "Font size").value, "Locked"); + assert!(entry(&entries, "Font size").disabled); + assert_eq!(entry(&entries, "Text background").value, "Locked"); + assert!(entry(&entries, "Text background").disabled); + } + + #[test] + fn property_entries_format_arrow_values_for_single_arrow() { + let mut state = make_state(); + let arrow_id = state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 0, + y1: 0, + x2: 20, + y2: 10, + color: state.current_color, + thick: 3.0, + arrow_length: 24.0, + arrow_angle: 35.0, + head_at_end: true, + label: None, + }); + + let entries = state.build_selection_property_entries(&[arrow_id]); + + assert_eq!(entry(&entries, "Arrow head").value, "End"); + assert_eq!(entry(&entries, "Arrow length").value, "24px"); + assert_eq!(entry(&entries, "Arrow angle").value, "35 deg"); + } + + #[test] + fn property_entries_report_mixed_arrow_head_values() { + let mut state = make_state(); + let first = state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 0, + y1: 0, + x2: 20, + y2: 10, + color: state.current_color, + thick: 3.0, + arrow_length: 24.0, + arrow_angle: 35.0, + head_at_end: true, + label: None, + }); + let second = state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 10, + y1: 10, + x2: 30, + y2: 20, + color: state.current_color, + thick: 3.0, + arrow_length: 24.0, + arrow_angle: 35.0, + head_at_end: false, + label: None, + }); + + let entries = state.build_selection_property_entries(&[first, second]); + + assert_eq!(entry(&entries, "Arrow head").value, "Mixed"); + } + + #[test] + fn property_entries_treat_marker_alpha_as_opaque_for_palette_labels() { + let mut state = make_state(); + let marker_id = state.boards.active_frame_mut().add_shape(Shape::MarkerStroke { + points: vec![(0, 0), (10, 10)], + color: Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 0.2, + }, + thick: 8.0, + }); + + let entries = state.build_selection_property_entries(&[marker_id]); + + assert_eq!(entry(&entries, "Color").value, "Red"); + } +} diff --git a/src/input/state/core/properties/panel.rs b/src/input/state/core/properties/panel.rs index fa73a84b..67e76157 100644 --- a/src/input/state/core/properties/panel.rs +++ b/src/input/state/core/properties/panel.rs @@ -210,3 +210,146 @@ impl InputState { self.needs_redraw = true; } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor, Shape, ShapeId}; + use crate::input::{ClickHighlightSettings, EraserMode}; + use crate::input::state::SelectionState; + use std::collections::HashSet; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + fn add_rect(state: &mut InputState, x: i32, y: i32, w: i32, h: i32) -> ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Rect { + x, + y, + w, + h, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }) + } + + fn set_selection_state(state: &mut InputState, ids: Vec) { + let shape_ids_set = ids.iter().copied().collect::>(); + state.selection_state = SelectionState::Active { + shape_ids: ids, + shape_ids_set, + }; + } + + #[test] + fn show_properties_panel_returns_false_without_selection() { + let mut state = make_state(); + + assert!(!state.show_properties_panel()); + assert!(state.properties_panel().is_none()); + } + + #[test] + fn refresh_properties_panel_closes_panel_when_selection_is_empty() { + let mut state = make_state(); + let shape_id = add_rect(&mut state, 10, 20, 30, 40); + state.set_selection(vec![shape_id]); + assert!(state.show_properties_panel()); + + state.selection_state = SelectionState::None; + state.refresh_properties_panel(); + + assert!(state.properties_panel().is_none()); + } + + #[test] + fn refresh_properties_panel_preserves_valid_keyboard_focus() { + let mut state = make_state(); + let shape_id = add_rect(&mut state, 10, 20, 30, 40); + state.set_selection(vec![shape_id]); + assert!(state.show_properties_panel()); + state + .shape_properties_panel + .as_mut() + .expect("panel") + .keyboard_focus = Some(0); + + state.refresh_properties_panel(); + + assert_eq!(state.properties_panel().and_then(|panel| panel.keyboard_focus), Some(0)); + } + + #[test] + fn refresh_properties_panel_clears_invalid_focus_and_hover() { + let mut state = make_state(); + let shape_id = add_rect(&mut state, 10, 20, 30, 40); + state.set_selection(vec![shape_id]); + assert!(state.show_properties_panel()); + let panel = state.shape_properties_panel.as_mut().expect("panel"); + panel.keyboard_focus = Some(99); + panel.hover_index = Some(99); + + state.refresh_properties_panel(); + + let panel = state.properties_panel().expect("panel after refresh"); + assert_eq!(panel.keyboard_focus, None); + assert_eq!(panel.hover_index, None); + } + + #[test] + fn refresh_properties_panel_updates_summary_when_selection_expands() { + let mut state = make_state(); + let first = add_rect(&mut state, 10, 20, 30, 40); + let second = add_rect(&mut state, 80, 30, 20, 10); + state.set_selection(vec![first]); + assert!(state.show_properties_panel()); + + set_selection_state(&mut state, vec![first, second]); + state.refresh_properties_panel(); + + let panel = state.properties_panel().expect("panel after refresh"); + assert_eq!(panel.title, "Selection Properties"); + assert!(panel.multiple_selection); + assert!(panel.lines.iter().any(|line| line == "Shapes selected: 2")); + assert!(panel.lines.iter().any(|line| line.starts_with("Bounds: "))); + } +} diff --git a/src/input/state/core/properties/panel_layout/focus.rs b/src/input/state/core/properties/panel_layout/focus.rs index 060c7586..89d7d953 100644 --- a/src/input/state/core/properties/panel_layout/focus.rs +++ b/src/input/state/core/properties/panel_layout/focus.rs @@ -111,3 +111,123 @@ impl InputState { true } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor, Shape}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + fn open_rect_panel(state: &mut InputState) { + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 10, + y: 20, + w: 30, + h: 40, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + state.set_selection(vec![shape_id]); + assert!(state.show_properties_panel()); + } + + #[test] + fn current_properties_focus_prefers_keyboard_focus_over_hover() { + let mut state = make_state(); + open_rect_panel(&mut state); + let panel = state.shape_properties_panel.as_mut().expect("panel"); + panel.hover_index = Some(1); + panel.keyboard_focus = Some(0); + + assert_eq!(state.current_properties_focus_or_hover(), Some(0)); + } + + #[test] + fn focus_first_properties_entry_skips_disabled_entries() { + let mut state = make_state(); + open_rect_panel(&mut state); + let panel = state.shape_properties_panel.as_mut().expect("panel"); + panel.entries[0].disabled = true; + panel.entries[1].disabled = false; + + assert!(state.focus_first_properties_entry()); + assert_eq!(state.properties_panel().and_then(|panel| panel.keyboard_focus), Some(1)); + } + + #[test] + fn focus_last_properties_entry_skips_disabled_entries() { + let mut state = make_state(); + open_rect_panel(&mut state); + let last = state.properties_panel().expect("panel").entries.len() - 1; + let panel = state.shape_properties_panel.as_mut().expect("panel"); + panel.entries[last].disabled = true; + + assert!(state.focus_last_properties_entry()); + assert_eq!(state.properties_panel().and_then(|panel| panel.keyboard_focus), Some(last - 1)); + } + + #[test] + fn focus_next_properties_entry_uses_hover_when_keyboard_focus_is_missing() { + let mut state = make_state(); + open_rect_panel(&mut state); + let panel = state.shape_properties_panel.as_mut().expect("panel"); + panel.hover_index = Some(0); + panel.keyboard_focus = None; + panel.entries[1].disabled = true; + + assert!(state.focus_next_properties_entry()); + assert_eq!(state.properties_panel().and_then(|panel| panel.keyboard_focus), Some(2)); + } + + #[test] + fn focus_previous_properties_entry_at_start_is_a_stable_no_op() { + let mut state = make_state(); + open_rect_panel(&mut state); + state.set_properties_panel_focus(Some(0)); + + assert!(state.focus_previous_properties_entry()); + assert_eq!(state.properties_panel().and_then(|panel| panel.keyboard_focus), Some(0)); + } +} diff --git a/src/input/state/core/properties/summary.rs b/src/input/state/core/properties/summary.rs index 12f39175..29294c76 100644 --- a/src/input/state/core/properties/summary.rs +++ b/src/input/state/core/properties/summary.rs @@ -135,3 +135,200 @@ pub(super) fn shape_text_background(shape: &Shape) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::draw::FontDescriptor; + use crate::input::state::core::properties::utils::color_eq; + + fn rect(color: Color, fill: bool, thick: f64) -> Shape { + Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill, + color, + thick, + } + } + + #[test] + fn summarize_property_returns_not_applicable_when_no_shapes_support_it() { + let mut frame = Frame::new(); + let text_id = frame.add_shape(Shape::Text { + x: 10, + y: 20, + text: "hello".to_string(), + color: Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }, + size: 16.0, + font_descriptor: FontDescriptor::default(), + background_enabled: false, + wrap_width: None, + }); + + let summary = summarize_property(&frame, &[text_id], shape_fill, |a, b| a == b); + + assert!(!summary.applicable); + assert!(!summary.editable); + assert!(!summary.mixed); + assert!(summary.value.is_none()); + } + + #[test] + fn summarize_property_reports_locked_applicable_shapes_as_not_editable() { + let mut frame = Frame::new(); + let id = frame.add_shape(rect( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + false, + 2.0, + )); + frame.shape_mut(id).expect("locked shape").locked = true; + + let summary = summarize_property(&frame, &[id], shape_color, color_eq); + + assert!(summary.applicable); + assert!(!summary.editable); + assert!(!summary.mixed); + assert!(summary.value.is_none()); + } + + #[test] + fn summarize_property_reports_mixed_when_unlocked_values_differ() { + let mut frame = Frame::new(); + let first = frame.add_shape(rect( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + false, + 2.0, + )); + let second = frame.add_shape(rect( + Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 1.0, + }, + false, + 2.0, + )); + + let summary = summarize_property(&frame, &[first, second], shape_color, color_eq); + + assert!(summary.applicable); + assert!(summary.editable); + assert!(summary.mixed); + assert_eq!( + summary.value, + Some(Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }) + ); + } + + #[test] + fn summarize_property_ignores_locked_shapes_when_computing_value() { + let mut frame = Frame::new(); + let unlocked = frame.add_shape(rect( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + false, + 2.0, + )); + let locked = frame.add_shape(rect( + Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 1.0, + }, + false, + 2.0, + )); + frame.shape_mut(locked).expect("locked shape").locked = true; + + let summary = summarize_property(&frame, &[unlocked, locked], shape_color, color_eq); + + assert!(summary.applicable); + assert!(summary.editable); + assert!(!summary.mixed); + assert_eq!( + summary.value, + Some(Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }) + ); + } + + #[test] + fn shape_color_uses_sticky_note_background_and_marker_opaque_alpha() { + let sticky = Shape::StickyNote { + x: 10, + y: 20, + text: "note".to_string(), + background: Color { + r: 0.2, + g: 0.3, + b: 0.4, + a: 1.0, + }, + size: 18.0, + font_descriptor: FontDescriptor::default(), + wrap_width: None, + }; + let marker = Shape::MarkerStroke { + points: vec![(0, 0), (5, 5)], + color: Color { + r: 1.0, + g: 0.5, + b: 0.0, + a: 0.25, + }, + thick: 6.0, + }; + + assert_eq!( + shape_color(&sticky), + Some(Color { + r: 0.2, + g: 0.3, + b: 0.4, + a: 1.0, + }) + ); + assert_eq!( + shape_color(&marker), + Some(Color { + r: 1.0, + g: 0.5, + b: 0.0, + a: 1.0, + }) + ); + } +} diff --git a/src/input/state/core/properties/utils.rs b/src/input/state/core/properties/utils.rs index 20b52b25..b4be2f2e 100644 --- a/src/input/state/core/properties/utils.rs +++ b/src/input/state/core/properties/utils.rs @@ -52,3 +52,52 @@ pub(super) fn approx_eq(a: &f64, b: &f64) -> bool { pub(super) fn format_timestamp(ms: u64) -> Option { format_unix_millis(ms, "%Y-%m-%d %H:%M") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cycle_index_wraps_forward_and_backward() { + assert_eq!(cycle_index(0, 8, -1), 7); + assert_eq!(cycle_index(7, 8, 1), 0); + assert_eq!(cycle_index(2, 8, 10), 4); + } + + #[test] + fn cycle_index_returns_zero_for_empty_palettes() { + assert_eq!(cycle_index(5, 0, 3), 0); + } + + #[test] + fn color_palette_index_and_label_use_approximate_rgb_matching() { + let near_red = Color { + r: RED.r - 0.009, + g: RED.g, + b: RED.b + 0.009, + a: 0.25, + }; + + assert_eq!(color_palette_index(near_red), Some(0)); + assert_eq!(color_label(near_red), "Red"); + } + + #[test] + fn color_label_returns_custom_outside_palette_tolerance() { + let custom = Color { + r: 0.13, + g: 0.27, + b: 0.61, + a: 1.0, + }; + + assert_eq!(color_palette_index(custom), None); + assert_eq!(color_label(custom), "Custom"); + } + + #[test] + fn approx_eq_uses_stable_threshold_comparisons() { + assert!(approx_eq(&1.0, &1.009)); + assert!(!approx_eq(&1.0, &1.011)); + } +} diff --git a/src/input/state/core/selection_actions/delete/tests.rs b/src/input/state/core/selection_actions/delete/tests.rs index 17490f5b..d2c287b2 100644 --- a/src/input/state/core/selection_actions/delete/tests.rs +++ b/src/input/state/core/selection_actions/delete/tests.rs @@ -60,3 +60,37 @@ fn sample_eraser_path_points_densifies_long_segments() { assert_eq!(sampled.first().copied(), Some((0, 0))); assert_eq!(sampled.last().copied(), Some((20, 0))); } + +#[test] +fn sample_eraser_path_points_returns_borrowed_for_single_point() { + let state = create_test_input_state(); + let points = vec![(5, 7)]; + let sampled = state.sample_eraser_path_points(&points); + + assert!(matches!(sampled, std::borrow::Cow::Borrowed(_))); + assert_eq!(sampled.as_ref(), points.as_slice()); +} + +#[test] +fn sample_eraser_path_points_returns_borrowed_for_dense_segments() { + let state = create_test_input_state(); + let points = vec![(0, 0), (2, 0), (4, 0)]; + let sampled = state.sample_eraser_path_points(&points); + + assert!(matches!(sampled, std::borrow::Cow::Borrowed(_))); + assert_eq!(sampled.as_ref(), points.as_slice()); +} + +#[test] +fn sample_eraser_path_points_avoids_duplicate_points_when_sampling() { + let state = create_test_input_state(); + let points = vec![(0, 0), (0, 20), (0, 20), (0, 40)]; + let sampled = state.sample_eraser_path_points(&points); + + assert!(matches!(sampled, std::borrow::Cow::Owned(_))); + for pair in sampled.windows(2) { + assert_ne!(pair[0], pair[1]); + } + assert_eq!(sampled.first().copied(), Some((0, 0))); + assert_eq!(sampled.last().copied(), Some((0, 40))); +} diff --git a/src/input/state/core/utility/frozen_zoom.rs b/src/input/state/core/utility/frozen_zoom.rs index 23e53874..5a229f95 100644 --- a/src/input/state/core/utility/frozen_zoom.rs +++ b/src/input/state/core/utility/frozen_zoom.rs @@ -56,3 +56,90 @@ impl InputState { self.zoom_scale } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + #[test] + fn frozen_toggle_request_is_consumed_once() { + let mut state = make_state(); + state.request_frozen_toggle(); + + assert!(state.take_pending_frozen_toggle()); + assert!(!state.take_pending_frozen_toggle()); + } + + #[test] + fn set_frozen_active_marks_redraw_only_on_change() { + let mut state = make_state(); + state.needs_redraw = false; + + state.set_frozen_active(true); + assert!(state.frozen_active()); + assert!(state.needs_redraw); + + state.needs_redraw = false; + state.set_frozen_active(true); + assert!(!state.needs_redraw); + } + + #[test] + fn set_zoom_status_updates_accessors_and_marks_redraw_only_on_change() { + let mut state = make_state(); + state.needs_redraw = false; + + state.set_zoom_status(true, true, 2.0); + assert!(state.zoom_active()); + assert!(state.zoom_locked()); + assert_eq!(state.zoom_scale(), 2.0); + assert!(state.needs_redraw); + + state.needs_redraw = false; + state.set_zoom_status(true, true, 2.0); + assert!(!state.needs_redraw); + } +} diff --git a/src/input/state/core/utility/help_overlay.rs b/src/input/state/core/utility/help_overlay.rs index 22f38b79..e251e1b2 100644 --- a/src/input/state/core/utility/help_overlay.rs +++ b/src/input/state/core/utility/help_overlay.rs @@ -201,3 +201,129 @@ impl InputState { Some(HelpOverlayCursorHint::Default) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + #[test] + fn toggle_help_overlay_opens_and_tracks_usage() { + let mut state = make_state(); + state.toggle_help_overlay(); + + assert!(state.show_help); + assert!(!state.help_overlay_quick_mode); + assert!(state.pending_onboarding_usage.used_help_overlay); + assert_eq!(state.help_overlay_page, 0); + } + + #[test] + fn toggle_quick_help_closes_when_already_in_quick_mode() { + let mut state = make_state(); + state.toggle_quick_help(); + assert!(state.show_help); + assert!(state.help_overlay_quick_mode); + + state.toggle_quick_help(); + assert!(!state.show_help); + assert!(!state.help_overlay_quick_mode); + } + + #[test] + fn help_overlay_page_navigation_resets_scroll_and_respects_bounds() { + let mut state = make_state(); + state.help_overlay_scroll = 123.0; + assert!(state.help_overlay_next_page()); + assert_eq!(state.help_overlay_page, 1); + assert_eq!(state.help_overlay_scroll, 0.0); + + state.help_overlay_page = HELP_OVERLAY_MAX_PAGES - 1; + assert!(!state.help_overlay_next_page()); + assert!(state.help_overlay_prev_page()); + assert_eq!(state.help_overlay_page, HELP_OVERLAY_MAX_PAGES - 2); + } + + #[test] + fn help_search_insert_and_cursor_movement_handle_unicode_scalars() { + let mut state = make_state(); + state.help_search_insert("a🙂"); + assert_eq!(state.help_overlay_search, "a🙂"); + assert_eq!(state.help_overlay_search_cursor, 2); + + state.help_search_cursor_left(); + assert_eq!(state.help_overlay_search_cursor, 1); + state.help_search_cursor_right(); + assert_eq!(state.help_overlay_search_cursor, 2); + } + + #[test] + fn help_search_backspace_removes_previous_unicode_character() { + let mut state = make_state(); + state.help_overlay_search = "a🙂b".to_string(); + state.help_overlay_search_cursor = 2; + + state.help_search_backspace(); + + assert_eq!(state.help_overlay_search, "ab"); + assert_eq!(state.help_overlay_search_cursor, 1); + } + + #[test] + fn help_overlay_cursor_hint_uses_nav_region_and_overlay_bounds() { + let mut state = make_state(); + state.toggle_help_overlay(); + + assert_eq!( + state.help_overlay_cursor_hint_at(200, 60, 1000, 800), + Some(HelpOverlayCursorHint::Text) + ); + assert_eq!( + state.help_overlay_cursor_hint_at(200, 200, 1000, 800), + Some(HelpOverlayCursorHint::Default) + ); + assert_eq!(state.help_overlay_cursor_hint_at(5, 5, 1000, 800), None); + } +} diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index c6e281fb..cbf1719d 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -96,3 +96,108 @@ impl InputState { self.dirty_tracker.take_regions(width, height) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + #[test] + fn update_pointer_position_synthetic_updates_pointer_without_redraw() { + let mut state = make_state(); + state.needs_redraw = false; + + state.update_pointer_position_synthetic(12, 34); + + assert_eq!(state.pointer_position(), (12, 34)); + assert!(!state.needs_redraw); + } + + #[test] + fn set_undo_stack_limit_clamps_to_at_least_one() { + let mut state = make_state(); + state.set_undo_stack_limit(0); + assert_eq!(state.undo_stack_limit, 1); + + state.set_undo_stack_limit(25); + assert_eq!(state.undo_stack_limit, 25); + } + + #[test] + fn update_screen_dimensions_updates_cached_values() { + let mut state = make_state(); + state.update_screen_dimensions(1920, 1080); + assert_eq!(state.screen_width, 1920); + assert_eq!(state.screen_height, 1080); + } + + #[test] + fn cancel_text_input_clears_wrap_width_and_returns_to_idle() { + let mut state = make_state(); + state.text_wrap_width = Some(240); + state.state = DrawingState::TextInput { + x: 10, + y: 20, + buffer: "hello".to_string(), + }; + state.needs_redraw = false; + + state.cancel_text_input(); + + assert!(matches!(state.state, DrawingState::Idle)); + assert!(state.text_wrap_width.is_none()); + assert!(state.needs_redraw); + } + + #[test] + fn take_dirty_regions_returns_full_surface_and_drains_tracker() { + let mut state = make_state(); + state.update_screen_dimensions(100, 50); + state.dirty_tracker.mark_full(); + + assert_eq!(state.take_dirty_regions(), vec![Rect::new(0, 0, 100, 50).unwrap()]); + assert!(state.take_dirty_regions().is_empty()); + } +} diff --git a/src/input/state/core/utility/pending.rs b/src/input/state/core/utility/pending.rs index 5c82c4a0..347ef085 100644 --- a/src/input/state/core/utility/pending.rs +++ b/src/input/state/core/utility/pending.rs @@ -42,3 +42,103 @@ impl InputState { self.pending_board_config.take() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + #[test] + fn pending_capture_action_is_taken_once() { + let mut state = make_state(); + state.set_pending_capture_action(Action::CaptureFileFull); + + assert_eq!(state.take_pending_capture_action(), Some(Action::CaptureFileFull)); + assert_eq!(state.take_pending_capture_action(), None); + } + + #[test] + fn pending_output_focus_action_is_taken_once() { + let mut state = make_state(); + state.request_output_focus_action(OutputFocusAction::Next); + + assert_eq!(state.take_pending_output_focus_action(), Some(OutputFocusAction::Next)); + assert_eq!(state.take_pending_output_focus_action(), None); + } + + #[test] + fn pending_zoom_action_is_taken_once() { + let mut state = make_state(); + state.request_zoom_action(ZoomAction::ToggleLock); + + assert_eq!(state.take_pending_zoom_action(), Some(ZoomAction::ToggleLock)); + assert_eq!(state.take_pending_zoom_action(), None); + } + + #[test] + fn pending_preset_action_is_taken_once() { + let mut state = make_state(); + state.pending_preset_action = Some(PresetAction::Clear { slot: 2 }); + + assert!(matches!( + state.take_pending_preset_action(), + Some(PresetAction::Clear { slot: 2 }) + )); + assert!(state.take_pending_preset_action().is_none()); + } + + #[test] + fn pending_board_config_is_taken_once() { + let mut state = make_state(); + let mut config = BoardsConfig::default(); + config.default_board = "blackboard".to_string(); + state.pending_board_config = Some(config.clone()); + + let taken = state.take_pending_board_config().expect("board config"); + assert_eq!(taken.default_board, "blackboard"); + assert_eq!(taken.items.len(), config.items.len()); + assert!(state.take_pending_board_config().is_none()); + } +} diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index 7fc8d4ca..cb7d3706 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -253,3 +253,121 @@ impl InputState { Some((elapsed / total).min(1.0)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + use crate::input::state::core::base::TextEditEntryFeedback; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + #[test] + fn advance_ui_toast_clears_expired_toast_and_bounds() { + let mut state = make_state(); + state.set_ui_toast_with_duration(UiToastKind::Info, "Hello", 10); + state.ui_toast_bounds = Some((1.0, 2.0, 3.0, 4.0)); + let now = state.ui_toast.as_ref().unwrap().started + Duration::from_millis(10); + + assert!(!state.advance_ui_toast(now)); + assert!(state.ui_toast.is_none()); + assert!(state.ui_toast_bounds.is_none()); + } + + #[test] + fn check_toast_click_returns_action_and_dismisses_inside_bounds() { + let mut state = make_state(); + state.set_ui_toast_with_action( + UiToastKind::Info, + "Saved", + "Open", + Action::OpenCaptureFolder, + ); + state.ui_toast_bounds = Some((10.0, 20.0, 100.0, 40.0)); + + let (hit, action) = state.check_toast_click(50, 40); + + assert!(hit); + assert_eq!(action, Some(Action::OpenCaptureFolder)); + assert!(state.ui_toast.is_none()); + assert!(state.ui_toast_bounds.is_none()); + } + + #[test] + fn check_toast_click_ignores_clicks_outside_bounds() { + let mut state = make_state(); + state.set_ui_toast(UiToastKind::Info, "Saved"); + state.ui_toast_bounds = Some((10.0, 20.0, 100.0, 40.0)); + + let (hit, action) = state.check_toast_click(5, 5); + + assert!(!hit); + assert_eq!(action, None); + assert!(state.ui_toast.is_some()); + } + + #[test] + fn save_pending_clipboard_to_file_without_pending_data_warns_and_triggers_feedback() { + let mut state = make_state(); + + state.save_pending_clipboard_to_file(); + + let toast = state.ui_toast.as_ref().expect("warning toast"); + assert_eq!(toast.kind, UiToastKind::Warning); + assert_eq!(toast.message, "No pending image to save"); + assert!(state.blocked_action_feedback.is_some()); + } + + #[test] + fn advance_text_edit_entry_feedback_clears_expired_feedback() { + let mut state = make_state(); + state.text_edit_entry_feedback = Some(TextEditEntryFeedback { + started: Instant::now(), + }); + let now = state.text_edit_entry_feedback.as_ref().unwrap().started + + Duration::from_millis(TEXT_EDIT_ENTRY_DURATION_MS); + + assert!(!state.advance_text_edit_entry_feedback(now)); + assert!(state.text_edit_entry_feedback.is_none()); + } +} diff --git a/src/input/state/tests/action_bindings.rs b/src/input/state/tests/action_bindings.rs new file mode 100644 index 00000000..bf082a43 --- /dev/null +++ b/src/input/state/tests/action_bindings.rs @@ -0,0 +1,83 @@ +use super::*; +use crate::config::KeyBinding; +use std::collections::HashMap; + +#[test] +fn explicit_action_binding_labels_dedup_and_preserve_order() { + let mut state = create_test_input_state(); + let mut bindings = HashMap::new(); + bindings.insert( + Action::ToggleHelp, + vec![ + KeyBinding::parse("Shift+F1").unwrap(), + KeyBinding::parse("Shift+F1").unwrap(), + KeyBinding::parse("F10").unwrap(), + ], + ); + state.set_action_bindings(bindings); + + assert_eq!( + state.action_binding_labels(Action::ToggleHelp), + vec!["Shift+F1".to_string(), "F10".to_string()] + ); +} + +#[test] +fn custom_action_bindings_override_fallback_action_map_labels() { + let mut state = create_test_input_state(); + let mut bindings = HashMap::new(); + bindings.insert( + Action::ToggleHelp, + vec![KeyBinding::parse("Menu").unwrap()], + ); + state.set_action_bindings(bindings); + + assert_eq!( + state.action_binding_labels(Action::ToggleHelp), + vec!["Menu".to_string()] + ); +} + +#[test] +fn fallback_action_binding_labels_are_sorted_when_explicit_bindings_are_missing() { + let mut state = create_test_input_state(); + state.set_action_bindings(HashMap::new()); + + assert_eq!( + state.action_binding_labels(Action::ToggleHelp), + vec!["F1".to_string(), "F10".to_string()] + ); +} + +#[test] +fn action_binding_primary_label_prefers_first_explicit_binding() { + let mut state = create_test_input_state(); + let mut bindings = HashMap::new(); + bindings.insert( + Action::ToggleStatusBar, + vec![ + KeyBinding::parse("F4").unwrap(), + KeyBinding::parse("F12").unwrap(), + ], + ); + state.set_action_bindings(bindings); + + assert_eq!( + state.action_binding_primary_label(Action::ToggleStatusBar), + Some("F4".to_string()) + ); +} + +#[test] +fn find_action_respects_exact_modifier_matches() { + let mut state = create_test_input_state(); + + state.modifiers.ctrl = true; + assert_eq!(state.find_action("z"), Some(Action::Undo)); + + state.modifiers.shift = true; + assert_eq!(state.find_action("z"), Some(Action::Redo)); + + state.modifiers.ctrl = false; + assert_eq!(state.find_action("z"), None); +} diff --git a/src/input/state/tests/arrow_labels.rs b/src/input/state/tests/arrow_labels.rs index cdddc0ab..78f5dfaf 100644 --- a/src/input/state/tests/arrow_labels.rs +++ b/src/input/state/tests/arrow_labels.rs @@ -52,3 +52,49 @@ fn sync_arrow_label_counter_uses_max_across_boards() { state.sync_arrow_label_counter(); assert_eq!(state.arrow_label_counter, 8); } + +#[test] +fn next_arrow_label_returns_none_when_disabled() { + let state = create_test_input_state(); + assert!(state.next_arrow_label().is_none()); +} + +#[test] +fn enabling_arrow_labels_syncs_counter_and_marks_session_dirty() { + let mut state = create_test_input_state(); + let font_descriptor = state.font_descriptor.clone(); + state + .boards + .active_frame_mut() + .add_shape(arrow_with_label(5, &font_descriptor)); + state.needs_redraw = false; + state.session_dirty = false; + + assert!(state.set_arrow_label_enabled(true)); + assert!(state.arrow_label_enabled); + assert_eq!(state.arrow_label_counter, 6); + assert!(state.needs_redraw); + assert!(state.session_dirty); +} + +#[test] +fn enabling_arrow_labels_is_noop_when_already_enabled() { + let mut state = create_test_input_state(); + state.arrow_label_enabled = true; + state.needs_redraw = false; + state.session_dirty = false; + + assert!(!state.set_arrow_label_enabled(true)); + assert!(!state.needs_redraw); + assert!(!state.session_dirty); +} + +#[test] +fn reset_arrow_label_counter_reports_no_change_at_default() { + let mut state = create_test_input_state(); + state.needs_redraw = false; + + assert!(!state.reset_arrow_label_counter()); + assert_eq!(state.arrow_label_counter, 1); + assert!(!state.needs_redraw); +} diff --git a/src/input/state/tests/board_picker.rs b/src/input/state/tests/board_picker.rs index 22caf086..e0eb5dc5 100644 --- a/src/input/state/tests/board_picker.rs +++ b/src/input/state/tests/board_picker.rs @@ -1,7 +1,10 @@ use super::create_test_input_state; use crate::draw::Frame; use crate::input::BoardBackground; -use crate::input::state::core::board_picker::{BoardPickerDrag, BoardPickerFocus}; +use crate::input::state::core::board_picker::{ + BoardPickerDrag, BoardPickerEditMode, BoardPickerFocus, BoardPickerMode, BoardPickerPageDrag, + BoardPickerPageEdit, BoardPickerState, +}; use crate::input::state::{ PAGE_DELETE_ICON_MARGIN, PAGE_DELETE_ICON_SIZE, PAGE_NAME_HEIGHT, PAGE_NAME_PADDING, }; @@ -390,3 +393,454 @@ fn board_picker_page_focus_clamps_to_existing_pages() { input.board_picker_set_page_focus_index(usize::MAX); assert_eq!(input.board_picker_page_focus_index(), Some(0)); } + +#[test] +fn board_picker_footer_text_prefers_active_search_query() { + let mut input = create_test_input_state(); + input.open_board_picker(); + input.board_picker_search = "blue".to_string(); + + assert_eq!( + input.board_picker_footer_text(), + "Search: blue (Esc: clear)" + ); +} + +#[test] +fn board_picker_footer_text_changes_for_quick_and_page_panel_modes() { + let mut input = create_test_input_state(); + input.open_board_picker_quick(); + assert_eq!( + input.board_picker_footer_text(), + "Enter: switch Type: jump Esc: close" + ); + + input.open_board_picker(); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + assert_eq!( + input.board_picker_footer_text(), + "Enter: open F2: rename Del: delete Tab: back Esc: close" + ); +} + +#[test] +fn board_picker_title_and_recent_label_reflect_mode_and_recent_boards() { + let mut input = create_test_input_state(); + input.switch_board("whiteboard"); + input.board_recent = vec![ + "whiteboard".to_string(), + "missing".to_string(), + "blackboard".to_string(), + "transparent".to_string(), + ]; + + input.open_board_picker(); + assert_eq!(input.board_picker_title(3, 8), "Boards (3/8)"); + assert_eq!( + input.board_picker_recent_label(), + Some("Recent: Blackboard, Overlay".to_string()) + ); + + input.open_board_picker_quick(); + assert_eq!(input.board_picker_title(3, 8), "Switch board"); +} + +#[test] +fn board_picker_rename_selected_promotes_quick_mode_to_full_editing() { + let mut input = create_test_input_state(); + let blackboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "blackboard") + .expect("blackboard board"); + + input.open_board_picker_quick(); + let selected_row = input + .board_picker_row_for_board(blackboard_index) + .expect("blackboard row"); + input.board_picker_set_selected(selected_row); + input.board_picker_rename_selected(); + + assert_eq!(input.board_picker_mode(), BoardPickerMode::Full); + assert_eq!( + input.board_picker_edit_state(), + Some((BoardPickerEditMode::Name, selected_row, "Blackboard")) + ); +} + +#[test] +fn board_picker_edit_color_selected_shows_info_toast_for_transparent_board() { + let mut input = create_test_input_state(); + let transparent_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.background.is_transparent()) + .expect("transparent board"); + + input.open_board_picker(); + input.board_picker_set_selected( + input + .board_picker_row_for_board(transparent_index) + .expect("transparent row"), + ); + input.board_picker_edit_color_selected(); + + assert!(input.board_picker_edit_state().is_none()); + assert_eq!( + input.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Overlay board has no background color.") + ); +} + +#[test] +fn board_picker_commit_edit_rejects_invalid_colors_and_keeps_edit_open() { + let mut input = create_test_input_state(); + let blackboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "blackboard") + .expect("blackboard board"); + + input.open_board_picker(); + let selected_row = input + .board_picker_row_for_board(blackboard_index) + .expect("blackboard row"); + input.board_picker_set_selected(selected_row); + input.board_picker_start_edit(BoardPickerEditMode::Color, "oops".to_string()); + + assert!(!input.board_picker_commit_edit()); + assert_eq!( + input.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Invalid color. Use #RRGGBB or RRGGBB.") + ); + assert_eq!( + input.board_picker_edit_state(), + Some((BoardPickerEditMode::Color, selected_row, "oops")) + ); +} + +#[test] +fn open_board_picker_closes_help_and_clears_transient_picker_state() { + let mut input = create_test_input_state(); + input.show_help = true; + input.board_picker_search = "blue".to_string(); + input.board_picker_drag = Some(BoardPickerDrag { + source_row: 0, + source_board: 0, + current_row: 0, + }); + input.board_picker_page_drag = Some(BoardPickerPageDrag { + source_index: 0, + current_index: 0, + board_index: 0, + target_board: Some(0), + }); + input.board_picker_page_edit = Some(BoardPickerPageEdit { + board_index: 0, + page_index: 0, + buffer: "Draft".to_string(), + }); + + input.open_board_picker(); + + assert!(input.is_board_picker_open()); + assert_eq!(input.board_picker_mode(), BoardPickerMode::Full); + assert_eq!(input.board_picker_focus(), BoardPickerFocus::BoardList); + assert!(!input.show_help); + assert!(input.board_picker_search.is_empty()); + assert!(input.board_picker_drag.is_none()); + assert!(input.board_picker_page_drag.is_none()); + assert!(input.board_picker_page_edit.is_none()); + assert_eq!( + input.board_picker_selected_index(), + input.board_picker_row_for_board(input.boards.active_index()) + ); +} + +#[test] +fn close_board_picker_clears_transient_picker_state() { + let mut input = create_test_input_state(); + input.open_board_picker(); + input.board_picker_search = "blue".to_string(); + input.board_picker_drag = Some(BoardPickerDrag { + source_row: 0, + source_board: 0, + current_row: 0, + }); + input.board_picker_page_drag = Some(BoardPickerPageDrag { + source_index: 0, + current_index: 0, + board_index: 0, + target_board: Some(0), + }); + input.board_picker_page_edit = Some(BoardPickerPageEdit { + board_index: 0, + page_index: 0, + buffer: "Draft".to_string(), + }); + + input.close_board_picker(); + + assert!(!input.is_board_picker_open()); + assert!(matches!(input.board_picker_state, BoardPickerState::Hidden)); + assert!(input.board_picker_search.is_empty()); + assert!(input.board_picker_drag.is_none()); + assert!(input.board_picker_page_drag.is_none()); + assert!(input.board_picker_page_edit.is_none()); + assert!(input.board_picker_layout.is_none()); +} + +#[test] +fn board_picker_active_index_prefers_hover_over_selected_row() { + let mut input = create_test_input_state(); + input.open_board_picker(); + input.board_picker_set_selected(0); + + if let BoardPickerState::Open { hover_index, .. } = &mut input.board_picker_state { + *hover_index = Some(1); + } + + assert_eq!(input.board_picker_active_index(), Some(1)); +} + +#[test] +fn board_picker_page_panel_board_index_falls_back_to_active_board_for_new_row() { + let mut input = create_test_input_state(); + input.open_board_picker(); + input.board_picker_set_selected(input.boards.board_count()); + + assert_eq!( + input.board_picker_page_panel_board_index(), + Some(input.boards.active_index()) + ); +} + +#[test] +fn toggle_board_picker_quick_opens_quick_mode_and_closes_on_second_toggle() { + let mut input = create_test_input_state(); + + input.toggle_board_picker_quick(); + assert!(input.is_board_picker_open()); + assert_eq!(input.board_picker_mode(), BoardPickerMode::Quick); + + input.toggle_board_picker_quick(); + assert!(!input.is_board_picker_open()); +} + +#[test] +fn board_picker_activate_row_on_new_row_creates_board_and_starts_editing() { + let mut input = create_test_input_state(); + let initial_count = input.boards.board_count(); + input.open_board_picker(); + + input.board_picker_activate_row(initial_count); + + let active_row = input + .board_picker_row_for_board(input.boards.active_index()) + .expect("active row"); + assert_eq!(input.boards.board_count(), initial_count + 1); + assert_eq!( + input.board_picker_edit_state(), + Some((BoardPickerEditMode::Name, active_row, input.boards.active_board_name())) + ); +} + +#[test] +fn board_picker_activate_page_switches_board_page_and_closes_picker() { + let mut input = create_test_input_state(); + let whiteboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "whiteboard") + .expect("whiteboard board"); + set_board_page_count(&mut input, whiteboard_index, 2); + input.open_board_picker(); + input.board_picker_set_selected( + input + .board_picker_row_for_board(whiteboard_index) + .expect("whiteboard row"), + ); + + input.board_picker_activate_page(1); + + assert_eq!(input.board_id(), "whiteboard"); + assert_eq!(input.boards.active_board().pages.active_index(), 1); + assert!(!input.is_board_picker_open()); +} + +#[test] +fn board_picker_activate_page_ignores_out_of_range_indices() { + let mut input = create_test_input_state(); + let whiteboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "whiteboard") + .expect("whiteboard board"); + set_board_page_count(&mut input, whiteboard_index, 1); + input.open_board_picker(); + input.board_picker_set_selected( + input + .board_picker_row_for_board(whiteboard_index) + .expect("whiteboard row"), + ); + + input.board_picker_activate_page(5); + + assert_eq!(input.board_id(), "transparent"); + assert!(input.is_board_picker_open()); +} + +#[test] +fn board_picker_create_new_from_quick_mode_promotes_to_full_and_starts_editing() { + let mut input = create_test_input_state(); + let initial_count = input.boards.board_count(); + input.open_board_picker_quick(); + + input.board_picker_create_new(); + + let active_row = input + .board_picker_row_for_board(input.boards.active_index()) + .expect("active row"); + assert_eq!(input.board_picker_mode(), BoardPickerMode::Full); + assert_eq!(input.boards.board_count(), initial_count + 1); + assert_eq!( + input.board_picker_edit_state(), + Some((BoardPickerEditMode::Name, active_row, input.boards.active_board_name())) + ); +} + +#[test] +fn board_picker_duplicate_page_uses_selected_page_panel_board() { + let mut input = create_test_input_state(); + let blackboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "blackboard") + .expect("blackboard board"); + set_board_page_count(&mut input, blackboard_index, 1); + input.open_board_picker(); + input.board_picker_set_selected( + input + .board_picker_row_for_board(blackboard_index) + .expect("blackboard row"), + ); + + input.board_picker_duplicate_page(0); + + assert_eq!( + input.boards.board_states()[blackboard_index].pages.page_count(), + 2 + ); +} + +#[test] +fn board_picker_add_page_uses_selected_page_panel_board() { + let mut input = create_test_input_state(); + let blackboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "blackboard") + .expect("blackboard board"); + set_board_page_count(&mut input, blackboard_index, 1); + input.open_board_picker(); + input.board_picker_set_selected( + input + .board_picker_row_for_board(blackboard_index) + .expect("blackboard row"), + ); + + input.board_picker_add_page(); + + assert_eq!( + input.boards.board_states()[blackboard_index].pages.page_count(), + 2 + ); +} + +#[test] +fn board_picker_delete_page_requires_confirmation_for_multi_page_boards() { + let mut input = create_test_input_state(); + let blackboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "blackboard") + .expect("blackboard board"); + set_board_page_count(&mut input, blackboard_index, 2); + input.open_board_picker(); + input.board_picker_set_selected( + input + .board_picker_row_for_board(blackboard_index) + .expect("blackboard row"), + ); + + input.board_picker_delete_page(1); + assert_eq!( + input.boards.board_states()[blackboard_index].pages.page_count(), + 2 + ); + assert!(input + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Click delete again to confirm."))); + + input.board_picker_delete_page(1); + assert_eq!( + input.boards.board_states()[blackboard_index].pages.page_count(), + 1 + ); +} + +#[test] +fn board_picker_delete_selected_ignores_new_row() { + let mut input = create_test_input_state(); + let initial_count = input.boards.board_count(); + input.open_board_picker(); + input.board_picker_set_selected(initial_count); + + input.board_picker_delete_selected(); + + assert_eq!(input.boards.board_count(), initial_count); + assert_eq!(input.board_picker_selected_index(), Some(initial_count)); +} + +#[test] +fn board_picker_toggle_pin_selected_ignores_new_row() { + let mut input = create_test_input_state(); + let board_count = input.boards.board_count(); + input.open_board_picker(); + input.board_picker_set_selected(board_count); + let pinned_before = input.board_picker_pinned_count(); + + input.board_picker_toggle_pin_selected(); + + assert_eq!(input.board_picker_pinned_count(), pinned_before); + assert_eq!(input.board_picker_selected_index(), Some(board_count)); +} + +#[test] +fn board_picker_activate_existing_row_switches_board_and_closes_picker() { + let mut input = create_test_input_state(); + let blackboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "blackboard") + .expect("blackboard board"); + input.open_board_picker(); + let row = input + .board_picker_row_for_board(blackboard_index) + .expect("blackboard row"); + + input.board_picker_activate_row(row); + + assert_eq!(input.board_id(), "blackboard"); + assert!(!input.is_board_picker_open()); +} diff --git a/src/input/state/tests/boards.rs b/src/input/state/tests/boards.rs new file mode 100644 index 00000000..bb0547f9 --- /dev/null +++ b/src/input/state/tests/boards.rs @@ -0,0 +1,95 @@ +use super::*; +use crate::input::{BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD}; +use crate::input::state::core::board_picker::BoardPickerState; + +#[test] +fn switch_board_force_does_not_toggle_back_to_transparent() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + assert_eq!(state.board_id(), BOARD_ID_WHITEBOARD); + + state.switch_board_force(BOARD_ID_WHITEBOARD); + assert_eq!(state.board_id(), BOARD_ID_WHITEBOARD); +} + +#[test] +fn switch_board_recent_skips_current_and_missing_entries() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + state.board_recent = vec![ + BOARD_ID_WHITEBOARD.to_string(), + "missing".to_string(), + "blackboard".to_string(), + ]; + + state.switch_board_recent(); + + assert_eq!(state.board_id(), "blackboard"); +} + +#[test] +fn switch_board_recent_shows_toast_when_no_other_recent_board_exists() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + state.board_recent = vec![BOARD_ID_WHITEBOARD.to_string(), "missing".to_string()]; + + state.switch_board_recent(); + + assert_eq!(state.board_id(), BOARD_ID_WHITEBOARD); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("No recent board to switch to.") + ); +} + +#[test] +fn switch_board_updates_open_board_picker_selection_and_clears_hover() { + let mut state = create_test_input_state(); + state.open_board_picker(); + + if let BoardPickerState::Open { hover_index, .. } = &mut state.board_picker_state { + *hover_index = Some(0); + } + + state.switch_board("blackboard"); + + assert_eq!(state.board_id(), "blackboard"); + assert_eq!( + state.board_picker_selected_index(), + state.board_picker_row_for_board(state.boards.active_index()) + ); + match &state.board_picker_state { + BoardPickerState::Open { hover_index, .. } => assert!(hover_index.is_none()), + BoardPickerState::Hidden => panic!("board picker should remain open"), + } +} + +#[test] +fn duplicate_board_from_transparent_shows_info_toast_without_creating_board() { + let mut state = create_test_input_state(); + let initial_count = state.boards.board_count(); + assert_eq!(state.board_id(), BOARD_ID_TRANSPARENT); + + state.duplicate_board(); + + assert_eq!(state.boards.board_count(), initial_count); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Overlay board cannot be duplicated.") + ); +} + +#[test] +fn create_board_adds_board_queues_config_save_and_emits_toast() { + let mut state = create_test_input_state(); + let initial_count = state.boards.board_count(); + + assert!(state.create_board()); + + assert_eq!(state.boards.board_count(), initial_count + 1); + assert!(state.take_pending_board_config().is_some()); + assert!(state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.starts_with("Board created:"))); +} diff --git a/src/input/state/tests/delete_restore.rs b/src/input/state/tests/delete_restore.rs new file mode 100644 index 00000000..d05a6681 --- /dev/null +++ b/src/input/state/tests/delete_restore.rs @@ -0,0 +1,138 @@ +use super::*; +use crate::draw::{Frame, PageDeleteOutcome}; +use crate::input::{BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT}; + +fn board_index(state: &InputState, id: &str) -> usize { + state + .boards + .board_states() + .iter() + .position(|board| board.spec.id == id) + .expect("board index") +} + +fn set_page_count(state: &mut InputState, board_index: usize, count: usize) { + let pages = state.boards.board_states_mut()[board_index].pages.pages_mut(); + pages.clear(); + pages.extend((0..count.max(1)).map(|_| Frame::new())); +} + +#[test] +fn delete_active_board_requires_confirmation_then_restore_recovers_board() { + let mut state = create_test_input_state(); + let initial_count = state.boards.board_count(); + state.switch_board(BOARD_ID_BLACKBOARD); + + state.delete_active_board(); + assert!(state.has_pending_board_delete()); + assert_eq!(state.boards.board_count(), initial_count); + assert!(state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Click to confirm."))); + + state.delete_active_board(); + assert!(!state.has_pending_board_delete()); + assert_eq!(state.boards.board_count(), initial_count - 1); + assert_ne!(state.board_id(), BOARD_ID_BLACKBOARD); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Board deleted: Blackboard") + ); + + state.restore_deleted_board(); + assert_eq!(state.boards.board_count(), initial_count); + assert_eq!(state.board_id(), BOARD_ID_BLACKBOARD); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Board restored: Blackboard") + ); +} + +#[test] +fn cancel_pending_board_delete_clears_confirmation_state() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_BLACKBOARD); + state.delete_active_board(); + assert!(state.has_pending_board_delete()); + + state.cancel_pending_board_delete(); + + assert!(!state.has_pending_board_delete()); + assert_eq!(state.board_id(), BOARD_ID_BLACKBOARD); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Board deletion cancelled.") + ); +} + +#[test] +fn page_delete_requires_confirmation_and_restore_recovers_deleted_page() { + let mut state = create_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + set_page_count(&mut state, board, 2); + + assert_eq!(state.page_delete(), PageDeleteOutcome::Pending); + assert!(state.has_pending_page_delete()); + assert_eq!(state.boards.page_count(), 2); + + assert_eq!(state.page_delete(), PageDeleteOutcome::Removed); + assert!(!state.has_pending_page_delete()); + assert_eq!(state.boards.page_count(), 1); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Page deleted (1/1)") + ); + + state.restore_deleted_page(); + assert_eq!(state.boards.page_count(), 2); + assert_eq!(state.boards.active_page_index(), 1); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Page restored (2/2)") + ); +} + +#[test] +fn cancel_pending_page_delete_clears_confirmation_state() { + let mut state = create_test_input_state(); + let board = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + set_page_count(&mut state, board, 2); + state.page_delete(); + assert!(state.has_pending_page_delete()); + + state.cancel_pending_page_delete(); + + assert!(!state.has_pending_page_delete()); + assert_eq!(state.boards.page_count(), 2); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Page deletion cancelled.") + ); +} + +#[test] +fn page_delete_on_last_page_clears_shapes_without_removing_page() { + let mut state = create_test_input_state(); + assert_eq!(state.board_id(), BOARD_ID_TRANSPARENT); + state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + + assert_eq!(state.page_delete(), PageDeleteOutcome::Cleared); + + assert_eq!(state.boards.page_count(), 1); + assert!(state.boards.active_frame().shapes.is_empty()); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Page cleared (last page)") + ); +} diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index d9b95a95..84d7b113 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -1,4 +1,34 @@ use super::*; +use crate::draw::{BoardPages, Frame}; +use crate::input::{BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT}; + +fn board_index(state: &InputState, id: &str) -> usize { + state + .boards + .board_states() + .iter() + .position(|board| board.spec.id == id) + .expect("board index") +} + +fn set_named_pages( + state: &mut InputState, + board_index: usize, + names: &[Option<&str>], + active: usize, +) { + let pages = names + .iter() + .map(|name| { + let mut frame = Frame::new(); + if let Some(name) = name { + frame.set_page_name(Some((*name).to_string())); + } + frame + }) + .collect(); + state.boards.board_states_mut()[board_index].pages = BoardPages::from_pages(pages, active); +} #[test] fn context_menu_respects_enable_flag() { @@ -266,3 +296,203 @@ fn context_menu_open_radial_command_opens_radial_and_closes_context_menu() { assert!(state.is_radial_menu_open()); assert!(!state.is_context_menu_open()); } + +#[test] +fn canvas_menu_uses_clear_unlocked_label_when_canvas_has_locked_shapes() { + let mut state = create_test_input_state(); + let locked = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 20, + y: 20, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + let locked_index = state.boards.active_frame().find_index(locked).expect("locked index"); + state.boards.active_frame_mut().shapes[locked_index].locked = true; + + state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Canvas, None); + + let clear_entry = state + .context_menu_entries() + .into_iter() + .find(|entry| entry.command == Some(MenuCommand::ClearAll)) + .expect("clear entry"); + assert_eq!(clear_entry.label, "Clear Unlocked"); + assert!(!clear_entry.disabled); +} + +#[test] +fn page_context_menu_header_uses_page_name_and_enables_move_submenu() { + let mut state = create_test_input_state(); + let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); + set_named_pages(&mut state, blackboard, &[None, Some("Agenda")], 1); + + state.open_page_context_menu((5, 5), blackboard, 1); + + let entries = state.context_menu_entries(); + assert_eq!(entries[0].label, "Agenda — Page 2 (2/2)"); + let move_entry = entries + .iter() + .find(|entry| entry.command == Some(MenuCommand::OpenPageMoveMenu)) + .expect("move entry"); + assert!(move_entry.has_submenu); + assert!(!move_entry.disabled); +} + +#[test] +fn page_move_menu_excludes_source_board_and_lists_other_boards() { + let mut state = create_test_input_state(); + let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); + state.open_page_context_menu((5, 5), blackboard, 0); + + state.execute_menu_command(MenuCommand::OpenPageMoveMenu); + + let entries = state.context_menu_entries(); + assert!(entries.iter().any(|entry| entry.label == "Overlay")); + assert!(entries.iter().any(|entry| entry.label == "Whiteboard")); + assert!(!entries.iter().any(|entry| entry.label == "Blackboard")); +} + +#[test] +fn pages_menu_shows_window_indicators_around_active_page() { + let mut state = create_test_input_state(); + let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); + let pages = (0..10) + .map(|index| { + let mut frame = Frame::new(); + frame.set_page_name(Some(format!("Page {index}"))); + frame + }) + .collect(); + state.boards.board_states_mut()[blackboard].pages = BoardPages::from_pages(pages, 5); + state.switch_board(BOARD_ID_BLACKBOARD); + state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Pages, None); + + let entries = state.context_menu_entries(); + assert!(entries.iter().any(|entry| entry.label == " ... 1 above")); + assert!(entries.iter().any(|entry| entry.label == " ... 1 below")); + assert!(entries + .iter() + .any(|entry| entry.label == " Page 6 (current)" && entry.disabled)); +} + +#[test] +fn boards_menu_disables_delete_for_transparent_board_and_shows_overflow_entry() { + let mut state = create_test_input_state(); + state.switch_board_slot(8); + state.switch_board_slot(5); + + state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Boards, None); + let overflow_entries = state + .context_menu_entries() + .into_iter() + .filter(|entry| entry.command == Some(MenuCommand::OpenBoardPicker)) + .collect::>(); + assert_eq!(overflow_entries.len(), 1); + assert!(overflow_entries[0].label.contains("open picker")); + + state.switch_board(BOARD_ID_TRANSPARENT); + state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Boards, None); + let delete_entry = state + .context_menu_entries() + .into_iter() + .find(|entry| entry.command == Some(MenuCommand::BoardDelete)) + .expect("delete board entry"); + assert!(delete_entry.disabled); +} + +#[test] +fn open_pages_menu_command_switches_to_pages_submenu_with_actionable_focus() { + let mut state = create_test_input_state(); + state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Canvas, None); + + state.execute_menu_command(MenuCommand::OpenPagesMenu); + + let focus_index = match &state.context_menu_state { + ContextMenuState::Open { + kind: ContextMenuKind::Pages, + keyboard_focus, + .. + } => keyboard_focus.expect("pages submenu focus"), + _ => panic!("expected pages submenu to be open"), + }; + let entries = state.context_menu_entries(); + assert!(!entries[focus_index].disabled); + assert!(entries[focus_index].command.is_some()); +} + +#[test] +fn open_boards_menu_command_switches_to_boards_submenu_with_actionable_focus() { + let mut state = create_test_input_state(); + state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Canvas, None); + + state.execute_menu_command(MenuCommand::OpenBoardsMenu); + + let focus_index = match &state.context_menu_state { + ContextMenuState::Open { + kind: ContextMenuKind::Boards, + keyboard_focus, + .. + } => keyboard_focus.expect("boards submenu focus"), + _ => panic!("expected boards submenu to be open"), + }; + let entries = state.context_menu_entries(); + assert!(!entries[focus_index].disabled); + assert!(entries[focus_index].command.is_some()); +} + +#[test] +fn page_duplicate_from_context_duplicates_target_page_and_closes_menu() { + let mut state = create_test_input_state(); + let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); + set_named_pages(&mut state, blackboard, &[Some("Only page")], 0); + state.open_page_context_menu((5, 5), blackboard, 0); + + state.execute_menu_command(MenuCommand::PageDuplicateFromContext); + + assert_eq!(state.boards.board_states()[blackboard].pages.page_count(), 2); + assert!(!state.is_context_menu_open()); +} + +#[test] +fn page_move_to_board_command_moves_page_switches_board_and_closes_menu() { + let mut state = create_test_input_state(); + let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); + let whiteboard = board_index(&state, "whiteboard"); + set_named_pages(&mut state, blackboard, &[Some("Keep"), Some("Move me")], 1); + set_named_pages(&mut state, whiteboard, &[Some("Target")], 0); + state.open_page_context_menu((5, 5), blackboard, 1); + + state.execute_menu_command(MenuCommand::PageMoveToBoard { + id: "whiteboard".to_string(), + }); + + assert_eq!(state.board_id(), "whiteboard"); + assert_eq!(state.boards.board_states()[blackboard].pages.page_count(), 1); + assert_eq!(state.boards.board_states()[whiteboard].pages.page_count(), 2); + assert_eq!(state.boards.board_states()[whiteboard].pages.page_name(1), Some("Move me")); + assert!(!state.is_context_menu_open()); +} + +#[test] +fn open_board_picker_command_closes_context_menu_and_opens_picker() { + let mut state = create_test_input_state(); + state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Canvas, None); + assert!(state.is_context_menu_open()); + + state.execute_menu_command(MenuCommand::OpenBoardPicker); + + assert!(!state.is_context_menu_open()); + assert!(state.is_board_picker_open()); +} diff --git a/src/input/state/tests/menus/history.rs b/src/input/state/tests/menus/history.rs index 524de0d1..fb50b804 100644 --- a/src/input/state/tests/menus/history.rs +++ b/src/input/state/tests/menus/history.rs @@ -1,5 +1,29 @@ use super::*; +fn push_rect_create(state: &mut InputState, x: i32) { + let color = state.current_color; + let thick = state.current_thickness; + let undo_limit = state.undo_stack_limit; + let frame = state.boards.active_frame_mut(); + let id = frame.add_shape(Shape::Rect { + x, + y: x, + w: 10, + h: 10, + fill: false, + color, + thick, + }); + let index = frame.find_index(id).expect("shape index"); + let snapshot = frame.shape(id).expect("shape snapshot").clone(); + frame.push_undo_action( + UndoAction::Create { + shapes: vec![(index, snapshot)], + }, + undo_limit, + ); +} + #[test] fn undo_all_and_redo_all_process_entire_stack() { let mut state = create_test_input_state(); @@ -111,3 +135,54 @@ fn redo_all_with_delay_replays_history() { assert_eq!(state.boards.active_frame().shapes.len(), 1); assert_eq!(state.boards.active_frame().undo_stack_len(), 1); } + +#[test] +fn custom_undo_uses_step_budget_and_minimum_delay_between_steps() { + let mut state = create_test_input_state(); + push_rect_create(&mut state, 0); + push_rect_create(&mut state, 20); + assert_eq!(state.boards.active_frame().shapes.len(), 2); + + state.start_custom_undo(0, 5); + let now = std::time::Instant::now(); + + assert!(state.has_pending_history()); + assert!(state.tick_delayed_history(now)); + assert_eq!(state.boards.active_frame().shapes.len(), 1); + assert!(state.has_pending_history()); + + assert!(!state.tick_delayed_history(now + std::time::Duration::from_millis(49))); + assert_eq!(state.boards.active_frame().shapes.len(), 1); + + assert!(state.tick_delayed_history(now + std::time::Duration::from_millis(50))); + assert_eq!(state.boards.active_frame().shapes.len(), 0); + assert!(!state.has_pending_history()); +} + +#[test] +fn custom_redo_respects_step_budget() { + let mut state = create_test_input_state(); + push_rect_create(&mut state, 0); + push_rect_create(&mut state, 20); + state.undo_all_immediate(); + assert_eq!(state.boards.active_frame().redo_stack_len(), 2); + + state.start_custom_redo(0, 1); + assert!(state.tick_delayed_history(std::time::Instant::now())); + + assert_eq!(state.boards.active_frame().shapes.len(), 1); + assert_eq!(state.boards.active_frame().undo_stack_len(), 1); + assert!(!state.has_pending_history()); +} + +#[test] +fn delayed_history_is_not_queued_when_no_steps_are_available() { + let mut state = create_test_input_state(); + + state.start_undo_all_delayed(0); + state.start_redo_all_delayed(0); + state.start_custom_undo(0, 3); + state.start_custom_redo(0, 3); + + assert!(!state.has_pending_history()); +} diff --git a/src/input/state/tests/mod.rs b/src/input/state/tests/mod.rs index 562fa9e7..31a128ca 100644 --- a/src/input/state/tests/mod.rs +++ b/src/input/state/tests/mod.rs @@ -8,17 +8,23 @@ use crate::util; mod helpers; use helpers::{create_test_input_state, create_test_input_state_with_keybindings}; +mod action_bindings; mod arrow_labels; mod basics; mod board_picker; +mod boards; +mod delete_restore; mod drawing; mod erase; mod menus; +mod pages; mod presenter_mode; mod pressure_modes; +mod properties_panel; mod radial_menu; mod selection; mod step_markers; mod text_edit; mod text_input; +mod tool_controls; mod transform; diff --git a/src/input/state/tests/pages.rs b/src/input/state/tests/pages.rs new file mode 100644 index 00000000..c631ca20 --- /dev/null +++ b/src/input/state/tests/pages.rs @@ -0,0 +1,134 @@ +use super::*; +use crate::draw::{BoardPages, Frame}; +use crate::input::{BOARD_ID_BLACKBOARD, BOARD_ID_WHITEBOARD, BoardBackground}; + +fn board_index(state: &InputState, id: &str) -> usize { + state + .boards + .board_states() + .iter() + .position(|board| board.spec.id == id) + .expect("board index") +} + +fn named_frame(name: &str) -> Frame { + let mut frame = Frame::new(); + frame.set_page_name(Some(name.to_string())); + frame +} + +fn set_named_pages(state: &mut InputState, board_index: usize, names: &[&str], active: usize) { + let pages = names.iter().map(|name| named_frame(name)).collect(); + state.boards.board_states_mut()[board_index].pages = BoardPages::from_pages(pages, active); +} + +#[test] +fn set_board_name_trims_and_queues_config_save() { + let mut state = create_test_input_state(); + let index = board_index(&state, BOARD_ID_BLACKBOARD); + + assert!(state.set_board_name(index, " Focus Board ".to_string())); + + assert_eq!(state.boards.board_states()[index].spec.name, "Focus Board"); + assert!(state.take_pending_board_config().is_some()); +} + +#[test] +fn set_board_name_rejects_empty_input_with_warning_toast() { + let mut state = create_test_input_state(); + let index = board_index(&state, BOARD_ID_BLACKBOARD); + let original = state.boards.board_states()[index].spec.name.clone(); + + assert!(!state.set_board_name(index, " \t ".to_string())); + + assert_eq!(state.boards.board_states()[index].spec.name, original); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Board name cannot be empty.") + ); +} + +#[test] +fn set_board_background_color_updates_active_auto_adjust_pen_color() { + let mut state = create_test_input_state(); + let index = board_index(&state, BOARD_ID_WHITEBOARD); + state.switch_board(BOARD_ID_WHITEBOARD); + + let new_color = Color { + r: 0.1, + g: 0.1, + b: 0.1, + a: 1.0, + }; + + assert!(state.set_board_background_color(index, new_color)); + + let board = &state.boards.board_states()[index]; + assert!(matches!(board.spec.background, BoardBackground::Solid(color) if color == new_color)); + assert_eq!( + board.spec.default_pen_color, + Some(Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }) + ); + assert_eq!(state.current_color, board.spec.effective_pen_color().expect("pen color")); + assert!(state.take_pending_board_config().is_some()); +} + +#[test] +fn reorder_page_in_board_moves_named_pages_and_active_index() { + let mut state = create_test_input_state(); + let index = board_index(&state, BOARD_ID_BLACKBOARD); + set_named_pages(&mut state, index, &["One", "Two", "Three"], 0); + + assert!(state.reorder_page_in_board(index, 0, 2)); + + let pages = &state.boards.board_states()[index].pages; + assert_eq!(pages.page_name(0), Some("Two")); + assert_eq!(pages.page_name(1), Some("Three")); + assert_eq!(pages.page_name(2), Some("One")); + assert_eq!(pages.active_index(), 2); +} + +#[test] +fn move_page_between_boards_copy_preserves_source_and_adds_page_to_target() { + let mut state = create_test_input_state(); + let source = board_index(&state, BOARD_ID_WHITEBOARD); + let target = board_index(&state, BOARD_ID_BLACKBOARD); + set_named_pages(&mut state, source, &["Copied page"], 0); + set_named_pages(&mut state, target, &["Target page"], 0); + + assert!(state.move_page_between_boards(source, 0, target, true)); + + assert_eq!(state.boards.board_states()[source].pages.page_count(), 1); + assert_eq!(state.boards.board_states()[target].pages.page_count(), 2); + assert_eq!(state.boards.board_states()[target].pages.page_name(1), Some("Copied page")); + assert!(state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page copied to 'Blackboard'"))); +} + +#[test] +fn move_page_between_boards_move_removes_source_page_and_activates_target_copy() { + let mut state = create_test_input_state(); + let source = board_index(&state, BOARD_ID_WHITEBOARD); + let target = board_index(&state, BOARD_ID_BLACKBOARD); + set_named_pages(&mut state, source, &["Keep", "Move me"], 1); + set_named_pages(&mut state, target, &["Target page"], 0); + + assert!(state.move_page_between_boards(source, 1, target, false)); + + assert_eq!(state.boards.board_states()[source].pages.page_count(), 1); + assert_eq!(state.boards.board_states()[source].pages.page_name(0), Some("Keep")); + assert_eq!(state.boards.board_states()[target].pages.page_count(), 2); + assert_eq!(state.boards.board_states()[target].pages.page_name(1), Some("Move me")); + assert_eq!(state.boards.board_states()[target].pages.active_index(), 1); + assert!(state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page moved to 'Blackboard'"))); +} diff --git a/src/input/state/tests/presenter_mode.rs b/src/input/state/tests/presenter_mode.rs index 7ec9c82a..ecf61b8e 100644 --- a/src/input/state/tests/presenter_mode.rs +++ b/src/input/state/tests/presenter_mode.rs @@ -58,3 +58,57 @@ fn presenter_mode_blocks_tool_preview_toggle() { assert!(!state.apply_toolbar_event(ToolbarEvent::ToggleToolPreview(true))); assert!(!state.show_tool_preview); } + +#[test] +fn presenter_mode_closes_help_overlay_and_switches_to_highlight_tool() { + let mut state = create_test_input_state(); + state.show_help = true; + state.set_tool_override(Some(Tool::Pen)); + + state.toggle_presenter_mode(); + + assert!(state.presenter_mode); + assert!(!state.show_help); + assert_eq!(state.tool_override(), Some(Tool::Highlight)); +} + +#[test] +fn presenter_mode_restores_status_bar_toolbars_and_tool_override_on_exit() { + let mut state = create_test_input_state(); + state.show_status_bar = true; + state.toolbar_visible = true; + state.toolbar_top_visible = true; + state.toolbar_side_visible = true; + state.set_tool_override(Some(Tool::Arrow)); + + state.toggle_presenter_mode(); + assert!(!state.show_status_bar); + assert!(!state.toolbar_visible); + assert_eq!(state.tool_override(), Some(Tool::Highlight)); + + state.toggle_presenter_mode(); + assert!(!state.presenter_mode); + assert!(state.show_status_bar); + assert!(state.toolbar_visible); + assert!(state.toolbar_top_visible); + assert!(state.toolbar_side_visible); + assert_eq!(state.tool_override(), Some(Tool::Arrow)); +} + +#[test] +fn presenter_mode_emits_entry_and_exit_toasts() { + let mut state = create_test_input_state(); + + state.toggle_presenter_mode(); + let entry_toast = state.ui_toast.as_ref().expect("entry toast"); + assert_eq!(entry_toast.message, "Presenter Mode active"); + assert_eq!( + entry_toast.action.as_ref().map(|action| action.action), + Some(crate::config::Action::TogglePresenterMode) + ); + + state.toggle_presenter_mode(); + let exit_toast = state.ui_toast.as_ref().expect("exit toast"); + assert_eq!(exit_toast.message, "Stopping Presenter Mode"); + assert!(exit_toast.action.is_none()); +} diff --git a/src/input/state/tests/properties_panel.rs b/src/input/state/tests/properties_panel.rs new file mode 100644 index 00000000..324211ce --- /dev/null +++ b/src/input/state/tests/properties_panel.rs @@ -0,0 +1,203 @@ +use super::*; + +fn add_rect(state: &mut InputState, x: i32, y: i32, w: i32, h: i32) -> crate::draw::ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Rect { + x, + y, + w, + h, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }) +} + +fn entry_index(state: &InputState, label: &str) -> usize { + state + .properties_panel() + .expect("properties panel") + .entries + .iter() + .position(|entry| entry.label == label) + .expect(label) +} + +#[test] +fn show_properties_panel_for_single_shape_reports_type_layer_and_lock_state() { + let mut state = create_test_input_state(); + let shape_id = add_rect(&mut state, 10, 20, 30, 40); + state.set_selection(vec![shape_id]); + + assert!(state.show_properties_panel()); + + let panel = state.properties_panel().expect("properties panel"); + assert_eq!(panel.title, "Shape Properties"); + assert!(!panel.multiple_selection); + assert!(panel.lines.iter().any(|line| line == &format!("Shape ID: {shape_id}"))); + assert!(panel.lines.iter().any(|line| line == "Type: Rectangle")); + assert!(panel.lines.iter().any(|line| line == "Layer: 1 of 1")); + assert!(panel.lines.iter().any(|line| line == "Locked: No")); + assert!(panel.lines.iter().any(|line| line.starts_with("Bounds: "))); +} + +#[test] +fn show_properties_panel_for_multi_selection_includes_locked_count_and_summary() { + let mut state = create_test_input_state(); + let first = add_rect(&mut state, 10, 10, 20, 20); + let second = add_rect(&mut state, 50, 15, 10, 15); + let second_index = state.boards.active_frame().find_index(second).expect("second index"); + state.boards.active_frame_mut().shapes[second_index].locked = true; + state.set_selection(vec![first, second]); + + assert!(state.show_properties_panel()); + + let panel = state.properties_panel().expect("properties panel"); + assert_eq!(panel.title, "Selection Properties"); + assert!(panel.multiple_selection); + assert!(panel.lines.iter().any(|line| line == "Shapes selected: 2")); + assert!(panel.lines.iter().any(|line| line == "Locked: 1/2")); + assert!(panel.lines.iter().any(|line| line.starts_with("Bounds: "))); +} + +#[test] +fn close_properties_panel_clears_panel_and_requests_redraw() { + let mut state = create_test_input_state(); + let shape_id = add_rect(&mut state, 5, 5, 10, 10); + state.set_selection(vec![shape_id]); + assert!(state.show_properties_panel()); + state.needs_redraw = false; + + state.close_properties_panel(); + + assert!(state.properties_panel().is_none()); + assert!(state.properties_panel_layout().is_none()); + assert!(state.needs_redraw); +} + +#[test] +fn activate_fill_entry_toggles_rectangle_fill_and_refreshes_panel_value() { + let mut state = create_test_input_state(); + let shape_id = add_rect(&mut state, 5, 5, 20, 20); + state.set_selection(vec![shape_id]); + assert!(state.show_properties_panel()); + let fill_index = entry_index(&state, "Fill"); + state.set_properties_panel_focus(Some(fill_index)); + + assert!(state.activate_properties_panel_entry()); + + match &state.boards.active_frame().shape(shape_id).expect("shape").shape { + Shape::Rect { fill, .. } => assert!(*fill), + other => panic!("expected rect, got {other:?}"), + } + assert_eq!( + state.properties_panel().expect("panel").entries[fill_index].value, + "On" + ); +} + +#[test] +fn adjust_font_size_entry_increases_text_size_and_refreshes_panel_value() { + let mut state = create_test_input_state(); + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 10, + y: 20, + text: "Note".to_string(), + color: state.current_color, + size: 18.0, + font_descriptor: state.font_descriptor.clone(), + background_enabled: false, + wrap_width: None, + }); + state.set_selection(vec![shape_id]); + assert!(state.show_properties_panel()); + let font_index = entry_index(&state, "Font size"); + state.set_properties_panel_focus(Some(font_index)); + + assert!(state.adjust_properties_panel_entry(1)); + + match &state.boards.active_frame().shape(shape_id).expect("shape").shape { + Shape::Text { size, .. } => assert_eq!(*size, 20.0), + other => panic!("expected text, got {other:?}"), + } + assert_eq!( + state.properties_panel().expect("panel").entries[font_index].value, + "20pt" + ); +} + +#[test] +fn activate_text_background_entry_on_mixed_selection_turns_all_backgrounds_on() { + let mut state = create_test_input_state(); + let first = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 10, + y: 20, + text: "One".to_string(), + color: state.current_color, + size: 18.0, + font_descriptor: state.font_descriptor.clone(), + background_enabled: false, + wrap_width: None, + }); + let second = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 40, + y: 50, + text: "Two".to_string(), + color: state.current_color, + size: 18.0, + font_descriptor: state.font_descriptor.clone(), + background_enabled: true, + wrap_width: None, + }); + state.set_selection(vec![first, second]); + assert!(state.show_properties_panel()); + let bg_index = entry_index(&state, "Text background"); + state.set_properties_panel_focus(Some(bg_index)); + + assert!(state.activate_properties_panel_entry()); + + for id in [first, second] { + match &state.boards.active_frame().shape(id).expect("text shape").shape { + Shape::Text { + background_enabled, .. + } => assert!(*background_enabled), + other => panic!("expected text, got {other:?}"), + } + } + assert_eq!( + state.properties_panel().expect("panel").entries[bg_index].value, + "On" + ); +} + +#[test] +fn adjust_arrow_length_entry_clamps_to_max_and_refreshes_panel_value() { + let mut state = create_test_input_state(); + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 0, + y1: 0, + x2: 20, + y2: 10, + color: state.current_color, + thick: 3.0, + arrow_length: 49.0, + arrow_angle: 30.0, + head_at_end: true, + label: None, + }); + state.set_selection(vec![shape_id]); + assert!(state.show_properties_panel()); + let length_index = entry_index(&state, "Arrow length"); + state.set_properties_panel_focus(Some(length_index)); + + assert!(state.adjust_properties_panel_entry(1)); + assert!(!state.adjust_properties_panel_entry(1)); + + match &state.boards.active_frame().shape(shape_id).expect("arrow").shape { + Shape::Arrow { arrow_length, .. } => assert_eq!(*arrow_length, 50.0), + other => panic!("expected arrow, got {other:?}"), + } + assert_eq!( + state.properties_panel().expect("panel").entries[length_index].value, + "50px" + ); +} diff --git a/src/input/state/tests/selection/duplicate.rs b/src/input/state/tests/selection/duplicate.rs index 87979f15..818025d2 100644 --- a/src/input/state/tests/selection/duplicate.rs +++ b/src/input/state/tests/selection/duplicate.rs @@ -111,3 +111,111 @@ fn duplicate_selection_skips_locked_shapes() { "locked shape should remain locked" ); } + +#[test] +fn copy_selection_of_only_locked_shapes_leaves_clipboard_empty() { + let mut state = create_test_input_state(); + let locked_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 5, + y: 5, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + let locked_index = state.boards.active_frame().find_index(locked_id).expect("locked index"); + state.boards.active_frame_mut().shapes[locked_index].locked = true; + state.set_selection(vec![locked_id]); + + assert_eq!(state.copy_selection(), 0); + assert!(state.selection_clipboard_is_empty()); +} + +#[test] +fn repeated_paste_selection_uses_increasing_offsets() { + let mut state = create_test_input_state(); + let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 10, + y: 20, + w: 30, + h: 40, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + state.set_selection(vec![original_id]); + assert_eq!(state.copy_selection(), 1); + + assert_eq!(state.paste_selection(), 1); + assert_eq!(state.paste_selection(), 1); + + let frame = state.boards.active_frame(); + assert_eq!(frame.shapes.len(), 3); + let coords = frame + .shapes + .iter() + .map(|shape| match &shape.shape { + Shape::Rect { x, y, .. } => (*x, *y), + _ => panic!("expected rectangles"), + }) + .collect::>(); + assert_eq!(coords, vec![(10, 20), (22, 32), (34, 44)]); +} + +#[test] +fn paste_selection_warns_when_shape_limit_prevents_any_paste() { + let mut state = create_test_input_state(); + let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 10, + y: 20, + w: 30, + h: 40, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + state.set_selection(vec![original_id]); + assert_eq!(state.copy_selection(), 1); + state.max_shapes_per_frame = 1; + + assert_eq!(state.paste_selection(), 0); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Shape limit reached; nothing pasted.") + ); +} + +#[test] +fn paste_selection_warns_when_shape_limit_allows_only_partial_paste() { + let mut state = create_test_input_state(); + let first = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + let second = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 20, + y: 20, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + state.set_selection(vec![first, second]); + assert_eq!(state.copy_selection(), 2); + state.max_shapes_per_frame = 3; + + assert_eq!(state.paste_selection(), 1); + assert_eq!(state.boards.active_frame().shapes.len(), 3); + assert_eq!(state.selected_shape_ids().len(), 1); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Shape limit reached; pasted 1 of 2.") + ); +} diff --git a/src/input/state/tests/step_markers.rs b/src/input/state/tests/step_markers.rs index cfc55100..61259189 100644 --- a/src/input/state/tests/step_markers.rs +++ b/src/input/state/tests/step_markers.rs @@ -98,3 +98,24 @@ fn drawing_step_marker_increments_counter() { assert_eq!(second_value, 2); assert_eq!(state.step_marker_counter, 3); } + +#[test] +fn reset_step_marker_counter_reports_no_change_at_default() { + let mut state = create_test_input_state(); + state.needs_redraw = false; + + assert!(!state.reset_step_marker_counter()); + assert_eq!(state.step_marker_counter, 1); + assert!(!state.needs_redraw); +} + +#[test] +fn next_step_marker_label_uses_current_counter_value() { + let mut state = create_test_input_state(); + state.step_marker_counter = 9; + + let label = state.next_step_marker_label(); + + assert_eq!(label.value, 9); + assert_eq!(label.font_descriptor.family, state.font_descriptor.family); +} diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs new file mode 100644 index 00000000..33a7b472 --- /dev/null +++ b/src/input/state/tests/tool_controls.rs @@ -0,0 +1,133 @@ +use super::*; +use crate::config::PresenterToolBehavior; + +#[test] +fn set_tool_override_clears_active_preset_and_resets_drawing_state() { + let mut state = create_test_input_state(); + state.active_preset_slot = Some(2); + state.needs_redraw = false; + state.session_dirty = false; + state.state = DrawingState::Drawing { + tool: Tool::Pen, + start_x: 10, + start_y: 20, + points: vec![(10, 20), (12, 24)], + point_thicknesses: vec![3.0, 3.5], + }; + + assert!(state.set_tool_override(Some(Tool::Arrow))); + assert_eq!(state.tool_override(), Some(Tool::Arrow)); + assert!(matches!(state.state, DrawingState::Idle)); + assert_eq!(state.active_preset_slot, None); + assert!(state.needs_redraw); + assert!(state.session_dirty); +} + +#[test] +fn set_tool_override_preserves_text_input_state() { + let mut state = create_test_input_state(); + state.state = DrawingState::TextInput { + x: 4, + y: 5, + buffer: "hello".to_string(), + }; + + assert!(state.set_tool_override(Some(Tool::Rect))); + assert_eq!(state.tool_override(), Some(Tool::Rect)); + assert!(matches!( + &state.state, + DrawingState::TextInput { x: 4, y: 5, buffer } if buffer == "hello" + )); +} + +#[test] +fn presenter_locked_mode_rejects_non_highlight_tool_override() { + let mut state = create_test_input_state(); + assert!(state.set_tool_override(Some(Tool::Highlight))); + state.presenter_mode = true; + state.presenter_mode_config.tool_behavior = PresenterToolBehavior::ForceHighlightLocked; + state.needs_redraw = false; + state.session_dirty = false; + + assert!(!state.set_tool_override(Some(Tool::Pen))); + assert_eq!(state.tool_override(), Some(Tool::Highlight)); + assert!(!state.needs_redraw); + assert!(!state.session_dirty); +} + +#[test] +fn set_thickness_for_active_tool_updates_eraser_size_when_eraser_is_active() { + let mut state = create_test_input_state(); + state.set_tool_override(Some(Tool::Eraser)); + + assert!(state.set_thickness_for_active_tool(17.0)); + assert_eq!(state.eraser_size, 17.0); + assert_eq!(state.current_thickness, 3.0); +} + +#[test] +fn nudge_thickness_for_active_tool_clamps_pen_thickness() { + let mut state = create_test_input_state(); + state.current_thickness = 49.0; + + assert!(state.nudge_thickness_for_active_tool(10.0)); + assert_eq!(state.current_thickness, 50.0); +} + +#[test] +fn nudge_thickness_for_active_tool_clamps_eraser_size() { + let mut state = create_test_input_state(); + state.set_tool_override(Some(Tool::Eraser)); + state.eraser_size = 2.0; + + assert!(state.nudge_thickness_for_active_tool(-10.0)); + assert_eq!(state.eraser_size, 1.0); +} + +#[test] +fn toggle_eraser_mode_round_trips_between_brush_and_stroke() { + let mut state = create_test_input_state(); + assert_eq!(state.eraser_mode, EraserMode::Brush); + + assert!(state.toggle_eraser_mode()); + assert_eq!(state.eraser_mode, EraserMode::Stroke); + + assert!(state.toggle_eraser_mode()); + assert_eq!(state.eraser_mode, EraserMode::Brush); +} + +#[test] +fn set_font_size_clamps_and_reports_noop_after_reaching_target() { + let mut state = create_test_input_state(); + state.needs_redraw = false; + state.session_dirty = false; + + assert!(state.set_font_size(120.0)); + assert_eq!(state.current_font_size, 72.0); + assert!(state.needs_redraw); + assert!(state.session_dirty); + + state.needs_redraw = false; + state.session_dirty = false; + assert!(!state.set_font_size(72.0)); + assert!(!state.needs_redraw); + assert!(!state.session_dirty); +} + +#[test] +fn set_marker_opacity_clamps_and_reports_noop_after_reaching_target() { + let mut state = create_test_input_state(); + state.needs_redraw = false; + state.session_dirty = false; + + assert!(state.set_marker_opacity(2.0)); + assert_eq!(state.marker_opacity, 0.9); + assert!(state.needs_redraw); + assert!(state.session_dirty); + + state.needs_redraw = false; + state.session_dirty = false; + assert!(!state.set_marker_opacity(0.9)); + assert!(!state.needs_redraw); + assert!(!state.session_dirty); +} diff --git a/src/input/tablet/mod.rs b/src/input/tablet/mod.rs index 4353c8d6..d84f3abc 100644 --- a/src/input/tablet/mod.rs +++ b/src/input/tablet/mod.rs @@ -52,3 +52,117 @@ pub fn apply_pressure_to_state(pressure01: f64, state: &mut InputState, settings state.current_thickness = new_thickness; state.needs_redraw = true; } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + #[test] + fn apply_pressure_to_state_ignores_disabled_tablet_settings() { + let mut state = make_state(); + state.current_thickness = 3.0; + state.needs_redraw = false; + + apply_pressure_to_state(0.8, &mut state, TabletSettings::default()); + + assert_eq!(state.current_thickness, 3.0); + assert!(!state.needs_redraw); + } + + #[test] + fn apply_pressure_to_state_ignores_disabled_pressure_mapping() { + let mut state = make_state(); + state.needs_redraw = false; + let settings = TabletSettings { + enabled: true, + pressure_enabled: false, + min_thickness: 1.0, + max_thickness: 8.0, + }; + + apply_pressure_to_state(0.8, &mut state, settings); + + assert_eq!(state.current_thickness, 4.0); + assert!(!state.needs_redraw); + } + + #[test] + fn apply_pressure_to_state_clamps_pressure_to_configured_range() { + let mut state = make_state(); + state.needs_redraw = false; + let settings = TabletSettings { + enabled: true, + pressure_enabled: true, + min_thickness: 2.0, + max_thickness: 6.0, + }; + + apply_pressure_to_state(-1.0, &mut state, settings); + assert_eq!(state.current_thickness, 2.0); + assert!(state.needs_redraw); + + state.needs_redraw = false; + apply_pressure_to_state(2.0, &mut state, settings); + assert_eq!(state.current_thickness, 6.0); + assert!(state.needs_redraw); + } + + #[test] + fn apply_pressure_to_state_clamps_to_global_max_thickness() { + let mut state = make_state(); + let settings = TabletSettings { + enabled: true, + pressure_enabled: true, + min_thickness: 1.0, + max_thickness: MAX_STROKE_THICKNESS + 50.0, + }; + + apply_pressure_to_state(1.0, &mut state, settings); + + assert_eq!(state.current_thickness, MAX_STROKE_THICKNESS); + assert!(state.needs_redraw); + } +} diff --git a/src/label_format.rs b/src/label_format.rs index 2d557d98..19ce4a60 100644 --- a/src/label_format.rs +++ b/src/label_format.rs @@ -25,3 +25,35 @@ pub(crate) fn format_binding_label(label: &str, binding: Option<&str>) -> String label.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn join_binding_labels_returns_none_for_empty_input() { + assert_eq!(join_binding_labels(&[]), None); + } + + #[test] + fn join_binding_labels_uses_shared_separator() { + let labels = vec!["Ctrl+K".to_string(), "F1".to_string()]; + assert_eq!(join_binding_labels(&labels), Some("Ctrl+K / F1".to_string())); + } + + #[test] + fn format_binding_labels_uses_not_bound_fallback() { + assert_eq!(format_binding_labels(&[]), NOT_BOUND_LABEL); + } + + #[test] + fn format_binding_labels_or_uses_custom_fallback() { + assert_eq!(format_binding_labels_or(&[], "fallback"), "fallback"); + } + + #[test] + fn format_binding_label_includes_optional_binding_text() { + assert_eq!(format_binding_label("Undo", Some("Ctrl+Z")), "Undo (Ctrl+Z)"); + assert_eq!(format_binding_label("Undo", None), "Undo"); + } +} diff --git a/src/ui/board_picker/page_panel/thumbnail/icons.rs b/src/ui/board_picker/page_panel/thumbnail/icons.rs index 464a71d8..691d315c 100644 --- a/src/ui/board_picker/page_panel/thumbnail/icons.rs +++ b/src/ui/board_picker/page_panel/thumbnail/icons.rs @@ -103,3 +103,24 @@ pub(super) fn draw_rename_icon(ctx: &cairo::Context, x: f64, y: f64, size: f64, ctx.line_to(tip_x, tip_y); let _ = ctx.stroke(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn icon_alpha_prefers_icon_hover_state() { + assert_eq!(icon_alpha(true, true), 1.0); + assert_eq!(icon_alpha(false, true), 1.0); + } + + #[test] + fn icon_alpha_uses_row_hover_alpha_when_only_row_is_hovered() { + assert_eq!(icon_alpha(true, false), 0.7); + } + + #[test] + fn icon_alpha_uses_idle_alpha_without_any_hover() { + assert_eq!(icon_alpha(false, false), 0.2); + } +} diff --git a/src/ui/constants.rs b/src/ui/constants.rs index 2e29f579..bff37a86 100644 --- a/src/ui/constants.rs +++ b/src/ui/constants.rs @@ -329,3 +329,38 @@ pub fn lerp_color( from.3 + (to.3 - from.3) * t, ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn with_alpha_replaces_only_alpha_component() { + assert_eq!(with_alpha((0.1, 0.2, 0.3, 0.4), 0.9), (0.1, 0.2, 0.3, 0.9)); + } + + #[test] + fn lerp_color_clamps_progress_before_zero() { + assert_eq!( + lerp_color((0.0, 0.2, 0.4, 0.6), (1.0, 0.8, 0.6, 0.4), -1.0), + (0.0, 0.2, 0.4, 0.6) + ); + } + + #[test] + fn lerp_color_interpolates_midpoint() { + let color = lerp_color((0.0, 0.0, 0.0, 0.0), (1.0, 0.5, 0.25, 1.0), 0.5); + assert!((color.0 - 0.5).abs() < 1e-9); + assert!((color.1 - 0.25).abs() < 1e-9); + assert!((color.2 - 0.125).abs() < 1e-9); + assert!((color.3 - 0.5).abs() < 1e-9); + } + + #[test] + fn lerp_color_clamps_progress_after_one() { + assert_eq!( + lerp_color((0.0, 0.2, 0.4, 0.6), (1.0, 0.8, 0.6, 0.4), 2.0), + (1.0, 0.8, 0.6, 0.4) + ); + } +} diff --git a/src/ui/help_overlay/sections/bindings.rs b/src/ui/help_overlay/sections/bindings.rs index 659c3d11..fa1c1089 100644 --- a/src/ui/help_overlay/sections/bindings.rs +++ b/src/ui/help_overlay/sections/bindings.rs @@ -181,3 +181,85 @@ fn split_numeric_suffix(label: &str) -> Option<(String, u32)> { let number = digits.parse::().ok()?; Some((prefix.to_string(), number)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Action; + + fn bindings_with(entries: &[(Action, &[&str])]) -> HelpOverlayBindings { + let labels = entries + .iter() + .map(|(action, values)| { + ( + *action, + values.iter().map(|value| (*value).to_string()).collect(), + ) + }) + .collect(); + HelpOverlayBindings { + labels, + cache_key: String::new(), + radial_menu_mouse_label: Some("Middle Click".to_string()), + } + } + + #[test] + fn collect_labels_dedupes_across_actions_in_first_seen_order() { + let bindings = bindings_with(&[ + (Action::ToggleHelp, &["F1", "F10"]), + (Action::ToggleToolbar, &["F10", "F2"]), + ]); + + assert_eq!( + collect_labels(&bindings, &[Action::ToggleHelp, Action::ToggleToolbar]), + vec!["F1", "F10", "F2"] + ); + } + + #[test] + fn bindings_compact_or_fallback_compacts_contiguous_numeric_ranges() { + let bindings = bindings_with(&[ + (Action::Board1, &["Ctrl+Shift+1"]), + (Action::Board2, &["Ctrl+Shift+2"]), + (Action::Board3, &["Ctrl+Shift+3"]), + ]); + + assert_eq!( + bindings_compact_or_fallback( + &bindings, + &[Action::Board1, Action::Board2, Action::Board3], + "fallback", + ), + "Ctrl+Shift+1..3" + ); + } + + #[test] + fn bindings_compact_or_fallback_keeps_non_contiguous_ranges_expanded() { + let bindings = bindings_with(&[ + (Action::Board1, &["Ctrl+Shift+1"]), + (Action::Board3, &["Ctrl+Shift+3"]), + ]); + + assert_eq!( + bindings_compact_or_fallback(&bindings, &[Action::Board1, Action::Board3], "fallback"), + "Ctrl+Shift+1 / Ctrl+Shift+3" + ); + } + + #[test] + fn primary_or_fallback_returns_fallback_for_unbound_action() { + let bindings = HelpOverlayBindings::default(); + + assert_eq!( + primary_or_fallback(&bindings, Action::ToggleCommandPalette, "Ctrl+K"), + "Ctrl+K" + ); + } + + #[test] + fn split_numeric_suffix_rejects_labels_without_numeric_suffix() { + assert_eq!(split_numeric_suffix("Ctrl+K"), None); + } +} diff --git a/src/ui/toolbar/apply/delays.rs b/src/ui/toolbar/apply/delays.rs index ea69fd2a..a02956df 100644 --- a/src/ui/toolbar/apply/delays.rs +++ b/src/ui/toolbar/apply/delays.rs @@ -48,3 +48,96 @@ impl InputState { fn clamp_delay_ms(delay_secs: f64) -> u64 { (delay_secs.clamp(MIN_DELAY_S, MAX_DELAY_S) * 1000.0).round() as u64 } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + + InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ) + } + + #[test] + fn clamp_delay_ms_clamps_to_minimum_and_maximum_bounds() { + assert_eq!(clamp_delay_ms(0.0), 50); + assert_eq!(clamp_delay_ms(99.0), 5000); + } + + #[test] + fn apply_toolbar_set_delay_methods_store_clamped_milliseconds() { + let mut state = make_state(); + + assert!(state.apply_toolbar_set_undo_delay(0.01)); + assert!(state.apply_toolbar_set_redo_delay(2.345)); + assert!(state.apply_toolbar_set_custom_undo_delay(8.0)); + assert!(state.apply_toolbar_set_custom_redo_delay(0.333)); + + assert_eq!(state.undo_all_delay_ms, 50); + assert_eq!(state.redo_all_delay_ms, 2345); + assert_eq!(state.custom_undo_delay_ms, 5000); + assert_eq!(state.custom_redo_delay_ms, 333); + } + + #[test] + fn custom_undo_steps_clamp_and_report_when_value_changes() { + let mut state = make_state(); + state.custom_undo_steps = 5; + + assert!(state.apply_toolbar_set_custom_undo_steps(0)); + assert_eq!(state.custom_undo_steps, 1); + assert!(state.apply_toolbar_set_custom_undo_steps(999)); + assert_eq!(state.custom_undo_steps, 500); + assert!(!state.apply_toolbar_set_custom_undo_steps(500)); + } + + #[test] + fn custom_redo_steps_clamp_and_report_when_value_changes() { + let mut state = make_state(); + state.custom_redo_steps = 5; + + assert!(state.apply_toolbar_set_custom_redo_steps(0)); + assert_eq!(state.custom_redo_steps, 1); + assert!(state.apply_toolbar_set_custom_redo_steps(999)); + assert_eq!(state.custom_redo_steps, 500); + assert!(!state.apply_toolbar_set_custom_redo_steps(500)); + } +} diff --git a/src/ui/toolbar/bindings.rs b/src/ui/toolbar/bindings.rs index e33a8c27..732d7145 100644 --- a/src/ui/toolbar/bindings.rs +++ b/src/ui/toolbar/bindings.rs @@ -147,3 +147,145 @@ pub(crate) fn action_for_clear_preset(slot: usize) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode}; + + fn make_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_map = keybindings + .build_action_map() + .expect("default keybindings map"); + let action_bindings = keybindings + .build_action_bindings() + .expect("default keybindings bindings"); + + let mut state = InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ); + state.set_action_bindings(action_bindings); + state + } + + #[test] + fn action_for_tool_maps_selection_and_eraser_tools() { + assert_eq!(action_for_tool(Tool::Select), Some(Action::SelectSelectionTool)); + assert_eq!(action_for_tool(Tool::Eraser), Some(Action::SelectEraserTool)); + } + + #[test] + fn action_for_event_maps_board_picker_related_events() { + assert_eq!(action_for_event(&ToolbarEvent::BoardRename), Some(Action::BoardPicker)); + assert_eq!( + action_for_event(&ToolbarEvent::ToggleBoardPicker), + Some(Action::BoardPicker) + ); + } + + #[test] + fn action_for_event_returns_none_for_layout_only_events() { + assert_eq!(action_for_event(&ToolbarEvent::OpenConfigFile), None); + assert_eq!(action_for_event(&ToolbarEvent::ToggleShapePicker(true)), None); + } + + #[test] + fn preset_action_helpers_cover_valid_slots_only() { + assert_eq!(action_for_apply_preset(1), Some(Action::ApplyPreset1)); + assert_eq!(action_for_save_preset(5), Some(Action::SavePreset5)); + assert_eq!(action_for_clear_preset(3), Some(Action::ClearPreset3)); + assert_eq!(action_for_apply_preset(0), None); + assert_eq!(action_for_save_preset(6), None); + assert_eq!(action_for_clear_preset(99), None); + } + + #[test] + fn toolbar_binding_hints_collect_only_toolbar_actions() { + let state = make_state(); + let hints = ToolbarBindingHints::from_input_state(&state); + + assert_eq!(hints.binding_for_action(Action::OpenConfigurator), Some("F11")); + assert_eq!(hints.binding_for_action(Action::Exit), None); + } + + #[test] + fn toolbar_binding_hints_follow_event_mapping() { + let state = make_state(); + let hints = ToolbarBindingHints::from_input_state(&state); + + assert_eq!( + hints.binding_for_event(&ToolbarEvent::OpenConfigurator), + Some("F11") + ); + assert_eq!(hints.binding_for_event(&ToolbarEvent::OpenConfigFile), None); + } + + #[test] + fn toolbar_binding_hints_resolve_tool_bindings() { + let state = make_state(); + let hints = ToolbarBindingHints::from_input_state(&state); + + assert_eq!(hints.for_tool(Tool::Pen), Some("F")); + assert_eq!(hints.for_tool(Tool::Eraser), Some("D")); + assert_eq!(hints.for_tool(Tool::StepMarker), None); + } + + #[test] + fn toolbar_binding_hints_resolve_preset_bindings() { + let state = make_state(); + let hints = ToolbarBindingHints::from_input_state(&state); + + assert_eq!(hints.apply_preset(1), Some("1")); + assert_eq!(hints.save_preset(1), Some("Shift+1")); + assert_eq!(hints.clear_preset(1), Some("Ctrl+1")); + assert_eq!(hints.apply_preset(6), None); + } + + #[test] + fn tool_label_and_tooltip_label_use_action_metadata() { + assert_eq!(tool_label(Tool::Ellipse), "Circle"); + assert_eq!(tool_tooltip_label(Tool::Ellipse), "Ellipse Tool"); + assert_eq!(tool_label(Tool::Select), "Select"); + } + + #[test] + fn action_for_event_maps_select_tool_and_freeze_events() { + assert_eq!( + action_for_event(&ToolbarEvent::SelectTool(Tool::Pen)), + Some(Action::SelectPenTool) + ); + assert_eq!(action_for_event(&ToolbarEvent::ToggleFreeze), Some(Action::ToggleFrozenMode)); + } +} diff --git a/src/util/colors.rs b/src/util/colors.rs index 17eaae89..151e7b1a 100644 --- a/src/util/colors.rs +++ b/src/util/colors.rs @@ -92,3 +92,47 @@ pub fn color_to_name(color: &Color) -> &'static str { "Custom" } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_to_color_is_case_insensitive() { + assert_eq!(key_to_color('r'), Some(RED)); + assert_eq!(key_to_color('K'), Some(BLACK)); + } + + #[test] + fn key_to_color_rejects_unknown_keys() { + assert_eq!(key_to_color('x'), None); + } + + #[test] + fn name_to_color_is_case_insensitive() { + assert_eq!(name_to_color("Orange"), Some(ORANGE)); + assert_eq!(name_to_color("WHITE"), Some(WHITE)); + } + + #[test] + fn color_to_name_matches_approximate_orange_band() { + let color = Color { + r: 0.95, + g: 0.5, + b: 0.05, + a: 1.0, + }; + assert_eq!(color_to_name(&color), "Orange"); + } + + #[test] + fn color_to_name_returns_custom_outside_thresholds() { + let color = Color { + r: 0.85, + g: 0.5, + b: 0.05, + a: 1.0, + }; + assert_eq!(color_to_name(&color), "Custom"); + } +} diff --git a/src/util/geometry.rs b/src/util/geometry.rs index 2a2d3c73..15e47a1c 100644 --- a/src/util/geometry.rs +++ b/src/util/geometry.rs @@ -89,3 +89,48 @@ pub fn ellipse_bounds(x1: i32, y1: i32, x2: i32, y2: i32) -> (i32, i32, i32, i32 let ry = ((y2 - y1).abs()) / 2; (cx, cy, rx, ry) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rect_new_rejects_non_positive_dimensions() { + assert_eq!(Rect::new(0, 0, 0, 4), None); + assert_eq!(Rect::new(0, 0, 4, -1), None); + } + + #[test] + fn rect_contains_uses_inclusive_min_and_exclusive_max_edges() { + let rect = Rect::new(10, 20, 5, 4).unwrap(); + + assert!(rect.contains(10, 20)); + assert!(rect.contains(14, 23)); + assert!(!rect.contains(15, 23)); + assert!(!rect.contains(14, 24)); + } + + #[test] + fn rect_inflated_expands_evenly_in_all_directions() { + let rect = Rect::new(10, 20, 5, 4).unwrap(); + + assert_eq!(rect.inflated(2), Rect::new(8, 18, 9, 8)); + } + + #[test] + fn rect_inflated_returns_none_when_negative_amount_eliminates_area() { + let rect = Rect::new(10, 20, 5, 4).unwrap(); + + assert_eq!(rect.inflated(-3), None); + } + + #[test] + fn ellipse_bounds_are_order_independent() { + assert_eq!(ellipse_bounds(0, 0, 10, 20), ellipse_bounds(10, 20, 0, 0)); + } + + #[test] + fn ellipse_bounds_compute_center_and_radii_from_drag_corners() { + assert_eq!(ellipse_bounds(4, 6, 14, 18), (9, 12, 5, 6)); + } +} diff --git a/src/util/tests.rs b/src/util/tests.rs index 24d56e3c..f75a3b5d 100644 --- a/src/util/tests.rs +++ b/src/util/tests.rs @@ -62,3 +62,33 @@ fn rect_inflated_returns_none_when_degenerate() { let rect = Rect::new(0, 0, 2, 2).unwrap(); assert!(rect.inflated(-2).is_none()); } + +#[test] +fn arrowhead_triangle_respects_minimum_length_for_thick_strokes() { + let geometry = calculate_arrowhead_triangle_custom(100, 0, 0, 0, 10.0, 1.0, 30.0) + .expect("non-degenerate line should yield geometry"); + let distance = ((geometry.tip.0 - geometry.base.0).powi(2) + + (geometry.tip.1 - geometry.base.1).powi(2)) + .sqrt(); + assert!((distance - 25.0).abs() < 1e-9); +} + +#[test] +fn arrowhead_triangle_uses_thickness_floor_for_half_base() { + let geometry = calculate_arrowhead_triangle_custom(100, 0, 0, 0, 10.0, 5.0, 1.0) + .expect("non-degenerate line should yield geometry"); + let half_base = (geometry.left.1 - geometry.right.1).abs() / 2.0; + assert!((half_base - 6.0).abs() < 1e-9); +} + +#[test] +fn arrowhead_triangle_is_symmetric_around_base_point() { + let geometry = calculate_arrowhead_triangle_custom(50, 50, 0, 0, 3.0, 20.0, 30.0) + .expect("non-degenerate line should yield geometry"); + let midpoint = ( + (geometry.left.0 + geometry.right.0) / 2.0, + (geometry.left.1 + geometry.right.1) / 2.0, + ); + assert!((midpoint.0 - geometry.base.0).abs() < 1e-9); + assert!((midpoint.1 - geometry.base.1).abs() < 1e-9); +} diff --git a/src/util/text.rs b/src/util/text.rs index e4f2a53b..5d086b6c 100644 --- a/src/util/text.rs +++ b/src/util/text.rs @@ -43,4 +43,14 @@ mod tests { fn test_truncate_one() { assert_eq!(truncate_with_ellipsis("hello", 1), "…"); } + + #[test] + fn test_truncate_counts_unicode_scalar_values() { + assert_eq!(truncate_with_ellipsis("éééé", 3), "éé…"); + } + + #[test] + fn test_truncate_preserves_exact_unicode_length() { + assert_eq!(truncate_with_ellipsis("🙂🙂", 2), "🙂🙂"); + } } From f56fbaf8b211edc7f3514d0f1f4fbc587e158010 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:00:21 +0100 Subject: [PATCH 2/3] test: expand coverage for input state, keybindings, and UI helpers --- src/config/keybindings/tests.rs | 74 ++++++------- src/input/state/core/base/types.rs | 11 -- src/input/state/core/menus/shortcuts.rs | 95 +++++----------- src/input/state/core/utility/interaction.rs | 60 +--------- src/input/state/mod.rs | 64 +++++++++++ src/ui/toolbar/bindings.rs | 117 ++++++++++---------- 6 files changed, 186 insertions(+), 235 deletions(-) diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 7db8d630..41378b9a 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -121,53 +121,33 @@ fn test_parse_modifier_order_independence() { #[test] fn test_build_action_map() { - let config = KeybindingsConfig::default(); + let mut config = KeybindingsConfig::default(); + config.core.exit = vec!["Ctrl+Alt+Shift+1".to_string()]; + config.core.undo = vec!["Ctrl+Alt+Shift+2".to_string()]; + config.core.redo = vec!["Ctrl+Alt+Shift+3".to_string()]; + config.ui.toggle_help = vec!["Ctrl+Alt+Shift+4".to_string()]; + config.board.toggle_whiteboard = vec!["Ctrl+Alt+Shift+5".to_string()]; let map = config.build_action_map().unwrap(); - // Check that some default bindings are present - let escape = KeyBinding::parse("Escape").unwrap(); - assert_eq!(map.get(&escape), Some(&Action::Exit)); - - let ctrl_z = KeyBinding::parse("Ctrl+Z").unwrap(); - assert_eq!(map.get(&ctrl_z), Some(&Action::Undo)); - - let ctrl_shift_z = KeyBinding::parse("Ctrl+Shift+Z").unwrap(); - assert_eq!(map.get(&ctrl_shift_z), Some(&Action::Redo)); - - let move_front = KeyBinding::parse("]").unwrap(); - assert_eq!(map.get(&move_front), Some(&Action::MoveSelectionToFront)); - - let move_back = KeyBinding::parse("[").unwrap(); - assert_eq!(map.get(&move_back), Some(&Action::MoveSelectionToBack)); - - let copy_selection = KeyBinding::parse("Ctrl+Alt+C").unwrap(); - assert_eq!(map.get(©_selection), Some(&Action::CopySelection)); - - let capture_selection = KeyBinding::parse("Ctrl+Shift+C").unwrap(); assert_eq!( - map.get(&capture_selection), - Some(&Action::CaptureClipboardSelection) + map.get(&KeyBinding::parse("Ctrl+Alt+Shift+1").unwrap()), + Some(&Action::Exit) ); - - let select_all = KeyBinding::parse("Ctrl+A").unwrap(); - assert_eq!(map.get(&select_all), Some(&Action::SelectAll)); - - let toggle_highlight = KeyBinding::parse("Ctrl+Shift+H").unwrap(); assert_eq!( - map.get(&toggle_highlight), - Some(&Action::ToggleClickHighlight) + map.get(&KeyBinding::parse("Ctrl+Alt+Shift+2").unwrap()), + Some(&Action::Undo) ); - - let toggle_highlight_tool = KeyBinding::parse("Ctrl+Alt+H").unwrap(); assert_eq!( - map.get(&toggle_highlight_tool), - Some(&Action::ToggleHighlightTool) + map.get(&KeyBinding::parse("Ctrl+Alt+Shift+3").unwrap()), + Some(&Action::Redo) ); - - let reset_arrow_labels = KeyBinding::parse("Ctrl+Shift+R").unwrap(); assert_eq!( - map.get(&reset_arrow_labels), - Some(&Action::ResetArrowLabelCounter) + map.get(&KeyBinding::parse("Ctrl+Alt+Shift+4").unwrap()), + Some(&Action::ToggleHelp) + ); + assert_eq!( + map.get(&KeyBinding::parse("Ctrl+Alt+Shift+5").unwrap()), + Some(&Action::ToggleWhiteboard) ); } @@ -228,21 +208,29 @@ fn test_matches_requires_exact_alt_state() { #[test] fn test_build_action_bindings_preserves_declared_binding_order() { - let config = KeybindingsConfig::default(); + let mut config = KeybindingsConfig::default(); + config.ui.toggle_help = vec![ + "Ctrl+Alt+Shift+1".to_string(), + "Ctrl+Alt+Shift+2".to_string(), + ]; + config.core.redo = vec![ + "Ctrl+Alt+Shift+3".to_string(), + "Ctrl+Alt+Shift+4".to_string(), + ]; let bindings = config.build_action_bindings().unwrap(); assert_eq!( bindings.get(&Action::ToggleHelp), Some(&vec![ - KeyBinding::parse("F10").unwrap(), - KeyBinding::parse("F1").unwrap(), + KeyBinding::parse("Ctrl+Alt+Shift+1").unwrap(), + KeyBinding::parse("Ctrl+Alt+Shift+2").unwrap(), ]) ); assert_eq!( bindings.get(&Action::Redo), Some(&vec![ - KeyBinding::parse("Ctrl+Shift+Z").unwrap(), - KeyBinding::parse("Ctrl+Y").unwrap(), + KeyBinding::parse("Ctrl+Alt+Shift+3").unwrap(), + KeyBinding::parse("Ctrl+Alt+Shift+4").unwrap(), ]) ); } diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 0ef3ff83..14c1e692 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -357,17 +357,6 @@ pub(crate) struct PendingOnboardingUsage { mod tests { use super::CompositorCapabilities; - #[test] - fn compositor_capabilities_all_available_requires_every_flag() { - assert!(!CompositorCapabilities::default().all_available()); - assert!(CompositorCapabilities { - layer_shell: true, - screencopy: true, - pointer_constraints: true, - } - .all_available()); - } - #[test] fn compositor_capabilities_limitations_summary_returns_none_when_fully_available() { assert_eq!( diff --git a/src/input/state/core/menus/shortcuts.rs b/src/input/state/core/menus/shortcuts.rs index 29357205..5a1edb28 100644 --- a/src/input/state/core/menus/shortcuts.rs +++ b/src/input/state/core/menus/shortcuts.rs @@ -43,67 +43,38 @@ impl InputState { #[cfg(test)] mod tests { use super::*; - use crate::config::{ - BoardsConfig, KeyBinding, KeybindingsConfig, PresenterModeConfig, RadialMenuMouseBinding, - }; - use crate::draw::{Color, FontDescriptor}; - use crate::input::{ClickHighlightSettings, EraserMode}; + use crate::config::{KeyBinding, RadialMenuMouseBinding}; + use crate::input::state::test_support::make_test_input_state_with_action_bindings; use std::collections::HashMap; - fn make_state() -> InputState { - let keybindings = KeybindingsConfig::default(); - let action_map = keybindings - .build_action_map() - .expect("default keybindings map"); - let action_bindings = keybindings - .build_action_bindings() - .expect("default keybindings bindings"); - - let mut state = InputState::with_defaults( - Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 1.0, - }, - 4.0, - 4.0, - EraserMode::Brush, - 0.32, - false, - 32.0, - FontDescriptor::default(), - false, - 20.0, - 30.0, - false, - true, - BoardsConfig::default(), - action_map, - usize::MAX, - ClickHighlightSettings::disabled(), - 0, - 0, - true, - 0, - 0, - 5, - 5, - PresenterModeConfig::default(), - ); - state.set_action_bindings(action_bindings); - state + fn binding_map(entries: &[(Action, &[&str])]) -> HashMap> { + entries + .iter() + .map(|(action, values)| { + ( + *action, + values + .iter() + .map(|value| KeyBinding::parse(value).expect("binding")) + .collect(), + ) + }) + .collect() } #[test] fn shortcut_for_help_prefers_f1_over_other_bindings() { - let state = make_state(); + let state = make_test_input_state_with_action_bindings(binding_map(&[( + Action::ToggleHelp, + &["Ctrl+Alt+Shift+H", "F1"], + )])); assert_eq!(state.shortcut_for_action(Action::ToggleHelp), Some("F1".to_string())); } #[test] fn shortcut_for_radial_menu_uses_mouse_binding_when_no_key_binding_exists() { - let state = make_state(); + let mut state = make_test_input_state_with_action_bindings(HashMap::new()); + state.radial_menu_mouse_binding = RadialMenuMouseBinding::Middle; assert_eq!( state.shortcut_for_action(Action::ToggleRadialMenu), Some("Middle Click".to_string()) @@ -112,13 +83,11 @@ mod tests { #[test] fn shortcut_for_radial_menu_combines_mouse_and_keyboard_bindings() { - let mut state = make_state(); - let mut action_bindings = HashMap::new(); - action_bindings.insert( + let mut state = make_test_input_state_with_action_bindings(binding_map(&[( Action::ToggleRadialMenu, - vec![KeyBinding::parse("Ctrl+R").expect("binding")], - ); - state.set_action_bindings(action_bindings); + &["Ctrl+R"], + )])); + state.radial_menu_mouse_binding = RadialMenuMouseBinding::Middle; assert_eq!( state.shortcut_for_action(Action::ToggleRadialMenu), @@ -128,14 +97,11 @@ mod tests { #[test] fn shortcut_for_radial_menu_returns_keyboard_only_when_mouse_binding_disabled() { - let mut state = make_state(); - state.radial_menu_mouse_binding = RadialMenuMouseBinding::Disabled; - let mut action_bindings = HashMap::new(); - action_bindings.insert( + let mut state = make_test_input_state_with_action_bindings(binding_map(&[( Action::ToggleRadialMenu, - vec![KeyBinding::parse("Ctrl+R").expect("binding")], - ); - state.set_action_bindings(action_bindings); + &["Ctrl+R"], + )])); + state.radial_menu_mouse_binding = RadialMenuMouseBinding::Disabled; assert_eq!( state.shortcut_for_action(Action::ToggleRadialMenu), @@ -145,9 +111,8 @@ mod tests { #[test] fn shortcut_for_radial_menu_returns_none_when_fully_unbound() { - let mut state = make_state(); + let mut state = make_test_input_state_with_action_bindings(HashMap::new()); state.radial_menu_mouse_binding = RadialMenuMouseBinding::Disabled; - state.set_action_bindings(HashMap::new()); assert_eq!(state.shortcut_for_action(Action::ToggleRadialMenu), None); } diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index cbf1719d..dbfe79b9 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -100,53 +100,11 @@ impl InputState { #[cfg(test)] mod tests { use super::*; - use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; - use crate::draw::{Color, FontDescriptor}; - use crate::input::{ClickHighlightSettings, EraserMode}; - - fn make_state() -> InputState { - let keybindings = KeybindingsConfig::default(); - let action_map = keybindings - .build_action_map() - .expect("default keybindings map"); - - InputState::with_defaults( - Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 1.0, - }, - 4.0, - 4.0, - EraserMode::Brush, - 0.32, - false, - 32.0, - FontDescriptor::default(), - false, - 20.0, - 30.0, - false, - true, - BoardsConfig::default(), - action_map, - usize::MAX, - ClickHighlightSettings::disabled(), - 0, - 0, - true, - 0, - 0, - 5, - 5, - PresenterModeConfig::default(), - ) - } + use crate::input::state::test_support::make_test_input_state; #[test] fn update_pointer_position_synthetic_updates_pointer_without_redraw() { - let mut state = make_state(); + let mut state = make_test_input_state(); state.needs_redraw = false; state.update_pointer_position_synthetic(12, 34); @@ -157,7 +115,7 @@ mod tests { #[test] fn set_undo_stack_limit_clamps_to_at_least_one() { - let mut state = make_state(); + let mut state = make_test_input_state(); state.set_undo_stack_limit(0); assert_eq!(state.undo_stack_limit, 1); @@ -165,17 +123,9 @@ mod tests { assert_eq!(state.undo_stack_limit, 25); } - #[test] - fn update_screen_dimensions_updates_cached_values() { - let mut state = make_state(); - state.update_screen_dimensions(1920, 1080); - assert_eq!(state.screen_width, 1920); - assert_eq!(state.screen_height, 1080); - } - #[test] fn cancel_text_input_clears_wrap_width_and_returns_to_idle() { - let mut state = make_state(); + let mut state = make_test_input_state(); state.text_wrap_width = Some(240); state.state = DrawingState::TextInput { x: 10, @@ -193,7 +143,7 @@ mod tests { #[test] fn take_dirty_regions_returns_full_surface_and_drains_tracker() { - let mut state = make_state(); + let mut state = make_test_input_state(); state.update_screen_dimensions(100, 50); state.dirty_tracker.mark_full(); diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index ae253635..8e6e1073 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -31,3 +31,67 @@ pub(crate) use core::{ COMMAND_PALETTE_PADDING, COMMAND_PALETTE_QUERY_PLACEHOLDER, }; pub use highlight::ClickHighlightSettings; + +#[cfg(test)] +pub(crate) mod test_support { + use crate::config::{ + Action, BoardsConfig, KeyBinding, KeybindingsConfig, PresenterModeConfig, + }; + use crate::draw::{Color, FontDescriptor}; + use crate::input::{ClickHighlightSettings, EraserMode, InputState}; + use std::collections::HashMap; + + pub(crate) fn make_test_input_state() -> InputState { + let keybindings = KeybindingsConfig::default(); + let action_bindings = keybindings + .build_action_bindings() + .expect("default keybindings bindings"); + make_test_input_state_with_action_bindings(action_bindings) + } + + // This helper is for tests that only need a stable InputState plus optional + // action-binding label overrides. It intentionally keeps the default + // dispatch/action map and swaps only the formatted bindings. + pub(crate) fn make_test_input_state_with_action_bindings( + action_bindings: HashMap>, + ) -> InputState { + let action_map = KeybindingsConfig::default() + .build_action_map() + .expect("default keybindings map"); + + let mut state = InputState::with_defaults( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 4.0, + 4.0, + EraserMode::Brush, + 0.32, + false, + 32.0, + FontDescriptor::default(), + false, + 20.0, + 30.0, + false, + true, + BoardsConfig::default(), + action_map, + usize::MAX, + ClickHighlightSettings::disabled(), + 0, + 0, + true, + 0, + 0, + 5, + 5, + PresenterModeConfig::default(), + ); + state.set_action_bindings(action_bindings); + state + } +} diff --git a/src/ui/toolbar/bindings.rs b/src/ui/toolbar/bindings.rs index 732d7145..27374d28 100644 --- a/src/ui/toolbar/bindings.rs +++ b/src/ui/toolbar/bindings.rs @@ -151,53 +151,22 @@ pub(crate) fn action_for_clear_preset(slot: usize) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; - use crate::draw::{Color, FontDescriptor}; - use crate::input::{ClickHighlightSettings, EraserMode}; - - fn make_state() -> InputState { - let keybindings = KeybindingsConfig::default(); - let action_map = keybindings - .build_action_map() - .expect("default keybindings map"); - let action_bindings = keybindings - .build_action_bindings() - .expect("default keybindings bindings"); - - let mut state = InputState::with_defaults( - Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 1.0, - }, - 4.0, - 4.0, - EraserMode::Brush, - 0.32, - false, - 32.0, - FontDescriptor::default(), - false, - 20.0, - 30.0, - false, - true, - BoardsConfig::default(), - action_map, - usize::MAX, - ClickHighlightSettings::disabled(), - 0, - 0, - true, - 0, - 0, - 5, - 5, - PresenterModeConfig::default(), - ); - state.set_action_bindings(action_bindings); - state + use crate::config::KeyBinding; + use crate::input::state::test_support::make_test_input_state_with_action_bindings; + + fn binding_map(entries: &[(Action, &[&str])]) -> HashMap> { + entries + .iter() + .map(|(action, values)| { + ( + *action, + values + .iter() + .map(|value| KeyBinding::parse(value).expect("binding")) + .collect(), + ) + }) + .collect() } #[test] @@ -233,51 +202,77 @@ mod tests { #[test] fn toolbar_binding_hints_collect_only_toolbar_actions() { - let state = make_state(); + let state = make_test_input_state_with_action_bindings(binding_map(&[ + (Action::OpenConfigurator, &["Ctrl+Alt+Shift+O"]), + (Action::Exit, &["Ctrl+Alt+Shift+Q"]), + ])); let hints = ToolbarBindingHints::from_input_state(&state); + let expected = KeyBinding::parse("Ctrl+Alt+Shift+O").unwrap().to_string(); - assert_eq!(hints.binding_for_action(Action::OpenConfigurator), Some("F11")); + assert_eq!( + hints.binding_for_action(Action::OpenConfigurator), + Some(expected.as_str()) + ); assert_eq!(hints.binding_for_action(Action::Exit), None); } #[test] fn toolbar_binding_hints_follow_event_mapping() { - let state = make_state(); + let state = make_test_input_state_with_action_bindings(binding_map(&[( + Action::OpenConfigurator, + &["Alt+P"], + )])); let hints = ToolbarBindingHints::from_input_state(&state); assert_eq!( hints.binding_for_event(&ToolbarEvent::OpenConfigurator), - Some("F11") + Some("Alt+P") ); assert_eq!(hints.binding_for_event(&ToolbarEvent::OpenConfigFile), None); } #[test] fn toolbar_binding_hints_resolve_tool_bindings() { - let state = make_state(); + let state = make_test_input_state_with_action_bindings(binding_map(&[ + (Action::SelectPenTool, &["Ctrl+P"]), + (Action::SelectEraserTool, &["Ctrl+E"]), + ])); let hints = ToolbarBindingHints::from_input_state(&state); - assert_eq!(hints.for_tool(Tool::Pen), Some("F")); - assert_eq!(hints.for_tool(Tool::Eraser), Some("D")); + assert_eq!(hints.for_tool(Tool::Pen), Some("Ctrl+P")); + assert_eq!(hints.for_tool(Tool::Eraser), Some("Ctrl+E")); assert_eq!(hints.for_tool(Tool::StepMarker), None); } #[test] fn toolbar_binding_hints_resolve_preset_bindings() { - let state = make_state(); + let state = make_test_input_state_with_action_bindings(binding_map(&[ + (Action::ApplyPreset1, &["Alt+1"]), + (Action::SavePreset1, &["Alt+2"]), + (Action::ClearPreset1, &["Alt+3"]), + ])); let hints = ToolbarBindingHints::from_input_state(&state); - assert_eq!(hints.apply_preset(1), Some("1")); - assert_eq!(hints.save_preset(1), Some("Shift+1")); - assert_eq!(hints.clear_preset(1), Some("Ctrl+1")); + assert_eq!(hints.apply_preset(1), Some("Alt+1")); + assert_eq!(hints.save_preset(1), Some("Alt+2")); + assert_eq!(hints.clear_preset(1), Some("Alt+3")); assert_eq!(hints.apply_preset(6), None); } #[test] fn tool_label_and_tooltip_label_use_action_metadata() { - assert_eq!(tool_label(Tool::Ellipse), "Circle"); - assert_eq!(tool_tooltip_label(Tool::Ellipse), "Ellipse Tool"); - assert_eq!(tool_label(Tool::Select), "Select"); + assert_eq!( + tool_label(Tool::Ellipse), + action_short_label(Action::SelectEllipseTool) + ); + assert_eq!( + tool_tooltip_label(Tool::Ellipse), + action_label(Action::SelectEllipseTool) + ); + assert_eq!( + tool_label(Tool::Select), + action_short_label(Action::SelectSelectionTool) + ); } #[test] From c38ae28a801c38686536e833cfc8bf75c8cd8d35 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:42:19 +0100 Subject: [PATCH 3/3] test: fix CI lint and formatting --- src/draw/render/selection.rs | 6 +- src/input/state/core/board_picker/search.rs | 14 +-- .../state/core/command_palette/layout.rs | 24 +++- src/input/state/core/command_palette/mod.rs | 10 +- src/input/state/core/menus/shortcuts.rs | 5 +- .../apply_selection/actions/arrow.rs | 14 ++- .../apply_selection/actions/color.rs | 39 +++++-- .../apply_selection/actions/fill.rs | 16 ++- .../apply_selection/actions/text.rs | 8 +- .../properties/apply_selection/helpers.rs | 106 +++++++++++++++--- src/input/state/core/properties/entries.rs | 34 ++++-- src/input/state/core/properties/panel.rs | 9 +- .../core/properties/panel_layout/focus.rs | 28 ++++- src/input/state/core/utility/interaction.rs | 5 +- src/input/state/core/utility/pending.rs | 21 +++- src/input/state/core/utility/toasts.rs | 2 +- src/input/state/mod.rs | 4 +- src/input/state/tests/action_bindings.rs | 5 +- src/input/state/tests/board_picker.rs | 38 +++++-- src/input/state/tests/boards.rs | 12 +- src/input/state/tests/delete_restore.rs | 14 ++- src/input/state/tests/menus/context_menu.rs | 34 ++++-- src/input/state/tests/pages.rs | 40 +++++-- src/input/state/tests/properties_panel.rs | 45 +++++++- src/input/state/tests/selection/duplicate.rs | 6 +- src/label_format.rs | 10 +- src/ui/toolbar/bindings.rs | 25 ++++- 27 files changed, 442 insertions(+), 132 deletions(-) diff --git a/src/draw/render/selection.rs b/src/draw/render/selection.rs index db3a71b2..1cd3205e 100644 --- a/src/draw/render/selection.rs +++ b/src/draw/render/selection.rs @@ -359,6 +359,10 @@ mod tests { let bounds = Rect::new(0, 0, 1, 1).unwrap(); let handles = selection_handle_rects(&bounds); - assert!(handles.iter().all(|handle| handle.width > 0 && handle.height > 0)); + assert!( + handles + .iter() + .all(|handle| handle.width > 0 && handle.height > 0) + ); } } diff --git a/src/input/state/core/board_picker/search.rs b/src/input/state/core/board_picker/search.rs index 6badae98..47049098 100644 --- a/src/input/state/core/board_picker/search.rs +++ b/src/input/state/core/board_picker/search.rs @@ -184,9 +184,7 @@ fn fuzzy_score_with_single_swap(needle: &str, haystack: &str) -> Option { #[cfg(test)] mod tests { - use super::{ - BOARD_PICKER_SEARCH_TIMEOUT, BoardPickerFocus, fuzzy_score, fuzzy_score_relaxed, - }; + use super::{BOARD_PICKER_SEARCH_TIMEOUT, BoardPickerFocus, fuzzy_score, fuzzy_score_relaxed}; use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; use crate::draw::{Color, FontDescriptor}; use crate::input::{ClickHighlightSettings, EraserMode, InputState}; @@ -253,7 +251,10 @@ mod tests { let mut state = make_state(); state.open_board_picker(); - assert_eq!(state.board_picker_match_index("3"), state.board_picker_row_for_board(2)); + assert_eq!( + state.board_picker_match_index("3"), + state.board_picker_row_for_board(2) + ); } #[test] @@ -299,9 +300,8 @@ mod tests { let mut state = make_state(); state.open_board_picker(); state.board_picker_search = "old".to_string(); - state.board_picker_search_last_input = Some( - Instant::now() - BOARD_PICKER_SEARCH_TIMEOUT - Duration::from_millis(1), - ); + state.board_picker_search_last_input = + Some(Instant::now() - BOARD_PICKER_SEARCH_TIMEOUT - Duration::from_millis(1)); state.board_picker_append_search('b'); diff --git a/src/input/state/core/command_palette/layout.rs b/src/input/state/core/command_palette/layout.rs index ef9bcdfe..bfb4ff0c 100644 --- a/src/input/state/core/command_palette/layout.rs +++ b/src/input/state/core/command_palette/layout.rs @@ -192,13 +192,19 @@ mod tests { fn visible_count_caps_at_max_visible() { assert_eq!(command_palette_visible_count(0), 0); assert_eq!(command_palette_visible_count(3), 3); - assert_eq!(command_palette_visible_count(99), COMMAND_PALETTE_MAX_VISIBLE); + assert_eq!( + command_palette_visible_count(99), + COMMAND_PALETTE_MAX_VISIBLE + ); } #[test] fn command_palette_height_clamps_to_maximum() { assert!(command_palette_height(1) < COMMAND_PALETTE_MAX_HEIGHT); - assert_eq!(command_palette_height(COMMAND_PALETTE_MAX_VISIBLE), COMMAND_PALETTE_MAX_HEIGHT); + assert_eq!( + command_palette_height(COMMAND_PALETTE_MAX_VISIBLE), + COMMAND_PALETTE_MAX_HEIGHT + ); } #[test] @@ -223,16 +229,24 @@ mod tests { let geometry = sample_geometry(); let x = geometry.inner_x + 10.0; - assert_eq!(geometry.visible_item_at(x, geometry.items_top + 1.0), Some(0)); + assert_eq!( + geometry.visible_item_at(x, geometry.items_top + 1.0), + Some(0) + ); assert_eq!( geometry.visible_item_at(x, geometry.items_top + COMMAND_PALETTE_ITEM_HEIGHT + 1.0), Some(1) ); - assert_eq!(geometry.visible_item_at(geometry.inner_x - 1.0, geometry.items_top + 1.0), None); + assert_eq!( + geometry.visible_item_at(geometry.inner_x - 1.0, geometry.items_top + 1.0), + None + ); assert_eq!( geometry.visible_item_at( x, - geometry.items_top + geometry.visible_count as f64 * COMMAND_PALETTE_ITEM_HEIGHT + 1.0, + geometry.items_top + + geometry.visible_count as f64 * COMMAND_PALETTE_ITEM_HEIGHT + + 1.0, ), None ); diff --git a/src/input/state/core/command_palette/mod.rs b/src/input/state/core/command_palette/mod.rs index 2dccfd07..2d580165 100644 --- a/src/input/state/core/command_palette/mod.rs +++ b/src/input/state/core/command_palette/mod.rs @@ -259,7 +259,10 @@ mod tests { state.toggle_command_palette(); state.command_palette_query = "status bar".to_string(); let selected = state.selected_command().expect("selected command"); - assert_eq!(selected.action, crate::config::keybindings::Action::ToggleStatusBar); + assert_eq!( + selected.action, + crate::config::keybindings::Action::ToggleStatusBar + ); assert!(state.show_status_bar); assert!(state.handle_command_palette_key(crate::input::Key::Return)); @@ -331,7 +334,10 @@ mod tests { assert!(!state.command_palette_open); assert!(!state.show_status_bar); let toast = state.ui_toast.as_ref().expect("command toast"); - assert_eq!(toast.kind, crate::input::state::core::base::UiToastKind::Info); + assert_eq!( + toast.kind, + crate::input::state::core::base::UiToastKind::Info + ); assert_eq!(toast.message, selected.label); } diff --git a/src/input/state/core/menus/shortcuts.rs b/src/input/state/core/menus/shortcuts.rs index 5a1edb28..bca1a0e3 100644 --- a/src/input/state/core/menus/shortcuts.rs +++ b/src/input/state/core/menus/shortcuts.rs @@ -68,7 +68,10 @@ mod tests { Action::ToggleHelp, &["Ctrl+Alt+Shift+H", "F1"], )])); - assert_eq!(state.shortcut_for_action(Action::ToggleHelp), Some("F1".to_string())); + assert_eq!( + state.shortcut_for_action(Action::ToggleHelp), + Some("F1".to_string()) + ); } #[test] diff --git a/src/input/state/core/properties/apply_selection/actions/arrow.rs b/src/input/state/core/properties/apply_selection/actions/arrow.rs index 21ccda69..083722a0 100644 --- a/src/input/state/core/properties/apply_selection/actions/arrow.rs +++ b/src/input/state/core/properties/apply_selection/actions/arrow.rs @@ -138,7 +138,11 @@ mod tests { ) } - fn add_arrow(state: &mut InputState, head_at_end: bool, arrow_angle: f64) -> crate::draw::ShapeId { + fn add_arrow( + state: &mut InputState, + head_at_end: bool, + arrow_angle: f64, + ) -> crate::draw::ShapeId { state.boards.active_frame_mut().add_shape(Shape::Arrow { x1: 0, y1: 0, @@ -179,7 +183,13 @@ mod tests { assert!(state.apply_selection_arrow_angle(1)); assert!(!state.apply_selection_arrow_angle(1)); - match &state.boards.active_frame().shape(arrow_id).expect("arrow").shape { + match &state + .boards + .active_frame() + .shape(arrow_id) + .expect("arrow") + .shape + { Shape::Arrow { arrow_angle, .. } => assert_eq!(*arrow_angle, MAX_ARROW_ANGLE), other => panic!("expected arrow, got {other:?}"), } diff --git a/src/input/state/core/properties/apply_selection/actions/color.rs b/src/input/state/core/properties/apply_selection/actions/color.rs index 71efd3cc..abd552da 100644 --- a/src/input/state/core/properties/apply_selection/actions/color.rs +++ b/src/input/state/core/properties/apply_selection/actions/color.rs @@ -185,21 +185,30 @@ mod tests { #[test] fn apply_selection_color_value_preserves_marker_alpha() { let mut state = make_state(); - let marker_id = state.boards.active_frame_mut().add_shape(Shape::MarkerStroke { - points: vec![(0, 0), (10, 10)], - color: Color { - r: 0.0, - g: 0.0, - b: 1.0, - a: 0.25, - }, - thick: 8.0, - }); + let marker_id = state + .boards + .active_frame_mut() + .add_shape(Shape::MarkerStroke { + points: vec![(0, 0), (10, 10)], + color: Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 0.25, + }, + thick: 8.0, + }); state.set_selection(vec![marker_id]); assert!(state.apply_selection_color_value(RED)); - match &state.boards.active_frame().shape(marker_id).expect("marker").shape { + match &state + .boards + .active_frame() + .shape(marker_id) + .expect("marker") + .shape + { Shape::MarkerStroke { color, .. } => assert_eq!( *color, Color { @@ -229,7 +238,13 @@ mod tests { assert!(state.apply_selection_color(0)); - match &state.boards.active_frame().shape(rect_id).expect("rect").shape { + match &state + .boards + .active_frame() + .shape(rect_id) + .expect("rect") + .shape + { Shape::Rect { color, .. } => assert_eq!(*color, RED), other => panic!("expected rect, got {other:?}"), } diff --git a/src/input/state/core/properties/apply_selection/actions/fill.rs b/src/input/state/core/properties/apply_selection/actions/fill.rs index 4b6fb8f0..309112db 100644 --- a/src/input/state/core/properties/apply_selection/actions/fill.rs +++ b/src/input/state/core/properties/apply_selection/actions/fill.rs @@ -111,11 +111,23 @@ mod tests { assert!(state.apply_selection_fill(0)); - match &state.boards.active_frame().shape(rect_id).expect("rect").shape { + match &state + .boards + .active_frame() + .shape(rect_id) + .expect("rect") + .shape + { Shape::Rect { fill, .. } => assert!(*fill), other => panic!("expected rect, got {other:?}"), } - match &state.boards.active_frame().shape(ellipse_id).expect("ellipse").shape { + match &state + .boards + .active_frame() + .shape(ellipse_id) + .expect("ellipse") + .shape + { Shape::Ellipse { fill, .. } => assert!(*fill), other => panic!("expected ellipse, got {other:?}"), } diff --git a/src/input/state/core/properties/apply_selection/actions/text.rs b/src/input/state/core/properties/apply_selection/actions/text.rs index 3df88b98..f18a6776 100644 --- a/src/input/state/core/properties/apply_selection/actions/text.rs +++ b/src/input/state/core/properties/apply_selection/actions/text.rs @@ -156,7 +156,13 @@ mod tests { assert!(state.apply_selection_font_size(1)); assert!(!state.apply_selection_font_size(1)); - match &state.boards.active_frame().shape(text_id).expect("text").shape { + match &state + .boards + .active_frame() + .shape(text_id) + .expect("text") + .shape + { Shape::Text { size, .. } => assert_eq!(*size, MAX_FONT_SIZE), other => panic!("expected text, got {other:?}"), } diff --git a/src/input/state/core/properties/apply_selection/helpers.rs b/src/input/state/core/properties/apply_selection/helpers.rs index 3c675db0..9865d8fd 100644 --- a/src/input/state/core/properties/apply_selection/helpers.rs +++ b/src/input/state/core/properties/apply_selection/helpers.rs @@ -223,7 +223,12 @@ mod tests { state } - fn add_rect(state: &mut InputState, color: Color, fill: bool, locked: bool) -> crate::draw::ShapeId { + fn add_rect( + state: &mut InputState, + color: Color, + fill: bool, + locked: bool, + ) -> crate::draw::ShapeId { let id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, y: 20, @@ -234,7 +239,11 @@ mod tests { thick: 2.0, }); if locked { - let index = state.boards.active_frame().find_index(id).expect("shape index"); + let index = state + .boards + .active_frame() + .find_index(id) + .expect("shape index"); state.boards.active_frame_mut().shapes[index].locked = true; } id @@ -245,13 +254,23 @@ mod tests { let mut state = make_state(); let locked = add_rect( &mut state, - Color { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, + Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 1.0, + }, false, true, ); let unlocked = add_rect( &mut state, - Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, false, false, ); @@ -259,15 +278,40 @@ mod tests { assert_eq!( state.selection_primary_color(), - Some(Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }) + Some(Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0 + }) ); } #[test] fn selection_bool_target_returns_true_for_mixed_or_locked_only_values() { let mut state = make_state(); - let first = add_rect(&mut state, Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, false, false); - let second = add_rect(&mut state, Color { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }, true, false); + let first = add_rect( + &mut state, + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + false, + false, + ); + let second = add_rect( + &mut state, + Color { + r: 0.0, + g: 1.0, + b: 0.0, + a: 1.0, + }, + true, + false, + ); state.set_selection(vec![first, second]); assert_eq!( @@ -281,7 +325,12 @@ mod tests { let mut locked_state = make_state(); let locked = add_rect( &mut locked_state, - Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, false, true, ); @@ -298,8 +347,28 @@ mod tests { #[test] fn selection_bool_target_flips_uniform_unlocked_value() { let mut state = make_state(); - let first = add_rect(&mut state, Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, false, false); - let second = add_rect(&mut state, Color { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }, false, false); + let first = add_rect( + &mut state, + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + false, + false, + ); + let second = add_rect( + &mut state, + Color { + r: 0.0, + g: 1.0, + b: 0.0, + a: 1.0, + }, + false, + false, + ); state.set_selection(vec![first, second]); assert_eq!( @@ -314,7 +383,8 @@ mod tests { if let Shape::Rect { fill, .. } = &mut frame.shape_mut(first).expect("first shape").shape { *fill = true; } - if let Shape::Rect { fill, .. } = &mut frame.shape_mut(second).expect("second shape").shape { + if let Shape::Rect { fill, .. } = &mut frame.shape_mut(second).expect("second shape").shape + { *fill = true; } @@ -332,13 +402,23 @@ mod tests { let mut state = make_state(); let unlocked = add_rect( &mut state, - Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, false, false, ); let locked = add_rect( &mut state, - Color { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, + Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 1.0, + }, false, true, ); diff --git a/src/input/state/core/properties/entries.rs b/src/input/state/core/properties/entries.rs index f31f86e8..32f05feb 100644 --- a/src/input/state/core/properties/entries.rs +++ b/src/input/state/core/properties/entries.rs @@ -267,7 +267,10 @@ mod tests { } fn entry<'a>(entries: &'a [SelectionPropertyEntry], label: &str) -> &'a SelectionPropertyEntry { - entries.iter().find(|entry| entry.label == label).expect(label) + entries + .iter() + .find(|entry| entry.label == label) + .expect(label) } #[test] @@ -322,7 +325,11 @@ mod tests { background_enabled: true, wrap_width: None, }); - let index = state.boards.active_frame().find_index(text_id).expect("text index"); + let index = state + .boards + .active_frame() + .find_index(text_id) + .expect("text index"); state.boards.active_frame_mut().shapes[index].locked = true; let entries = state.build_selection_property_entries(&[text_id]); @@ -394,16 +401,19 @@ mod tests { #[test] fn property_entries_treat_marker_alpha_as_opaque_for_palette_labels() { let mut state = make_state(); - let marker_id = state.boards.active_frame_mut().add_shape(Shape::MarkerStroke { - points: vec![(0, 0), (10, 10)], - color: Color { - r: 1.0, - g: 0.0, - b: 0.0, - a: 0.2, - }, - thick: 8.0, - }); + let marker_id = state + .boards + .active_frame_mut() + .add_shape(Shape::MarkerStroke { + points: vec![(0, 0), (10, 10)], + color: Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 0.2, + }, + thick: 8.0, + }); let entries = state.build_selection_property_entries(&[marker_id]); diff --git a/src/input/state/core/properties/panel.rs b/src/input/state/core/properties/panel.rs index 67e76157..686ee115 100644 --- a/src/input/state/core/properties/panel.rs +++ b/src/input/state/core/properties/panel.rs @@ -216,8 +216,8 @@ mod tests { use super::*; use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; use crate::draw::{Color, FontDescriptor, Shape, ShapeId}; - use crate::input::{ClickHighlightSettings, EraserMode}; use crate::input::state::SelectionState; + use crate::input::{ClickHighlightSettings, EraserMode}; use std::collections::HashSet; fn make_state() -> InputState { @@ -315,7 +315,12 @@ mod tests { state.refresh_properties_panel(); - assert_eq!(state.properties_panel().and_then(|panel| panel.keyboard_focus), Some(0)); + assert_eq!( + state + .properties_panel() + .and_then(|panel| panel.keyboard_focus), + Some(0) + ); } #[test] diff --git a/src/input/state/core/properties/panel_layout/focus.rs b/src/input/state/core/properties/panel_layout/focus.rs index 89d7d953..7122de9f 100644 --- a/src/input/state/core/properties/panel_layout/focus.rs +++ b/src/input/state/core/properties/panel_layout/focus.rs @@ -193,7 +193,12 @@ mod tests { panel.entries[1].disabled = false; assert!(state.focus_first_properties_entry()); - assert_eq!(state.properties_panel().and_then(|panel| panel.keyboard_focus), Some(1)); + assert_eq!( + state + .properties_panel() + .and_then(|panel| panel.keyboard_focus), + Some(1) + ); } #[test] @@ -205,7 +210,12 @@ mod tests { panel.entries[last].disabled = true; assert!(state.focus_last_properties_entry()); - assert_eq!(state.properties_panel().and_then(|panel| panel.keyboard_focus), Some(last - 1)); + assert_eq!( + state + .properties_panel() + .and_then(|panel| panel.keyboard_focus), + Some(last - 1) + ); } #[test] @@ -218,7 +228,12 @@ mod tests { panel.entries[1].disabled = true; assert!(state.focus_next_properties_entry()); - assert_eq!(state.properties_panel().and_then(|panel| panel.keyboard_focus), Some(2)); + assert_eq!( + state + .properties_panel() + .and_then(|panel| panel.keyboard_focus), + Some(2) + ); } #[test] @@ -228,6 +243,11 @@ mod tests { state.set_properties_panel_focus(Some(0)); assert!(state.focus_previous_properties_entry()); - assert_eq!(state.properties_panel().and_then(|panel| panel.keyboard_focus), Some(0)); + assert_eq!( + state + .properties_panel() + .and_then(|panel| panel.keyboard_focus), + Some(0) + ); } } diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index dbfe79b9..4517af91 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -147,7 +147,10 @@ mod tests { state.update_screen_dimensions(100, 50); state.dirty_tracker.mark_full(); - assert_eq!(state.take_dirty_regions(), vec![Rect::new(0, 0, 100, 50).unwrap()]); + assert_eq!( + state.take_dirty_regions(), + vec![Rect::new(0, 0, 100, 50).unwrap()] + ); assert!(state.take_dirty_regions().is_empty()); } } diff --git a/src/input/state/core/utility/pending.rs b/src/input/state/core/utility/pending.rs index 347ef085..4b6a4b99 100644 --- a/src/input/state/core/utility/pending.rs +++ b/src/input/state/core/utility/pending.rs @@ -95,7 +95,10 @@ mod tests { let mut state = make_state(); state.set_pending_capture_action(Action::CaptureFileFull); - assert_eq!(state.take_pending_capture_action(), Some(Action::CaptureFileFull)); + assert_eq!( + state.take_pending_capture_action(), + Some(Action::CaptureFileFull) + ); assert_eq!(state.take_pending_capture_action(), None); } @@ -104,7 +107,10 @@ mod tests { let mut state = make_state(); state.request_output_focus_action(OutputFocusAction::Next); - assert_eq!(state.take_pending_output_focus_action(), Some(OutputFocusAction::Next)); + assert_eq!( + state.take_pending_output_focus_action(), + Some(OutputFocusAction::Next) + ); assert_eq!(state.take_pending_output_focus_action(), None); } @@ -113,7 +119,10 @@ mod tests { let mut state = make_state(); state.request_zoom_action(ZoomAction::ToggleLock); - assert_eq!(state.take_pending_zoom_action(), Some(ZoomAction::ToggleLock)); + assert_eq!( + state.take_pending_zoom_action(), + Some(ZoomAction::ToggleLock) + ); assert_eq!(state.take_pending_zoom_action(), None); } @@ -132,8 +141,10 @@ mod tests { #[test] fn pending_board_config_is_taken_once() { let mut state = make_state(); - let mut config = BoardsConfig::default(); - config.default_board = "blackboard".to_string(); + let config = BoardsConfig { + default_board: "blackboard".to_string(), + ..BoardsConfig::default() + }; state.pending_board_config = Some(config.clone()); let taken = state.take_pending_board_config().expect("board config"); diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index cb7d3706..c4c2b317 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -259,8 +259,8 @@ mod tests { use super::*; use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; use crate::draw::{Color, FontDescriptor}; - use crate::input::{ClickHighlightSettings, EraserMode}; use crate::input::state::core::base::TextEditEntryFeedback; + use crate::input::{ClickHighlightSettings, EraserMode}; fn make_state() -> InputState { let keybindings = KeybindingsConfig::default(); diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 8e6e1073..44a23bf2 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -34,9 +34,7 @@ pub use highlight::ClickHighlightSettings; #[cfg(test)] pub(crate) mod test_support { - use crate::config::{ - Action, BoardsConfig, KeyBinding, KeybindingsConfig, PresenterModeConfig, - }; + use crate::config::{Action, BoardsConfig, KeyBinding, KeybindingsConfig, PresenterModeConfig}; use crate::draw::{Color, FontDescriptor}; use crate::input::{ClickHighlightSettings, EraserMode, InputState}; use std::collections::HashMap; diff --git a/src/input/state/tests/action_bindings.rs b/src/input/state/tests/action_bindings.rs index bf082a43..b533b583 100644 --- a/src/input/state/tests/action_bindings.rs +++ b/src/input/state/tests/action_bindings.rs @@ -26,10 +26,7 @@ fn explicit_action_binding_labels_dedup_and_preserve_order() { fn custom_action_bindings_override_fallback_action_map_labels() { let mut state = create_test_input_state(); let mut bindings = HashMap::new(); - bindings.insert( - Action::ToggleHelp, - vec![KeyBinding::parse("Menu").unwrap()], - ); + bindings.insert(Action::ToggleHelp, vec![KeyBinding::parse("Menu").unwrap()]); state.set_action_bindings(bindings); assert_eq!( diff --git a/src/input/state/tests/board_picker.rs b/src/input/state/tests/board_picker.rs index e0eb5dc5..ff7a9de5 100644 --- a/src/input/state/tests/board_picker.rs +++ b/src/input/state/tests/board_picker.rs @@ -644,7 +644,11 @@ fn board_picker_activate_row_on_new_row_creates_board_and_starts_editing() { assert_eq!(input.boards.board_count(), initial_count + 1); assert_eq!( input.board_picker_edit_state(), - Some((BoardPickerEditMode::Name, active_row, input.boards.active_board_name())) + Some(( + BoardPickerEditMode::Name, + active_row, + input.boards.active_board_name() + )) ); } @@ -710,7 +714,11 @@ fn board_picker_create_new_from_quick_mode_promotes_to_full_and_starts_editing() assert_eq!(input.boards.board_count(), initial_count + 1); assert_eq!( input.board_picker_edit_state(), - Some((BoardPickerEditMode::Name, active_row, input.boards.active_board_name())) + Some(( + BoardPickerEditMode::Name, + active_row, + input.boards.active_board_name() + )) ); } @@ -734,7 +742,9 @@ fn board_picker_duplicate_page_uses_selected_page_panel_board() { input.board_picker_duplicate_page(0); assert_eq!( - input.boards.board_states()[blackboard_index].pages.page_count(), + input.boards.board_states()[blackboard_index] + .pages + .page_count(), 2 ); } @@ -759,7 +769,9 @@ fn board_picker_add_page_uses_selected_page_panel_board() { input.board_picker_add_page(); assert_eq!( - input.boards.board_states()[blackboard_index].pages.page_count(), + input.boards.board_states()[blackboard_index] + .pages + .page_count(), 2 ); } @@ -783,17 +795,23 @@ fn board_picker_delete_page_requires_confirmation_for_multi_page_boards() { input.board_picker_delete_page(1); assert_eq!( - input.boards.board_states()[blackboard_index].pages.page_count(), + input.boards.board_states()[blackboard_index] + .pages + .page_count(), 2 ); - assert!(input - .ui_toast - .as_ref() - .is_some_and(|toast| toast.message.contains("Click delete again to confirm."))); + assert!( + input + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Click delete again to confirm.")) + ); input.board_picker_delete_page(1); assert_eq!( - input.boards.board_states()[blackboard_index].pages.page_count(), + input.boards.board_states()[blackboard_index] + .pages + .page_count(), 1 ); } diff --git a/src/input/state/tests/boards.rs b/src/input/state/tests/boards.rs index bb0547f9..c8e1bbdc 100644 --- a/src/input/state/tests/boards.rs +++ b/src/input/state/tests/boards.rs @@ -1,6 +1,6 @@ use super::*; -use crate::input::{BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD}; use crate::input::state::core::board_picker::BoardPickerState; +use crate::input::{BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD}; #[test] fn switch_board_force_does_not_toggle_back_to_transparent() { @@ -88,8 +88,10 @@ fn create_board_adds_board_queues_config_save_and_emits_toast() { assert_eq!(state.boards.board_count(), initial_count + 1); assert!(state.take_pending_board_config().is_some()); - assert!(state - .ui_toast - .as_ref() - .is_some_and(|toast| toast.message.starts_with("Board created:"))); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.starts_with("Board created:")) + ); } diff --git a/src/input/state/tests/delete_restore.rs b/src/input/state/tests/delete_restore.rs index d05a6681..dd77e158 100644 --- a/src/input/state/tests/delete_restore.rs +++ b/src/input/state/tests/delete_restore.rs @@ -12,7 +12,9 @@ fn board_index(state: &InputState, id: &str) -> usize { } fn set_page_count(state: &mut InputState, board_index: usize, count: usize) { - let pages = state.boards.board_states_mut()[board_index].pages.pages_mut(); + let pages = state.boards.board_states_mut()[board_index] + .pages + .pages_mut(); pages.clear(); pages.extend((0..count.max(1)).map(|_| Frame::new())); } @@ -26,10 +28,12 @@ fn delete_active_board_requires_confirmation_then_restore_recovers_board() { state.delete_active_board(); assert!(state.has_pending_board_delete()); assert_eq!(state.boards.board_count(), initial_count); - assert!(state - .ui_toast - .as_ref() - .is_some_and(|toast| toast.message.contains("Click to confirm."))); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Click to confirm.")) + ); state.delete_active_board(); assert!(!state.has_pending_board_delete()); diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index 84d7b113..1c70255c 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -318,7 +318,11 @@ fn canvas_menu_uses_clear_unlocked_label_when_canvas_has_locked_shapes() { color: state.current_color, thick: state.current_thickness, }); - let locked_index = state.boards.active_frame().find_index(locked).expect("locked index"); + let locked_index = state + .boards + .active_frame() + .find_index(locked) + .expect("locked index"); state.boards.active_frame_mut().shapes[locked_index].locked = true; state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Canvas, None); @@ -382,9 +386,11 @@ fn pages_menu_shows_window_indicators_around_active_page() { let entries = state.context_menu_entries(); assert!(entries.iter().any(|entry| entry.label == " ... 1 above")); assert!(entries.iter().any(|entry| entry.label == " ... 1 below")); - assert!(entries - .iter() - .any(|entry| entry.label == " Page 6 (current)" && entry.disabled)); + assert!( + entries + .iter() + .any(|entry| entry.label == " Page 6 (current)" && entry.disabled) + ); } #[test] @@ -461,7 +467,10 @@ fn page_duplicate_from_context_duplicates_target_page_and_closes_menu() { state.execute_menu_command(MenuCommand::PageDuplicateFromContext); - assert_eq!(state.boards.board_states()[blackboard].pages.page_count(), 2); + assert_eq!( + state.boards.board_states()[blackboard].pages.page_count(), + 2 + ); assert!(!state.is_context_menu_open()); } @@ -479,9 +488,18 @@ fn page_move_to_board_command_moves_page_switches_board_and_closes_menu() { }); assert_eq!(state.board_id(), "whiteboard"); - assert_eq!(state.boards.board_states()[blackboard].pages.page_count(), 1); - assert_eq!(state.boards.board_states()[whiteboard].pages.page_count(), 2); - assert_eq!(state.boards.board_states()[whiteboard].pages.page_name(1), Some("Move me")); + assert_eq!( + state.boards.board_states()[blackboard].pages.page_count(), + 1 + ); + assert_eq!( + state.boards.board_states()[whiteboard].pages.page_count(), + 2 + ); + assert_eq!( + state.boards.board_states()[whiteboard].pages.page_name(1), + Some("Move me") + ); assert!(!state.is_context_menu_open()); } diff --git a/src/input/state/tests/pages.rs b/src/input/state/tests/pages.rs index c631ca20..6a6ce03a 100644 --- a/src/input/state/tests/pages.rs +++ b/src/input/state/tests/pages.rs @@ -74,7 +74,10 @@ fn set_board_background_color_updates_active_auto_adjust_pen_color() { a: 1.0, }) ); - assert_eq!(state.current_color, board.spec.effective_pen_color().expect("pen color")); + assert_eq!( + state.current_color, + board.spec.effective_pen_color().expect("pen color") + ); assert!(state.take_pending_board_config().is_some()); } @@ -105,11 +108,16 @@ fn move_page_between_boards_copy_preserves_source_and_adds_page_to_target() { assert_eq!(state.boards.board_states()[source].pages.page_count(), 1); assert_eq!(state.boards.board_states()[target].pages.page_count(), 2); - assert_eq!(state.boards.board_states()[target].pages.page_name(1), Some("Copied page")); - assert!(state - .ui_toast - .as_ref() - .is_some_and(|toast| toast.message.contains("Page copied to 'Blackboard'"))); + assert_eq!( + state.boards.board_states()[target].pages.page_name(1), + Some("Copied page") + ); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page copied to 'Blackboard'")) + ); } #[test] @@ -123,12 +131,20 @@ fn move_page_between_boards_move_removes_source_page_and_activates_target_copy() assert!(state.move_page_between_boards(source, 1, target, false)); assert_eq!(state.boards.board_states()[source].pages.page_count(), 1); - assert_eq!(state.boards.board_states()[source].pages.page_name(0), Some("Keep")); + assert_eq!( + state.boards.board_states()[source].pages.page_name(0), + Some("Keep") + ); assert_eq!(state.boards.board_states()[target].pages.page_count(), 2); - assert_eq!(state.boards.board_states()[target].pages.page_name(1), Some("Move me")); + assert_eq!( + state.boards.board_states()[target].pages.page_name(1), + Some("Move me") + ); assert_eq!(state.boards.board_states()[target].pages.active_index(), 1); - assert!(state - .ui_toast - .as_ref() - .is_some_and(|toast| toast.message.contains("Page moved to 'Blackboard'"))); + assert!( + state + .ui_toast + .as_ref() + .is_some_and(|toast| toast.message.contains("Page moved to 'Blackboard'")) + ); } diff --git a/src/input/state/tests/properties_panel.rs b/src/input/state/tests/properties_panel.rs index 324211ce..92f9e27b 100644 --- a/src/input/state/tests/properties_panel.rs +++ b/src/input/state/tests/properties_panel.rs @@ -33,7 +33,12 @@ fn show_properties_panel_for_single_shape_reports_type_layer_and_lock_state() { let panel = state.properties_panel().expect("properties panel"); assert_eq!(panel.title, "Shape Properties"); assert!(!panel.multiple_selection); - assert!(panel.lines.iter().any(|line| line == &format!("Shape ID: {shape_id}"))); + assert!( + panel + .lines + .iter() + .any(|line| line == &format!("Shape ID: {shape_id}")) + ); assert!(panel.lines.iter().any(|line| line == "Type: Rectangle")); assert!(panel.lines.iter().any(|line| line == "Layer: 1 of 1")); assert!(panel.lines.iter().any(|line| line == "Locked: No")); @@ -45,7 +50,11 @@ fn show_properties_panel_for_multi_selection_includes_locked_count_and_summary() let mut state = create_test_input_state(); let first = add_rect(&mut state, 10, 10, 20, 20); let second = add_rect(&mut state, 50, 15, 10, 15); - let second_index = state.boards.active_frame().find_index(second).expect("second index"); + let second_index = state + .boards + .active_frame() + .find_index(second) + .expect("second index"); state.boards.active_frame_mut().shapes[second_index].locked = true; state.set_selection(vec![first, second]); @@ -85,7 +94,13 @@ fn activate_fill_entry_toggles_rectangle_fill_and_refreshes_panel_value() { assert!(state.activate_properties_panel_entry()); - match &state.boards.active_frame().shape(shape_id).expect("shape").shape { + match &state + .boards + .active_frame() + .shape(shape_id) + .expect("shape") + .shape + { Shape::Rect { fill, .. } => assert!(*fill), other => panic!("expected rect, got {other:?}"), } @@ -115,7 +130,13 @@ fn adjust_font_size_entry_increases_text_size_and_refreshes_panel_value() { assert!(state.adjust_properties_panel_entry(1)); - match &state.boards.active_frame().shape(shape_id).expect("shape").shape { + match &state + .boards + .active_frame() + .shape(shape_id) + .expect("shape") + .shape + { Shape::Text { size, .. } => assert_eq!(*size, 20.0), other => panic!("expected text, got {other:?}"), } @@ -156,7 +177,13 @@ fn activate_text_background_entry_on_mixed_selection_turns_all_backgrounds_on() assert!(state.activate_properties_panel_entry()); for id in [first, second] { - match &state.boards.active_frame().shape(id).expect("text shape").shape { + match &state + .boards + .active_frame() + .shape(id) + .expect("text shape") + .shape + { Shape::Text { background_enabled, .. } => assert!(*background_enabled), @@ -192,7 +219,13 @@ fn adjust_arrow_length_entry_clamps_to_max_and_refreshes_panel_value() { assert!(state.adjust_properties_panel_entry(1)); assert!(!state.adjust_properties_panel_entry(1)); - match &state.boards.active_frame().shape(shape_id).expect("arrow").shape { + match &state + .boards + .active_frame() + .shape(shape_id) + .expect("arrow") + .shape + { Shape::Arrow { arrow_length, .. } => assert_eq!(*arrow_length, 50.0), other => panic!("expected arrow, got {other:?}"), } diff --git a/src/input/state/tests/selection/duplicate.rs b/src/input/state/tests/selection/duplicate.rs index 818025d2..7721e157 100644 --- a/src/input/state/tests/selection/duplicate.rs +++ b/src/input/state/tests/selection/duplicate.rs @@ -124,7 +124,11 @@ fn copy_selection_of_only_locked_shapes_leaves_clipboard_empty() { color: state.current_color, thick: state.current_thickness, }); - let locked_index = state.boards.active_frame().find_index(locked_id).expect("locked index"); + let locked_index = state + .boards + .active_frame() + .find_index(locked_id) + .expect("locked index"); state.boards.active_frame_mut().shapes[locked_index].locked = true; state.set_selection(vec![locked_id]); diff --git a/src/label_format.rs b/src/label_format.rs index 19ce4a60..6e74dfe4 100644 --- a/src/label_format.rs +++ b/src/label_format.rs @@ -38,7 +38,10 @@ mod tests { #[test] fn join_binding_labels_uses_shared_separator() { let labels = vec!["Ctrl+K".to_string(), "F1".to_string()]; - assert_eq!(join_binding_labels(&labels), Some("Ctrl+K / F1".to_string())); + assert_eq!( + join_binding_labels(&labels), + Some("Ctrl+K / F1".to_string()) + ); } #[test] @@ -53,7 +56,10 @@ mod tests { #[test] fn format_binding_label_includes_optional_binding_text() { - assert_eq!(format_binding_label("Undo", Some("Ctrl+Z")), "Undo (Ctrl+Z)"); + assert_eq!( + format_binding_label("Undo", Some("Ctrl+Z")), + "Undo (Ctrl+Z)" + ); assert_eq!(format_binding_label("Undo", None), "Undo"); } } diff --git a/src/ui/toolbar/bindings.rs b/src/ui/toolbar/bindings.rs index 27374d28..89db9130 100644 --- a/src/ui/toolbar/bindings.rs +++ b/src/ui/toolbar/bindings.rs @@ -171,13 +171,22 @@ mod tests { #[test] fn action_for_tool_maps_selection_and_eraser_tools() { - assert_eq!(action_for_tool(Tool::Select), Some(Action::SelectSelectionTool)); - assert_eq!(action_for_tool(Tool::Eraser), Some(Action::SelectEraserTool)); + assert_eq!( + action_for_tool(Tool::Select), + Some(Action::SelectSelectionTool) + ); + assert_eq!( + action_for_tool(Tool::Eraser), + Some(Action::SelectEraserTool) + ); } #[test] fn action_for_event_maps_board_picker_related_events() { - assert_eq!(action_for_event(&ToolbarEvent::BoardRename), Some(Action::BoardPicker)); + assert_eq!( + action_for_event(&ToolbarEvent::BoardRename), + Some(Action::BoardPicker) + ); assert_eq!( action_for_event(&ToolbarEvent::ToggleBoardPicker), Some(Action::BoardPicker) @@ -187,7 +196,10 @@ mod tests { #[test] fn action_for_event_returns_none_for_layout_only_events() { assert_eq!(action_for_event(&ToolbarEvent::OpenConfigFile), None); - assert_eq!(action_for_event(&ToolbarEvent::ToggleShapePicker(true)), None); + assert_eq!( + action_for_event(&ToolbarEvent::ToggleShapePicker(true)), + None + ); } #[test] @@ -281,6 +293,9 @@ mod tests { action_for_event(&ToolbarEvent::SelectTool(Tool::Pen)), Some(Action::SelectPenTool) ); - assert_eq!(action_for_event(&ToolbarEvent::ToggleFreeze), Some(Action::ToggleFrozenMode)); + assert_eq!( + action_for_event(&ToolbarEvent::ToggleFreeze), + Some(Action::ToggleFrozenMode) + ); } }