diff --git a/src/command/cmd.rs b/src/command/cmd.rs index d85968f..47ca668 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -3370,4 +3370,835 @@ mod tests { "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}"); + } + + #[test] + fn commit_formula_without_categories_shows_status() { + // Create an empty model (only virtual categories) + let m = Model::new("Empty"); + 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); + // NOTE: Model::category_names() may include virtual categories (_Index, _Dim). + // If it does, this test needs to check the actual behavior rather than + // assuming "no categories" means "no virtual ones either". + // Let's just verify it returns effects with FormulaPanel mode. + assert!(dbg.contains("FormulaPanel"), "Expected return to FormulaPanel, 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() { + 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() { + assert!(parse_axis("diagonal").is_err()); + } }