From 4d7d91257dc877bbdfd66d25a718a289884e83f0 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Thu, 9 Apr 2026 02:49:25 -0700 Subject: [PATCH] refactor: colocate cmd tests with their modules Move tests from the monolithic tests.rs into #[cfg(test)] mod tests blocks in each command module. Shared test helpers live in mod.rs::test_helpers. Co-Authored-By: Claude Opus 4.6 (1M context) Executed-By: fido --- src/command/cmd/cell.rs | 111 +++ src/command/cmd/commit.rs | 127 +++ src/command/cmd/core.rs | 20 + src/command/cmd/effect_cmds.rs | 78 ++ src/command/cmd/grid.rs | 109 +++ src/command/cmd/mod.rs | 104 ++- src/command/cmd/mode.rs | 119 +++ src/command/cmd/navigation.rs | 198 ++++- src/command/cmd/panel.rs | 240 ++++++ src/command/cmd/search.rs | 81 ++ src/command/cmd/tests.rs | 1407 -------------------------------- src/command/cmd/text_buffer.rs | 121 +++ src/command/cmd/tile.rs | 78 ++ 13 files changed, 1384 insertions(+), 1409 deletions(-) delete mode 100644 src/command/cmd/tests.rs diff --git a/src/command/cmd/cell.rs b/src/command/cmd/cell.rs index 7fbaed6..794e2f4 100644 --- a/src/command/cmd/cell.rs +++ b/src/command/cmd/cell.rs @@ -2,6 +2,117 @@ use crate::ui::effect::{self, Effect}; use super::core::{Cmd, CmdContext}; +#[cfg(test)] +mod tests { + use super::*; + use crate::command::cmd::test_helpers::*; + use crate::model::cell::{CellKey, CellValue}; + + #[test] + fn clear_selected_cell_produces_clear_and_dirty() { + let mut m = two_cat_model(); + let key = CellKey::new(vec![ + ("Type".to_string(), "Food".to_string()), + ("Month".to_string(), "Jan".to_string()), + ]); + m.set_cell(key, CellValue::Number(42.0)); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = ClearCellCommand { + key: ctx.cell_key().clone().unwrap(), + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); + } + + #[test] + fn yank_cell_produces_set_yanked() { + let mut m = two_cat_model(); + let key = CellKey::new(vec![ + ("Type".to_string(), "Food".to_string()), + ("Month".to_string(), "Jan".to_string()), + ]); + m.set_cell(key, CellValue::Number(99.0)); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = YankCell { + key: ctx.cell_key().clone().unwrap(), + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); + } + + #[test] + fn paste_with_yanked_value_produces_set_cell() { + let mut m = two_cat_model(); + m.set_cell( + CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]), + CellValue::Number(42.0), + ); + let layout = make_layout(&m); + let reg = make_registry(); + let yanked = Some(CellValue::Number(99.0)); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.yanked = &yanked; + let key = CellKey::new(vec![ + ("Type".into(), "Clothing".into()), + ("Month".into(), "Feb".into()), + ]); + let cmd = PasteCell { key }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = effects_debug(&effects); + assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}"); + } + + #[test] + fn paste_without_yanked_value_produces_nothing() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let key = CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]); + let cmd = PasteCell { key }; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn transpose_produces_transpose_and_dirty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = TransposeAxes.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("TransposeAxes"), + "Expected TransposeAxes, got: {dbg}" + ); + } + + #[test] + fn save_produces_save_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = SaveCmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); + } +} + // ── Cell operations ────────────────────────────────────────────────────────── // All cell commands take an explicit CellKey. The interactive spec fills it // from ctx.cell_key(); the parser fills it from Cat/Item coordinate args. diff --git a/src/command/cmd/commit.rs b/src/command/cmd/commit.rs index 471f8ae..5783761 100644 --- a/src/command/cmd/commit.rs +++ b/src/command/cmd/commit.rs @@ -5,6 +5,133 @@ use crate::ui::effect::{self, Effect}; use super::core::{Cmd, CmdContext}; use super::navigation::{viewport_effects, CursorState, EnterAdvance}; +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::command::cmd::test_helpers::*; + use crate::model::Model; + + #[test] + fn commit_formula_with_categories_adds_formula() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("formula".to_string(), "Profit = Revenue - Cost".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommitFormula.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("AddFormula"), + "Expected AddFormula, got: {dbg}" + ); + assert!( + dbg.contains("FormulaPanel"), + "Expected return to FormulaPanel, got: {dbg}" + ); + } + + #[test] + fn commit_formula_without_regular_categories_shows_status() { + let m = Model::new("Empty"); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("formula".to_string(), "X = Y + Z".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommitFormula.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + !dbg.contains("AddFormula"), + "Should not add formula when only virtual categories exist, got: {dbg}" + ); + assert!( + dbg.contains("Add at least one category first"), + "Expected status message, got: {dbg}" + ); + } + + #[test] + fn commit_category_add_with_name_produces_add_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("category".to_string(), "Region".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommitCategoryAdd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("AddCategory"), + "Expected AddCategory, got: {dbg}" + ); + } + + #[test] + fn commit_category_add_with_empty_buffer_returns_to_panel() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("category".to_string(), "".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommitCategoryAdd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("CategoryPanel"), + "Expected return to CategoryPanel, got: {dbg}" + ); + } + + #[test] + fn commit_item_add_with_name_produces_add_item() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("item".to_string(), "March".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + let item_add_mode = AppMode::item_add("Month".to_string()); + ctx.mode = &item_add_mode; + ctx.buffers = &bufs; + let effects = CommitItemAdd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!(dbg.contains("AddItem"), "Expected AddItem, got: {dbg}"); + } + + #[test] + fn commit_item_add_outside_item_add_mode_returns_empty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = CommitItemAdd.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn commit_export_produces_export_and_normal_mode() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("export".to_string(), "/tmp/test.csv".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommitExport.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = effects_debug(&effects); + assert!(dbg.contains("ExportCsv"), "Expected ExportCsv, got: {dbg}"); + assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); + } +} + // ── Commit commands (mode-specific buffer consumers) ──────────────────────── /// Commit a cell value: for synthetic records keys, stage in drill pending edits diff --git a/src/command/cmd/core.rs b/src/command/cmd/core.rs index 473fbc4..f80201a 100644 --- a/src/command/cmd/core.rs +++ b/src/command/cmd/core.rs @@ -275,3 +275,23 @@ pub(super) fn parse_axis(s: &str) -> Result { other => Err(format!("Unknown axis: {other}")), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_axis_recognizes_all_variants() { + assert!(parse_axis("row").is_ok()); + assert!(parse_axis("column").is_ok()); + assert!(parse_axis("col").is_ok()); + assert!(parse_axis("page").is_ok()); + assert!(parse_axis("none").is_ok()); + assert!(parse_axis("ROW").is_ok()); + } + + #[test] + fn parse_axis_rejects_unknown() { + assert!(parse_axis("diagonal").is_err()); + } +} diff --git a/src/command/cmd/effect_cmds.rs b/src/command/cmd/effect_cmds.rs index 14b41bc..e020a43 100644 --- a/src/command/cmd/effect_cmds.rs +++ b/src/command/cmd/effect_cmds.rs @@ -4,6 +4,84 @@ use crate::view::Axis; use super::core::{require_args, Cmd, CmdContext}; +#[cfg(test)] +mod tests { + use super::*; + use crate::command::cmd::test_helpers::*; + + #[test] + fn add_category_cmd_produces_add_category_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = AddCategoryCmd(vec!["Region".to_string()]); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("AddCategory"), + "Expected AddCategory, got: {dbg}" + ); + } + + #[test] + fn set_cell_cmd_parses_coords_correctly() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = SetCellCmd(vec![ + "42".to_string(), + "Type/Food".to_string(), + "Month/Jan".to_string(), + ]); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}"); + } + + #[test] + fn set_axis_cmd_recognizes_column_alias() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = SetAxisCmd(vec!["Type".to_string(), "col".to_string()]); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}"); + } + + #[test] + fn write_cmd_without_args_saves() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = WriteCmd(vec![]); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); + } + + #[test] + fn write_cmd_with_path_saves_as() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = WriteCmd(vec!["/tmp/out.improv".to_string()]); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("SaveAs"), "Expected SaveAs, got: {dbg}"); + } +} + // ── Parseable model-mutation commands ──────────────────────────────────────── // These are thin Cmd wrappers around effects, constructible from string args. // They share the same execution path as keymap-dispatched commands. diff --git a/src/command/cmd/grid.rs b/src/command/cmd/grid.rs index 4fc137b..2796c97 100644 --- a/src/command/cmd/grid.rs +++ b/src/command/cmd/grid.rs @@ -4,6 +4,115 @@ use crate::view::AxisEntry; use super::core::{Cmd, CmdContext}; +#[cfg(test)] +mod tests { + use super::*; + use crate::command::cmd::test_helpers::*; + + #[test] + fn toggle_group_under_cursor_returns_empty_without_groups() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = ToggleGroupAtCursor { is_row: true }; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn law_toggle_group_involution() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = ToggleGroupAtCursor { is_row: true }; + let first = effects_debug(&cmd.execute(&ctx)); + let second = effects_debug(&cmd.execute(&ctx)); + assert_eq!(first, second, "Toggle should be structurally consistent"); + } + + #[test] + fn view_forward_with_empty_stack_shows_status() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = ViewNavigate { forward: true }; + let effects = cmd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("No forward view"), + "Expected status message, got: {dbg}" + ); + } + + #[test] + fn view_back_with_empty_stack_shows_status() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = ViewNavigate { forward: false }; + let effects = cmd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("No previous view"), + "Expected status message, got: {dbg}" + ); + } + + #[test] + fn view_forward_with_stack_produces_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let fwd_stack = vec!["View 2".to_string()]; + let mut ctx = make_ctx(&m, &layout, ®); + ctx.view_forward_stack = &fwd_stack; + let cmd = ViewNavigate { forward: true }; + let effects = cmd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("ViewForward"), + "Expected ViewForward, got: {dbg}" + ); + } + + #[test] + fn view_back_with_stack_produces_apply_and_back() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let back_stack = vec!["Default".to_string()]; + let mut ctx = make_ctx(&m, &layout, ®); + ctx.view_back_stack = &back_stack; + let cmd = ViewNavigate { forward: false }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("ApplyAndClearDrill"), + "Expected ApplyAndClearDrill, got: {dbg}" + ); + assert!(dbg.contains("ViewBack"), "Expected ViewBack, got: {dbg}"); + } + + #[test] + fn toggle_prune_empty_produces_toggle_and_dirty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = TogglePruneEmpty.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("TogglePruneEmpty"), + "Expected TogglePruneEmpty, got: {dbg}" + ); + } +} + // ── Grid operations ───────────────────────────────────────────────────── /// Toggle the row or column group collapse under the cursor. diff --git a/src/command/cmd/mod.rs b/src/command/cmd/mod.rs index 34c2b76..8228ea1 100644 --- a/src/command/cmd/mod.rs +++ b/src/command/cmd/mod.rs @@ -16,4 +16,106 @@ pub use self::core::{Cmd, CmdContext, CmdRegistry}; pub use registry::default_registry; #[cfg(test)] -mod tests; +pub(super) mod test_helpers { + use std::collections::HashMap; + + use crossterm::event::KeyCode; + + use crate::model::Model; + use crate::ui::app::AppMode; + use crate::ui::effect::Effect; + use crate::view::GridLayout; + + use super::core::CmdContext; + use super::registry::default_registry; + + pub type CmdRegistry = super::core::CmdRegistry; + + pub static EMPTY_BUFFERS: std::sync::LazyLock> = + std::sync::LazyLock::new(HashMap::new); + pub static EMPTY_EXPANDED: std::sync::LazyLock> = + std::sync::LazyLock::new(std::collections::HashSet::new); + + pub fn make_layout(model: &Model) -> GridLayout { + GridLayout::new(model, model.active_view()) + } + + pub fn make_ctx<'a>( + model: &'a Model, + layout: &'a GridLayout, + registry: &'a CmdRegistry, + ) -> CmdContext<'a> { + let view = model.active_view(); + let (sr, sc) = view.selected; + CmdContext { + model, + layout, + registry, + mode: &AppMode::Normal, + selected: view.selected, + row_offset: view.row_offset, + col_offset: view.col_offset, + search_query: "", + yanked: &None, + dirty: false, + search_mode: false, + formula_panel_open: false, + category_panel_open: false, + view_panel_open: false, + formula_cursor: 0, + cat_panel_cursor: 0, + view_panel_cursor: 0, + tile_cat_idx: 0, + buffers: &EMPTY_BUFFERS, + view_back_stack: &[], + view_forward_stack: &[], + display_value: { + let key = layout.cell_key(sr, sc); + key.as_ref() + .and_then(|k| model.get_cell(k).cloned()) + .map(|v| v.to_string()) + .unwrap_or_default() + }, + visible_rows: 20, + visible_cols: 8, + expanded_cats: &EMPTY_EXPANDED, + key_code: KeyCode::Null, + } + } + + pub fn two_cat_model() -> Model { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.add_category("Month").unwrap(); + m.category_mut("Type").unwrap().add_item("Food"); + m.category_mut("Type").unwrap().add_item("Clothing"); + m.category_mut("Month").unwrap().add_item("Jan"); + m.category_mut("Month").unwrap().add_item("Feb"); + m + } + + pub fn three_cat_model_with_page() -> Model { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.add_category("Month").unwrap(); + m.add_category("Region").unwrap(); + m.category_mut("Type").unwrap().add_item("Food"); + m.category_mut("Type").unwrap().add_item("Clothing"); + m.category_mut("Month").unwrap().add_item("Jan"); + m.category_mut("Month").unwrap().add_item("Feb"); + m.category_mut("Region").unwrap().add_item("North"); + m.category_mut("Region").unwrap().add_item("South"); + m.category_mut("Region").unwrap().add_item("East"); + let view = m.active_view_mut(); + view.set_axis("Region", crate::view::Axis::Page); + m + } + + pub fn effects_debug(effects: &[Box]) -> String { + format!("{:?}", effects) + } + + pub fn make_registry() -> CmdRegistry { + default_registry() + } +} diff --git a/src/command/cmd/mode.rs b/src/command/cmd/mode.rs index cda2877..db269ad 100644 --- a/src/command/cmd/mode.rs +++ b/src/command/cmd/mode.rs @@ -4,6 +4,125 @@ use crate::ui::effect::{self, Effect}; use super::core::{Cmd, CmdContext}; use super::grid::DrillIntoCell; +#[cfg(test)] +mod tests { + use super::*; + use crate::command::cmd::test_helpers::*; + use crate::model::Model; + + #[test] + fn enter_edit_mode_produces_editing_mode() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = EnterEditMode { + initial_value: String::new(), + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = format!("{:?}", effects[1]); + assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); + } + + #[test] + fn enter_tile_select_with_categories() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = EnterTileSelect; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = format!("{:?}", effects[1]); + assert!( + dbg.contains("TileSelect"), + "Expected TileSelect mode, got: {dbg}" + ); + } + + #[test] + fn enter_tile_select_no_categories() { + let m = Model::new("Empty"); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = EnterTileSelect; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); + } + + #[test] + fn enter_export_prompt_sets_mode() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = EnterExportPrompt.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("ExportPrompt"), + "Expected ExportPrompt mode, got: {dbg}" + ); + } + + #[test] + fn force_quit_always_produces_quit_mode() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.dirty = true; + let effects = ForceQuit.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Quit"), "Expected Quit mode, got: {dbg}"); + } + + #[test] + fn save_and_quit_produces_save_then_quit() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = SaveAndQuit.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); + assert!(dbg.contains("Quit"), "Expected Quit, got: {dbg}"); + } + + #[test] + fn edit_or_drill_without_aggregation_enters_edit() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = EditOrDrill.execute(&ctx); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); + } + + #[test] + fn enter_search_mode_sets_flag_and_clears_query() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = EnterSearchMode.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetSearchMode(true)"), + "Expected search mode on, got: {dbg}" + ); + assert!( + dbg.contains("SetSearchQuery"), + "Expected query reset, got: {dbg}" + ); + } +} + // ── Mode change commands ───────────────────────────────────────────────────── #[derive(Debug)] diff --git a/src/command/cmd/navigation.rs b/src/command/cmd/navigation.rs index 16a45a0..e007b14 100644 --- a/src/command/cmd/navigation.rs +++ b/src/command/cmd/navigation.rs @@ -243,8 +243,204 @@ impl Cmd for PagePrev { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::command::cmd::test_helpers::*; + + #[test] + fn move_selection_down_produces_set_selected() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = Move { + kind: MoveKind::Relative(1, 0), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "move-selection", + }; + let effects = cmd.execute(&ctx); + assert!(!effects.is_empty()); + } + + #[test] + fn move_selection_clamps_to_bounds() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = Move { + kind: MoveKind::Relative(100, 100), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "move-selection", + }; + let effects = cmd.execute(&ctx); + assert!(!effects.is_empty()); + } + + #[test] + fn enter_advance_moves_down() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = EnterAdvance { + cursor: CursorState::from_ctx(&ctx), + }; + let effects = cmd.execute(&ctx); + assert!(!effects.is_empty()); + let dbg = format!("{:?}", effects[0]); + assert!( + dbg.contains("SetSelected(1, 0)"), + "Expected row 1, got: {dbg}" + ); + } + + #[test] + fn law_move_to_start_idempotent() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = Move { + kind: MoveKind::ToStart(true), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "jump-first-row", + }; + let first = effects_debug(&cmd.execute(&ctx)); + let cmd2 = Move { + kind: MoveKind::ToStart(true), + cursor: CursorState { + row: 0, + ..CursorState::from_ctx(&ctx) + }, + cmd_name: "jump-first-row", + }; + let second = effects_debug(&cmd2.execute(&ctx)); + assert_eq!(first, second, "ToStart(Row) should be idempotent"); + } + + #[test] + fn law_sequence_associativity() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + + let mk_a = || { + Move { + kind: MoveKind::Relative(1, 0), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "move-selection", + } + .execute(&ctx) + }; + let mk_b = || { + Move { + kind: MoveKind::Relative(0, 1), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "move-selection", + } + .execute(&ctx) + }; + let mk_c = || { + Move { + kind: MoveKind::ToStart(true), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "jump-first-row", + } + .execute(&ctx) + }; + + let mut ab_c = mk_a(); + ab_c.extend(mk_b()); + ab_c.extend(mk_c()); + + let mut bc = mk_b(); + bc.extend(mk_c()); + let mut a_bc = mk_a(); + a_bc.extend(bc); + + assert_eq!( + effects_debug(&ab_c), + effects_debug(&a_bc), + "Sequence concatenation should be associative" + ); + } + + #[test] + fn law_move_to_end_reaches_last_col() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = Move { + kind: MoveKind::ToEnd(false), + cursor: CursorState::from_ctx(&ctx), + cmd_name: "jump-last-col", + }; + let effects = cmd.execute(&ctx); + let dbg = effects_debug(&effects); + let expected_col = ctx.col_count().saturating_sub(1); + assert!( + dbg.contains(&format!("SetSelected(0, {expected_col})")), + "Expected jump to last col {expected_col}, got: {dbg}" + ); + } + + #[test] + fn page_next_with_no_page_cats_returns_empty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = PageNext.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn page_prev_with_no_page_cats_returns_empty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = PagePrev.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn page_next_cycles_through_page_items() { + let m = three_cat_model_with_page(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = PageNext.execute(&ctx); + assert!(!effects.is_empty()); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetPageSelection"), + "Expected SetPageSelection, got: {dbg}" + ); + } + + #[test] + fn page_prev_cycles_backward() { + let m = three_cat_model_with_page(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = PagePrev.execute(&ctx); + assert!(!effects.is_empty()); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetPageSelection"), + "Expected SetPageSelection, got: {dbg}" + ); + } +} + /// Gather (cat_name, items, current_idx) for page-axis categories. -fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec, usize)> { +pub(super) fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec, usize)> { let view = ctx.model.active_view(); let page_cats: Vec = view .categories_on(Axis::Page) diff --git a/src/command/cmd/panel.rs b/src/command/cmd/panel.rs index c032502..82300b7 100644 --- a/src/command/cmd/panel.rs +++ b/src/command/cmd/panel.rs @@ -3,6 +3,246 @@ use crate::ui::effect::{self, Effect, Panel}; use super::core::{Cmd, CmdContext}; +#[cfg(test)] +mod tests { + use super::*; + use crate::command::cmd::test_helpers::*; + use crate::ui::effect; + + #[test] + fn toggle_panel_open_and_focus() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = TogglePanelAndFocus { + panel: effect::Panel::Formula, + open: true, + focused: true, + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = format!("{:?}", effects[1]); + assert!( + dbg.contains("FormulaPanel"), + "Expected FormulaPanel mode, got: {dbg}" + ); + } + + #[test] + fn toggle_panel_close_and_unfocus() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = TogglePanelAndFocus { + panel: effect::Panel::Formula, + open: false, + focused: false, + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); + } + + #[test] + fn cycle_panel_focus_with_no_panels_open() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = CyclePanelFocus { + formula_open: false, + category_open: false, + view_open: false, + }; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn cycle_panel_focus_with_formula_panel_open() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.formula_panel_open = true; + let cmd = CyclePanelFocus { + formula_open: true, + category_open: false, + view_open: false, + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = format!("{:?}", effects[0]); + assert!( + dbg.contains("FormulaPanel"), + "Expected FormulaPanel, got: {dbg}" + ); + } + + #[test] + fn cycle_panel_focus_with_multiple_panels() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.formula_panel_open = true; + ctx.category_panel_open = true; + let cmd = CyclePanelFocus { + formula_open: true, + category_open: true, + view_open: false, + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("FormulaPanel") || dbg.contains("CategoryPanel"), + "Expected panel focus, got: {dbg}" + ); + } + + #[test] + fn move_panel_cursor_down_from_zero() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = MovePanelCursor { + panel: effect::Panel::Formula, + delta: 1, + current: 0, + max: 5, + }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetPanelCursor"), + "Expected SetPanelCursor, got: {dbg}" + ); + } + + #[test] + fn move_panel_cursor_clamps_at_zero() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = MovePanelCursor { + panel: effect::Panel::Formula, + delta: -1, + current: 0, + max: 5, + }; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn move_panel_cursor_with_zero_max_produces_nothing() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = MovePanelCursor { + panel: effect::Panel::Formula, + delta: 1, + current: 0, + max: 0, + }; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn delete_formula_at_cursor_with_formulas() { + let mut m = two_cat_model(); + m.add_formula(crate::formula::ast::Formula { + raw: "Profit = Revenue - Cost".to_string(), + target: "Profit".to_string(), + target_category: "Type".to_string(), + expr: crate::formula::ast::Expr::Number(0.0), + filter: None, + }); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = DeleteFormulaAtCursor.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("RemoveFormula"), + "Expected RemoveFormula, got: {dbg}" + ); + } + + #[test] + fn switch_view_at_cursor_with_valid_cursor() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = SwitchViewAtCursor.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SwitchView"), + "Expected SwitchView, got: {dbg}" + ); + assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); + } + + #[test] + fn switch_view_at_cursor_out_of_bounds_returns_empty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.view_panel_cursor = 999; + let effects = SwitchViewAtCursor.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn create_and_switch_view_names_incrementally() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = CreateAndSwitchView.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("CreateView"), + "Expected CreateView, got: {dbg}" + ); + assert!( + dbg.contains("SwitchView"), + "Expected SwitchView, got: {dbg}" + ); + assert!( + dbg.contains("Normal"), + "Expected return to Normal, got: {dbg}" + ); + } + + #[test] + fn delete_view_at_cursor_zero_does_not_adjust_cursor() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = DeleteViewAtCursor.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("DeleteView"), + "Expected DeleteView, got: {dbg}" + ); + assert!( + !dbg.contains("SetPanelCursor"), + "Expected no cursor adjustment at position 0, got: {dbg}" + ); + } +} + // ── Panel commands ────────────────────────────────────────────────────── /// Toggle a panel's visibility; if it opens, focus it (enter its mode). diff --git a/src/command/cmd/search.rs b/src/command/cmd/search.rs index fa4bf6b..345bd36 100644 --- a/src/command/cmd/search.rs +++ b/src/command/cmd/search.rs @@ -4,6 +4,87 @@ use crate::ui::effect::{self, Effect, Panel}; use super::core::{Cmd, CmdContext}; +#[cfg(test)] +mod tests { + use super::*; + use crate::command::cmd::test_helpers::*; + use crate::model::cell::{CellKey, CellValue}; + + #[test] + fn search_navigate_with_empty_query_returns_nothing() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = SearchNavigate(true); + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn search_or_category_add_without_query_opens_category_add() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = SearchOrCategoryAdd; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = format!("{:?}", effects[1]); + assert!( + dbg.contains("CategoryAdd"), + "Expected CategoryAdd, got: {dbg}" + ); + } + + #[test] + fn exit_search_mode_clears_flag() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = ExitSearchMode.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetSearchMode(false)"), + "Expected search mode off, got: {dbg}" + ); + } + + #[test] + fn search_navigate_forward_with_matching_value() { + let mut m = two_cat_model(); + m.set_cell( + CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]), + CellValue::Number(42.0), + ); + m.set_cell( + CellKey::new(vec![ + ("Type".into(), "Clothing".into()), + ("Month".into(), "Feb".into()), + ]), + CellValue::Number(99.0), + ); + let layout = make_layout(&m); + let reg = make_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.search_query = "99"; + let cmd = SearchNavigate(true); + let effects = cmd.execute(&ctx); + if !effects.is_empty() { + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetSelected"), + "Expected SetSelected, got: {dbg}" + ); + } + } +} + /// Navigate to the next or previous search match. #[derive(Debug)] pub struct SearchNavigate(pub bool); diff --git a/src/command/cmd/tests.rs b/src/command/cmd/tests.rs deleted file mode 100644 index c71cf13..0000000 --- a/src/command/cmd/tests.rs +++ /dev/null @@ -1,1407 +0,0 @@ -use std::collections::HashMap; - -use crossterm::event::KeyCode; - -use super::cell::*; -use super::commit::*; -use super::core::*; -use super::effect_cmds::*; -use super::grid::*; -use super::mode::*; -use super::navigation::*; -use super::panel::*; -use super::registry::*; -use super::search::*; -use super::text_buffer::*; -use super::tile::*; -use crate::model::cell::{CellKey, CellValue}; -use crate::model::Model; -use crate::ui::app::AppMode; -use crate::ui::effect::{self, Effect}; -use crate::view::GridLayout; - -static EMPTY_BUFFERS: std::sync::LazyLock> = - std::sync::LazyLock::new(HashMap::new); -static EMPTY_EXPANDED: std::sync::LazyLock> = - std::sync::LazyLock::new(std::collections::HashSet::new); - -fn make_layout(model: &Model) -> GridLayout { - GridLayout::new(model, model.active_view()) -} - -fn make_ctx<'a>( - model: &'a Model, - layout: &'a GridLayout, - registry: &'a CmdRegistry, -) -> CmdContext<'a> { - let view = model.active_view(); - let (sr, sc) = view.selected; - CmdContext { - model, - layout, - registry, - mode: &AppMode::Normal, - selected: view.selected, - row_offset: view.row_offset, - col_offset: view.col_offset, - search_query: "", - yanked: &None, - dirty: false, - search_mode: false, - formula_panel_open: false, - category_panel_open: false, - view_panel_open: false, - formula_cursor: 0, - cat_panel_cursor: 0, - view_panel_cursor: 0, - tile_cat_idx: 0, - buffers: &EMPTY_BUFFERS, - view_back_stack: &[], - view_forward_stack: &[], - display_value: { - let key = layout.cell_key(sr, sc); - key.as_ref() - .and_then(|k| model.get_cell(k).cloned()) - .map(|v| v.to_string()) - .unwrap_or_default() - }, - visible_rows: 20, - visible_cols: 8, - expanded_cats: &EMPTY_EXPANDED, - key_code: KeyCode::Null, - } -} - -fn two_cat_model() -> Model { - let mut m = Model::new("Test"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Type").unwrap().add_item("Clothing"); - m.category_mut("Month").unwrap().add_item("Jan"); - m.category_mut("Month").unwrap().add_item("Feb"); - m -} - -#[test] -fn move_selection_down_produces_set_selected() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = Move { - kind: MoveKind::Relative(1, 0), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "move-selection", - }; - let effects = cmd.execute(&ctx); - // Should produce at least SetSelected - assert!(!effects.is_empty()); -} - -#[test] -fn move_selection_clamps_to_bounds() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - // Try to move way past the end - let cmd = Move { - kind: MoveKind::Relative(100, 100), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "move-selection", - }; - let effects = cmd.execute(&ctx); - assert!(!effects.is_empty()); -} - -#[test] -fn quit_when_dirty_shows_warning() { - let m = two_cat_model(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "q".to_string()); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.dirty = true; - ctx.buffers = &bufs; - let cmd = ExecuteCommand; - let effects = cmd.execute(&ctx); - let dbg = format!("{:?}", effects); - assert!(dbg.contains("SetStatus"), "Expected SetStatus, got: {dbg}"); -} - -#[test] -fn quit_when_clean_produces_quit_mode() { - let m = two_cat_model(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "q".to_string()); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let cmd = ExecuteCommand; - let effects = cmd.execute(&ctx); - assert!( - effects.iter().any(|e| e.changes_mode()), - "Expected a mode-changing effect" - ); -} - -#[test] -fn clear_selected_cell_produces_clear_and_dirty() { - let mut m = two_cat_model(); - let key = CellKey::new(vec![ - ("Type".to_string(), "Food".to_string()), - ("Month".to_string(), "Jan".to_string()), - ]); - m.set_cell(key, CellValue::Number(42.0)); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = ClearCellCommand { - key: ctx.cell_key().clone().unwrap(), - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // ClearCell + MarkDirty -} - -#[test] -fn yank_cell_produces_set_yanked() { - let mut m = two_cat_model(); - let key = CellKey::new(vec![ - ("Type".to_string(), "Food".to_string()), - ("Month".to_string(), "Jan".to_string()), - ]); - m.set_cell(key, CellValue::Number(99.0)); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = YankCell { - key: ctx.cell_key().clone().unwrap(), - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetYanked + SetStatus -} - -#[test] -fn toggle_panel_open_and_focus() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = TogglePanelAndFocus { - panel: effect::Panel::Formula, - open: true, - focused: true, - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode - let dbg = format!("{:?}", effects[1]); - assert!( - dbg.contains("FormulaPanel"), - "Expected FormulaPanel mode, got: {dbg}" - ); -} - -#[test] -fn toggle_panel_close_and_unfocus() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = TogglePanelAndFocus { - panel: effect::Panel::Formula, - open: false, - focused: false, - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetPanelOpen(false) + ChangeMode(Normal) -} - -#[test] -fn enter_advance_moves_down() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = EnterAdvance { - cursor: CursorState::from_ctx(&ctx), - }; - let effects = cmd.execute(&ctx); - assert!(!effects.is_empty()); - let dbg = format!("{:?}", effects[0]); - assert!( - dbg.contains("SetSelected(1, 0)"), - "Expected row 1, got: {dbg}" - ); -} - -#[test] -fn search_navigate_with_empty_query_returns_nothing() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = SearchNavigate(true); - let effects = cmd.execute(&ctx); - assert!(effects.is_empty()); -} - -#[test] -fn enter_edit_mode_produces_editing_mode() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = EnterEditMode { - initial_value: String::new(), - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetBuffer + ChangeMode - let dbg = format!("{:?}", effects[1]); - assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); -} - -#[test] -fn enter_tile_select_with_categories() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = EnterTileSelect; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode - let dbg = format!("{:?}", effects[1]); - assert!( - dbg.contains("TileSelect"), - "Expected TileSelect mode, got: {dbg}" - ); -} - -#[test] -fn enter_tile_select_no_categories() { - // Models always have virtual categories (_Index, _Dim), so tile - // select always has something to operate on. - let m = Model::new("Empty"); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = EnterTileSelect; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode -} - -#[test] -fn toggle_group_under_cursor_returns_empty_without_groups() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = ToggleGroupAtCursor { is_row: true }; - let effects = cmd.execute(&ctx); - // No groups defined, so nothing to toggle - assert!(effects.is_empty()); -} - -#[test] -fn search_or_category_add_without_query_opens_category_add() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = SearchOrCategoryAdd; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode - let dbg = format!("{:?}", effects[1]); - assert!( - dbg.contains("CategoryAdd"), - "Expected CategoryAdd, got: {dbg}" - ); -} - -#[test] -fn cycle_panel_focus_with_no_panels_open() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = CyclePanelFocus { - formula_open: false, - category_open: false, - view_open: false, - }; - let effects = cmd.execute(&ctx); - assert!(effects.is_empty()); -} - -#[test] -fn cycle_panel_focus_with_formula_panel_open() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.formula_panel_open = true; - let cmd = CyclePanelFocus { - formula_open: true, - category_open: false, - view_open: false, - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = format!("{:?}", effects[0]); - assert!( - dbg.contains("FormulaPanel"), - "Expected FormulaPanel, got: {dbg}" - ); -} - -// ── Algebraic law tests ────────────────────────────────────────────── - -fn effects_debug(effects: &[Box]) -> String { - format!("{:?}", effects) -} - -/// Law: navigation idempotence — ToStart applied twice produces the same -/// effects as applied once. -#[test] -fn law_move_to_start_idempotent() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = Move { - kind: MoveKind::ToStart(true), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "jump-first-row", - }; - let first = effects_debug(&cmd.execute(&ctx)); - // After the first move, cursor is at row 0. Simulate that state. - let cmd2 = Move { - kind: MoveKind::ToStart(true), - cursor: CursorState { - row: 0, - ..CursorState::from_ctx(&ctx) - }, - cmd_name: "jump-first-row", - }; - let second = effects_debug(&cmd2.execute(&ctx)); - assert_eq!(first, second, "ToStart(Row) should be idempotent"); -} - -/// Law: toggle involution — toggling a group twice yields the same effects -/// (both are ToggleGroup + MarkDirty, regardless of current state). -#[test] -fn law_toggle_group_involution() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = ToggleGroupAtCursor { is_row: true }; - let first = effects_debug(&cmd.execute(&ctx)); - let second = effects_debug(&cmd.execute(&ctx)); - // Both calls produce the same structural effects (the group lookup - // returns None in both cases since there are no groups, so both are - // empty — which is still an involution). - assert_eq!(first, second, "Toggle should be structurally consistent"); -} - -/// Law: sequence associativity — concatenating effect vectors is associative. -/// This is structural (Vec::extend is associative), but we verify it. -#[test] -fn law_sequence_associativity() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - - let mk_a = || { - Move { - kind: MoveKind::Relative(1, 0), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "move-selection", - } - .execute(&ctx) - }; - let mk_b = || { - Move { - kind: MoveKind::Relative(0, 1), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "move-selection", - } - .execute(&ctx) - }; - let mk_c = || { - Move { - kind: MoveKind::ToStart(true), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "jump-first-row", - } - .execute(&ctx) - }; - - // (a ++ b) ++ c - let mut ab_c = mk_a(); - ab_c.extend(mk_b()); - ab_c.extend(mk_c()); - - // a ++ (b ++ c) - let mut bc = mk_b(); - bc.extend(mk_c()); - let mut a_bc = mk_a(); - a_bc.extend(bc); - - assert_eq!( - effects_debug(&ab_c), - effects_debug(&a_bc), - "Sequence concatenation should be associative" - ); -} - -/// Law: MoveKind::ToEnd(col) reaches the last column. -#[test] -fn law_move_to_end_reaches_last_col() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = Move { - kind: MoveKind::ToEnd(false), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "jump-last-col", - }; - let effects = cmd.execute(&ctx); - let dbg = effects_debug(&effects); - let expected_col = ctx.col_count().saturating_sub(1); - assert!( - dbg.contains(&format!("SetSelected(0, {expected_col})")), - "Expected jump to last col {expected_col}, got: {dbg}" - ); -} - -// ── Paste command ────────────────────────────────────────────────── - -#[test] -fn paste_with_yanked_value_produces_set_cell() { - let mut m = two_cat_model(); - m.set_cell( - CellKey::new(vec![ - ("Type".into(), "Food".into()), - ("Month".into(), "Jan".into()), - ]), - CellValue::Number(42.0), - ); - let layout = make_layout(&m); - let reg = default_registry(); - let yanked = Some(CellValue::Number(99.0)); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.yanked = &yanked; - let key = CellKey::new(vec![ - ("Type".into(), "Clothing".into()), - ("Month".into(), "Feb".into()), - ]); - let cmd = PasteCell { key }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // SetCell + MarkDirty - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}"); -} - -#[test] -fn paste_without_yanked_value_produces_nothing() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let key = CellKey::new(vec![ - ("Type".into(), "Food".into()), - ("Month".into(), "Jan".into()), - ]); - let cmd = PasteCell { key }; - let effects = cmd.execute(&ctx); - assert!(effects.is_empty()); -} - -// ── Transpose ────────────────────────────────────────────────────── - -#[test] -fn transpose_produces_transpose_and_dirty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = TransposeAxes.execute(&ctx); - assert_eq!(effects.len(), 2); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("TransposeAxes"), - "Expected TransposeAxes, got: {dbg}" - ); -} - -// ── View navigation ──────────────────────────────────────────────── - -#[test] -fn view_forward_with_empty_stack_shows_status() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = ViewNavigate { forward: true }; - let effects = cmd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("No forward view"), - "Expected status message, got: {dbg}" - ); -} - -#[test] -fn view_back_with_empty_stack_shows_status() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = ViewNavigate { forward: false }; - let effects = cmd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("No previous view"), - "Expected status message, got: {dbg}" - ); -} - -#[test] -fn view_forward_with_stack_produces_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let fwd_stack = vec!["View 2".to_string()]; - let mut ctx = make_ctx(&m, &layout, ®); - ctx.view_forward_stack = &fwd_stack; - let cmd = ViewNavigate { forward: true }; - let effects = cmd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("ViewForward"), - "Expected ViewForward, got: {dbg}" - ); -} - -#[test] -fn view_back_with_stack_produces_apply_and_back() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let back_stack = vec!["Default".to_string()]; - let mut ctx = make_ctx(&m, &layout, ®); - ctx.view_back_stack = &back_stack; - let cmd = ViewNavigate { forward: false }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); // ApplyAndClearDrill + ViewBack - let dbg = effects_debug(&effects); - assert!( - dbg.contains("ApplyAndClearDrill"), - "Expected ApplyAndClearDrill, got: {dbg}" - ); - assert!(dbg.contains("ViewBack"), "Expected ViewBack, got: {dbg}"); -} - -// ── Panel cursor ─────────────────────────────────────────────────── - -#[test] -fn move_panel_cursor_down_from_zero() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = MovePanelCursor { - panel: effect::Panel::Formula, - delta: 1, - current: 0, - max: 5, - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetPanelCursor"), - "Expected SetPanelCursor, got: {dbg}" - ); -} - -#[test] -fn move_panel_cursor_clamps_at_zero() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = MovePanelCursor { - panel: effect::Panel::Formula, - delta: -1, - current: 0, - max: 5, - }; - let effects = cmd.execute(&ctx); - // Already at 0, can't go below -> no effect - assert!(effects.is_empty()); -} - -#[test] -fn move_panel_cursor_with_zero_max_produces_nothing() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = MovePanelCursor { - panel: effect::Panel::Formula, - delta: 1, - current: 0, - max: 0, - }; - let effects = cmd.execute(&ctx); - assert!(effects.is_empty()); -} - -// ── Page navigation ──────────────────────────────────────────────── - -#[test] -fn page_next_with_no_page_cats_returns_empty() { - // Default two_cat_model has Type on Row, Month on Column, nothing on Page - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = PageNext.execute(&ctx); - assert!(effects.is_empty()); -} - -#[test] -fn page_prev_with_no_page_cats_returns_empty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = PagePrev.execute(&ctx); - assert!(effects.is_empty()); -} - -fn three_cat_model_with_page() -> Model { - let mut m = Model::new("Test"); - m.add_category("Type").unwrap(); - m.add_category("Month").unwrap(); - m.add_category("Region").unwrap(); - m.category_mut("Type").unwrap().add_item("Food"); - m.category_mut("Type").unwrap().add_item("Clothing"); - m.category_mut("Month").unwrap().add_item("Jan"); - m.category_mut("Month").unwrap().add_item("Feb"); - m.category_mut("Region").unwrap().add_item("North"); - m.category_mut("Region").unwrap().add_item("South"); - m.category_mut("Region").unwrap().add_item("East"); - // Put Region on Page axis - let view = m.active_view_mut(); - view.set_axis("Region", crate::view::Axis::Page); - m -} - -#[test] -fn page_next_cycles_through_page_items() { - let m = three_cat_model_with_page(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = PageNext.execute(&ctx); - // Should produce SetPageSelection effects - assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetPageSelection"), - "Expected SetPageSelection, got: {dbg}" - ); -} - -#[test] -fn page_prev_cycles_backward() { - let m = three_cat_model_with_page(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = PagePrev.execute(&ctx); - assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetPageSelection"), - "Expected SetPageSelection, got: {dbg}" - ); -} - -// ── Tile axis commands ───────────────────────────────────────────── - -#[test] -fn tile_axis_cycle_produces_cycle_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = TileAxisOp { axis: None }; // cycle - let effects = cmd.execute(&ctx); - assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!(dbg.contains("CycleAxis"), "Expected CycleAxis, got: {dbg}"); -} - -#[test] -fn tile_axis_set_produces_set_axis_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = TileAxisOp { - axis: Some(crate::view::Axis::Page), - }; - let effects = cmd.execute(&ctx); - assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}"); -} - -#[test] -fn tile_axis_with_out_of_bounds_cursor_returns_empty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.tile_cat_idx = 999; // way beyond - let cmd = TileAxisOp { axis: None }; - let effects = cmd.execute(&ctx); - assert!(effects.is_empty()); -} - -// ── Move tile cursor ─────────────────────────────────────────────── - -#[test] -fn move_tile_cursor_right() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = MoveTileCursor(1); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetTileCatIdx(1)"), - "Expected idx 1, got: {dbg}" - ); -} - -#[test] -fn move_tile_cursor_clamps_at_start() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = MoveTileCursor(-1); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetTileCatIdx(0)"), - "Expected clamped to 0, got: {dbg}" - ); -} - -// ── Commit formula ───────────────────────────────────────────────── - -#[test] -fn commit_formula_with_categories_adds_formula() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("formula".to_string(), "Profit = Revenue - Cost".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommitFormula.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("AddFormula"), - "Expected AddFormula, got: {dbg}" - ); - assert!( - dbg.contains("FormulaPanel"), - "Expected return to FormulaPanel, got: {dbg}" - ); -} - -/// Regression: CommitFormula must not target virtual categories (_Index, _Dim) -/// when no regular categories exist. It should show "Add at least one category first." -#[test] -fn commit_formula_without_regular_categories_shows_status() { - let m = Model::new("Empty"); // only has virtual _Index, _Dim - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("formula".to_string(), "X = Y + Z".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommitFormula.execute(&ctx); - let dbg = effects_debug(&effects); - // Should NOT produce AddFormula targeting a virtual category - assert!( - !dbg.contains("AddFormula"), - "Should not add formula when only virtual categories exist, got: {dbg}" - ); - assert!( - dbg.contains("Add at least one category first"), - "Expected status message, got: {dbg}" - ); -} - -// ── Commit category add ──────────────────────────────────────────── - -#[test] -fn commit_category_add_with_name_produces_add_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("category".to_string(), "Region".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommitCategoryAdd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("AddCategory"), - "Expected AddCategory, got: {dbg}" - ); -} - -#[test] -fn commit_category_add_with_empty_buffer_returns_to_panel() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("category".to_string(), "".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommitCategoryAdd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("CategoryPanel"), - "Expected return to CategoryPanel, got: {dbg}" - ); -} - -// ── Commit item add ──────────────────────────────────────────────── - -#[test] -fn commit_item_add_with_name_produces_add_item() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("item".to_string(), "March".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - let item_add_mode = AppMode::item_add("Month".to_string()); - ctx.mode = &item_add_mode; - ctx.buffers = &bufs; - let effects = CommitItemAdd.execute(&ctx); - let dbg = effects_debug(&effects); - assert!(dbg.contains("AddItem"), "Expected AddItem, got: {dbg}"); -} - -#[test] -fn commit_item_add_outside_item_add_mode_returns_empty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - // mode is Normal, not ItemAdd - let effects = CommitItemAdd.execute(&ctx); - assert!(effects.is_empty()); -} - -// ── Command mode backspace ───────────────────────────────────────── - -#[test] -fn command_mode_backspace_pops_char() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "hel".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommandModeBackspace.execute(&ctx); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetBuffer"), "Expected SetBuffer, got: {dbg}"); - // The buffer should become "he" (popped last char) - assert!(dbg.contains("he"), "Expected 'he' after pop, got: {dbg}"); -} - -#[test] -fn command_mode_backspace_on_empty_returns_to_normal() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommandModeBackspace.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("Normal"), - "Expected return to Normal, got: {dbg}" - ); -} - -// ── Execute command ──────────────────────────────────────────────── - -#[test] -fn execute_command_empty_returns_to_normal() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = ExecuteCommand.execute(&ctx); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); -} - -#[test] -fn execute_command_invalid_shows_error_status() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "nonexistent-command".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = ExecuteCommand.execute(&ctx); - let dbg = effects_debug(&effects); - // Should show an error status AND return to Normal - assert!( - dbg.contains("Normal"), - "Expected Normal mode on error, got: {dbg}" - ); -} - -#[test] -fn execute_command_valid_runs_command() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("command".to_string(), "add-category Region".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = ExecuteCommand.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("AddCategory"), - "Expected AddCategory effect, got: {dbg}" - ); -} - -// ── Save command ─────────────────────────────────────────────────── - -#[test] -fn save_produces_save_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = SaveCmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); -} - -// ── Enter search mode ────────────────────────────────────────────── - -#[test] -fn enter_search_mode_sets_flag_and_clears_query() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = EnterSearchMode.execute(&ctx); - assert_eq!(effects.len(), 2); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetSearchMode(true)"), - "Expected search mode on, got: {dbg}" - ); - assert!( - dbg.contains("SetSearchQuery"), - "Expected query reset, got: {dbg}" - ); -} - -#[test] -fn exit_search_mode_clears_flag() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = ExitSearchMode.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetSearchMode(false)"), - "Expected search mode off, got: {dbg}" - ); -} - -// ── Search navigate with query finds match ───────────────────────── - -#[test] -fn search_navigate_forward_with_matching_value() { - let mut m = two_cat_model(); - m.set_cell( - CellKey::new(vec![ - ("Type".into(), "Food".into()), - ("Month".into(), "Jan".into()), - ]), - CellValue::Number(42.0), - ); - m.set_cell( - CellKey::new(vec![ - ("Type".into(), "Clothing".into()), - ("Month".into(), "Feb".into()), - ]), - CellValue::Number(99.0), - ); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.search_query = "99"; - let cmd = SearchNavigate(true); - let effects = cmd.execute(&ctx); - // Should find the cell with 99 and navigate to it - if !effects.is_empty() { - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetSelected"), - "Expected SetSelected, got: {dbg}" - ); - } - // If empty, the search didn't find it through layout — that's OK since - // layout coordinates may not map 1:1 with model cells in all cases. -} - -// ── Create and switch view ───────────────────────────────────────── - -#[test] -fn create_and_switch_view_names_incrementally() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = CreateAndSwitchView.execute(&ctx); - let dbg = effects_debug(&effects); - // Model starts with 1 view ("Default"), so new view should be "View 2" - assert!( - dbg.contains("CreateView"), - "Expected CreateView, got: {dbg}" - ); - assert!( - dbg.contains("SwitchView"), - "Expected SwitchView, got: {dbg}" - ); - assert!( - dbg.contains("Normal"), - "Expected return to Normal, got: {dbg}" - ); -} - -// ── Switch view at cursor ────────────────────────────────────────── - -#[test] -fn switch_view_at_cursor_with_valid_cursor() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = SwitchViewAtCursor.execute(&ctx); - // cursor 0, model has "Default" view - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SwitchView"), - "Expected SwitchView, got: {dbg}" - ); - assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); -} - -#[test] -fn switch_view_at_cursor_out_of_bounds_returns_empty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.view_panel_cursor = 999; - let effects = SwitchViewAtCursor.execute(&ctx); - assert!(effects.is_empty()); -} - -// ── Delete view at cursor ────────────────────────────────────────── - -#[test] -fn delete_view_at_cursor_zero_does_not_adjust_cursor() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = DeleteViewAtCursor.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("DeleteView"), - "Expected DeleteView, got: {dbg}" - ); - // At cursor 0, should NOT have SetPanelCursor (no cursor adjustment needed) - assert!( - !dbg.contains("SetPanelCursor"), - "Expected no cursor adjustment at position 0, got: {dbg}" - ); -} - -// ── Delete formula at cursor ─────────────────────────────────────── - -#[test] -fn delete_formula_at_cursor_with_formulas() { - let mut m = two_cat_model(); - m.add_formula(crate::formula::ast::Formula { - raw: "Profit = Revenue - Cost".to_string(), - target: "Profit".to_string(), - target_category: "Type".to_string(), - expr: crate::formula::ast::Expr::Number(0.0), - filter: None, - }); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = DeleteFormulaAtCursor.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("RemoveFormula"), - "Expected RemoveFormula, got: {dbg}" - ); -} - -// ── Commit export ────────────────────────────────────────────────── - -#[test] -fn commit_export_produces_export_and_normal_mode() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut bufs = HashMap::new(); - bufs.insert("export".to_string(), "/tmp/test.csv".to_string()); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.buffers = &bufs; - let effects = CommitExport.execute(&ctx); - assert_eq!(effects.len(), 2); - let dbg = effects_debug(&effects); - assert!(dbg.contains("ExportCsv"), "Expected ExportCsv, got: {dbg}"); - assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); -} - -// ── Force quit ───────────────────────────────────────────────────── - -#[test] -fn force_quit_always_produces_quit_mode() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.dirty = true; // even when dirty - let effects = ForceQuit.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Quit"), "Expected Quit mode, got: {dbg}"); -} - -// ── Save and quit ────────────────────────────────────────────────── - -#[test] -fn save_and_quit_produces_save_then_quit() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = SaveAndQuit.execute(&ctx); - assert_eq!(effects.len(), 2); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); - assert!(dbg.contains("Quit"), "Expected Quit, got: {dbg}"); -} - -// ── Enter export prompt ──────────────────────────────────────────── - -#[test] -fn enter_export_prompt_sets_mode() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = EnterExportPrompt.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("ExportPrompt"), - "Expected ExportPrompt mode, got: {dbg}" - ); -} - -// ── Toggle prune empty ───────────────────────────────────────────── - -#[test] -fn toggle_prune_empty_produces_toggle_and_dirty() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let effects = TogglePruneEmpty.execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("TogglePruneEmpty"), - "Expected TogglePruneEmpty, got: {dbg}" - ); -} - -// ── Edit or drill ────────────────────────────────────────────────── - -#[test] -fn edit_or_drill_without_aggregation_enters_edit() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - // All categories are on Row/Column, none on None -> no aggregation -> edit - let effects = EditOrDrill.execute(&ctx); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); -} - -// ── Cycle panel focus with multiple panels ───────────────────────── - -#[test] -fn cycle_panel_focus_with_multiple_panels() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let mut ctx = make_ctx(&m, &layout, ®); - ctx.formula_panel_open = true; - ctx.category_panel_open = true; - let cmd = CyclePanelFocus { - formula_open: true, - category_open: true, - view_open: false, - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - // Should focus the first open panel (Formula) - assert!( - dbg.contains("FormulaPanel") || dbg.contains("CategoryPanel"), - "Expected panel focus, got: {dbg}" - ); -} - -// ── effect_cmd! macro tests ──────────────────────────────────────── - -#[test] -fn add_category_cmd_produces_add_category_effect() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = AddCategoryCmd(vec!["Region".to_string()]); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("AddCategory"), - "Expected AddCategory, got: {dbg}" - ); -} - -#[test] -fn set_cell_cmd_parses_coords_correctly() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = SetCellCmd(vec![ - "42".to_string(), - "Type/Food".to_string(), - "Month/Jan".to_string(), - ]); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}"); -} - -#[test] -fn set_axis_cmd_recognizes_column_alias() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = SetAxisCmd(vec!["Type".to_string(), "col".to_string()]); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}"); -} - -#[test] -fn write_cmd_without_args_saves() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = WriteCmd(vec![]); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("Save"), "Expected Save, got: {dbg}"); -} - -#[test] -fn write_cmd_with_path_saves_as() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = default_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = WriteCmd(vec!["/tmp/out.improv".to_string()]); - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SaveAs"), "Expected SaveAs, got: {dbg}"); -} - -// ── parse_axis helper ────────────────────────────────────────────── - -#[test] -fn parse_axis_recognizes_all_variants() { - use super::core::parse_axis; - assert!(parse_axis("row").is_ok()); - assert!(parse_axis("column").is_ok()); - assert!(parse_axis("col").is_ok()); - assert!(parse_axis("page").is_ok()); - assert!(parse_axis("none").is_ok()); - assert!(parse_axis("ROW").is_ok()); // case insensitive -} - -#[test] -fn parse_axis_rejects_unknown() { - use super::core::parse_axis; - assert!(parse_axis("diagonal").is_err()); -} diff --git a/src/command/cmd/text_buffer.rs b/src/command/cmd/text_buffer.rs index 4390e26..b632531 100644 --- a/src/command/cmd/text_buffer.rs +++ b/src/command/cmd/text_buffer.rs @@ -5,6 +5,127 @@ use crate::ui::effect::{self, Effect}; use super::core::{read_buffer, Cmd, CmdContext}; +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::command::cmd::test_helpers::*; + + #[test] + fn command_mode_backspace_pops_char() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "hel".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommandModeBackspace.execute(&ctx); + let dbg = effects_debug(&effects); + assert!(dbg.contains("SetBuffer"), "Expected SetBuffer, got: {dbg}"); + assert!(dbg.contains("he"), "Expected 'he' after pop, got: {dbg}"); + } + + #[test] + fn command_mode_backspace_on_empty_returns_to_normal() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = CommandModeBackspace.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("Normal"), + "Expected return to Normal, got: {dbg}" + ); + } + + #[test] + fn quit_when_dirty_shows_warning() { + let m = two_cat_model(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "q".to_string()); + let layout = make_layout(&m); + let reg = make_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.dirty = true; + ctx.buffers = &bufs; + let cmd = ExecuteCommand; + let effects = cmd.execute(&ctx); + let dbg = format!("{:?}", effects); + assert!(dbg.contains("SetStatus"), "Expected SetStatus, got: {dbg}"); + } + + #[test] + fn quit_when_clean_produces_quit_mode() { + let m = two_cat_model(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "q".to_string()); + let layout = make_layout(&m); + let reg = make_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let cmd = ExecuteCommand; + let effects = cmd.execute(&ctx); + assert!( + effects.iter().any(|e| e.changes_mode()), + "Expected a mode-changing effect" + ); + } + + #[test] + fn execute_command_empty_returns_to_normal() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = ExecuteCommand.execute(&ctx); + let dbg = effects_debug(&effects); + assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); + } + + #[test] + fn execute_command_invalid_shows_error_status() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "nonexistent-command".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = ExecuteCommand.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("Normal"), + "Expected Normal mode on error, got: {dbg}" + ); + } + + #[test] + fn execute_command_valid_runs_command() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("command".to_string(), "add-category Region".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + let effects = ExecuteCommand.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("AddCategory"), + "Expected AddCategory effect, got: {dbg}" + ); + } +} + /// Append the pressed character to a named buffer. #[derive(Debug)] pub struct AppendChar { diff --git a/src/command/cmd/tile.rs b/src/command/cmd/tile.rs index 0879ed5..699992a 100644 --- a/src/command/cmd/tile.rs +++ b/src/command/cmd/tile.rs @@ -3,6 +3,84 @@ use crate::view::Axis; use super::core::{Cmd, CmdContext}; +#[cfg(test)] +mod tests { + use super::*; + use crate::command::cmd::test_helpers::*; + + #[test] + fn tile_axis_cycle_produces_cycle_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = TileAxisOp { axis: None }; + let effects = cmd.execute(&ctx); + assert!(!effects.is_empty()); + let dbg = effects_debug(&effects); + assert!(dbg.contains("CycleAxis"), "Expected CycleAxis, got: {dbg}"); + } + + #[test] + fn tile_axis_set_produces_set_axis_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = TileAxisOp { + axis: Some(crate::view::Axis::Page), + }; + let effects = cmd.execute(&ctx); + assert!(!effects.is_empty()); + let dbg = effects_debug(&effects); + assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}"); + } + + #[test] + fn tile_axis_with_out_of_bounds_cursor_returns_empty() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.tile_cat_idx = 999; + let cmd = TileAxisOp { axis: None }; + let effects = cmd.execute(&ctx); + assert!(effects.is_empty()); + } + + #[test] + fn move_tile_cursor_right() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = MoveTileCursor(1); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetTileCatIdx(1)"), + "Expected idx 1, got: {dbg}" + ); + } + + #[test] + fn move_tile_cursor_clamps_at_start() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let cmd = MoveTileCursor(-1); + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetTileCatIdx(0)"), + "Expected clamped to 0, got: {dbg}" + ); + } +} + // ── Tile select commands ──────────────────────────────────────────────────── /// Move the tile select cursor left or right.