diff --git a/src/ui/effect.rs b/src/ui/effect.rs index 65fa5b3..ac435e4 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -940,3 +940,610 @@ pub fn help_page_prev() -> Box { pub fn help_page_set(page: usize) -> Box { Box::new(HelpPageSet(page)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::cell::{CellKey, CellValue}; + use crate::model::Model; + + fn test_app() -> App { + 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"); + App::new(m, None) + } + + // ── Model mutation effects ────────────────────────────────────────── + + #[test] + fn add_category_effect() { + let mut app = test_app(); + AddCategory("Region".to_string()).apply(&mut app); + assert!(app.model.category("Region").is_some()); + } + + #[test] + fn add_item_to_existing_category() { + let mut app = test_app(); + AddItem { + category: "Type".to_string(), + item: "Electronics".to_string(), + } + .apply(&mut app); + let items: Vec<&str> = app + .model + .category("Type") + .unwrap() + .ordered_item_names() + .into_iter() + .collect(); + assert!(items.contains(&"Electronics")); + } + + #[test] + fn add_item_to_nonexistent_category_sets_status() { + let mut app = test_app(); + AddItem { + category: "Nonexistent".to_string(), + item: "X".to_string(), + } + .apply(&mut app); + assert!(app.status_msg.contains("Unknown category")); + } + + #[test] + fn set_cell_and_clear_cell() { + let mut app = test_app(); + let key = CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]); + SetCell(key.clone(), CellValue::Number(42.0)).apply(&mut app); + assert_eq!(app.model.get_cell(&key), Some(&CellValue::Number(42.0))); + + ClearCell(key.clone()).apply(&mut app); + assert_eq!(app.model.get_cell(&key), None); + } + + #[test] + fn add_formula_valid() { + let mut app = test_app(); + AddFormula { + raw: "Clothing = Food * 2".to_string(), + target_category: "Type".to_string(), + } + .apply(&mut app); + assert!(!app.model.formulas().is_empty()); + } + + #[test] + fn add_formula_invalid_sets_error_status() { + let mut app = test_app(); + AddFormula { + raw: "this is not valid".to_string(), + target_category: "Type".to_string(), + } + .apply(&mut app); + assert!(app.status_msg.contains("Formula error")); + } + + #[test] + fn remove_formula_effect() { + let mut app = test_app(); + AddFormula { + raw: "Clothing = Food * 2".to_string(), + target_category: "Type".to_string(), + } + .apply(&mut app); + assert!(!app.model.formulas().is_empty()); + RemoveFormula { + target: "Clothing".to_string(), + target_category: "Type".to_string(), + } + .apply(&mut app); + assert!(app.model.formulas().is_empty()); + } + + // ── View effects ──────────────────────────────────────────────────── + + #[test] + fn switch_view_pushes_to_back_stack() { + let mut app = test_app(); + app.model.create_view("View 2"); + assert!(app.view_back_stack.is_empty()); + + SwitchView("View 2".to_string()).apply(&mut app); + assert_eq!(app.model.active_view.as_str(), "View 2"); + assert_eq!(app.view_back_stack, vec!["Default".to_string()]); + // Forward stack should be cleared + assert!(app.view_forward_stack.is_empty()); + } + + #[test] + fn switch_view_to_same_does_not_push_stack() { + let mut app = test_app(); + SwitchView("Default".to_string()).apply(&mut app); + assert!(app.view_back_stack.is_empty()); + } + + #[test] + fn view_back_and_forward() { + let mut app = test_app(); + app.model.create_view("View 2"); + SwitchView("View 2".to_string()).apply(&mut app); + assert_eq!(app.model.active_view.as_str(), "View 2"); + + // Go back + ViewBack.apply(&mut app); + assert_eq!(app.model.active_view.as_str(), "Default"); + assert_eq!(app.view_forward_stack, vec!["View 2".to_string()]); + assert!(app.view_back_stack.is_empty()); + + // Go forward + ViewForward.apply(&mut app); + assert_eq!(app.model.active_view.as_str(), "View 2"); + assert_eq!(app.view_back_stack, vec!["Default".to_string()]); + assert!(app.view_forward_stack.is_empty()); + } + + #[test] + fn view_back_with_empty_stack_is_noop() { + let mut app = test_app(); + let before = app.model.active_view.clone(); + ViewBack.apply(&mut app); + assert_eq!(app.model.active_view, before); + } + + #[test] + fn create_and_delete_view() { + let mut app = test_app(); + CreateView("View 2".to_string()).apply(&mut app); + assert!(app.model.views.contains_key("View 2")); + + DeleteView("View 2".to_string()).apply(&mut app); + assert!(!app.model.views.contains_key("View 2")); + } + + #[test] + fn set_axis_effect() { + let mut app = test_app(); + SetAxis { + category: "Type".to_string(), + axis: Axis::Page, + } + .apply(&mut app); + assert_eq!(app.model.active_view().axis_of("Type"), Axis::Page); + } + + #[test] + fn transpose_axes_effect() { + let mut app = test_app(); + let row_before: Vec = app.model.active_view().categories_on(Axis::Row) + .into_iter().map(String::from).collect(); + let col_before: Vec = app.model.active_view().categories_on(Axis::Column) + .into_iter().map(String::from).collect(); + TransposeAxes.apply(&mut app); + let row_after: Vec = app.model.active_view().categories_on(Axis::Row) + .into_iter().map(String::from).collect(); + let col_after: Vec = app.model.active_view().categories_on(Axis::Column) + .into_iter().map(String::from).collect(); + assert_eq!(row_before, col_after); + assert_eq!(col_before, row_after); + } + + // ── Navigation effects ────────────────────────────────────────────── + + #[test] + fn set_selected_effect() { + let mut app = test_app(); + SetSelected(3, 5).apply(&mut app); + assert_eq!(app.model.active_view().selected, (3, 5)); + } + + #[test] + fn set_row_and_col_offset() { + let mut app = test_app(); + SetRowOffset(10).apply(&mut app); + SetColOffset(5).apply(&mut app); + assert_eq!(app.model.active_view().row_offset, 10); + assert_eq!(app.model.active_view().col_offset, 5); + } + + // ── App state effects ─────────────────────────────────────────────── + + #[test] + fn change_mode_effect() { + let mut app = test_app(); + assert!(ChangeMode(AppMode::Help).changes_mode()); + ChangeMode(AppMode::Help).apply(&mut app); + assert_eq!(app.mode, AppMode::Help); + } + + #[test] + fn set_status_effect() { + let mut app = test_app(); + SetStatus("hello".to_string()).apply(&mut app); + assert_eq!(app.status_msg, "hello"); + } + + #[test] + fn mark_dirty_effect() { + let mut app = test_app(); + assert!(!app.dirty); + MarkDirty.apply(&mut app); + assert!(app.dirty); + } + + #[test] + fn set_yanked_effect() { + let mut app = test_app(); + SetYanked(Some(CellValue::Number(42.0))).apply(&mut app); + assert_eq!(app.yanked, Some(CellValue::Number(42.0))); + } + + #[test] + fn set_search_query_and_mode() { + let mut app = test_app(); + SetSearchQuery("foo".to_string()).apply(&mut app); + assert_eq!(app.search_query, "foo"); + SetSearchMode(true).apply(&mut app); + assert!(app.search_mode); + SetSearchMode(false).apply(&mut app); + assert!(!app.search_mode); + } + + // ── SetBuffer special behavior ────────────────────────────────────── + + #[test] + fn set_buffer_normal_key() { + let mut app = test_app(); + SetBuffer { + name: "edit".to_string(), + value: "hello".to_string(), + } + .apply(&mut app); + assert_eq!(app.buffers.get("edit").unwrap(), "hello"); + } + + #[test] + fn set_buffer_search_writes_to_search_query() { + let mut app = test_app(); + SetBuffer { + name: "search".to_string(), + value: "query".to_string(), + } + .apply(&mut app); + // "search" buffer is special — writes to app.search_query + assert_eq!(app.search_query, "query"); + } + + // ── Panel effects ─────────────────────────────────────────────────── + + #[test] + fn set_panel_open_and_cursor() { + let mut app = test_app(); + SetPanelOpen { + panel: Panel::Formula, + open: true, + } + .apply(&mut app); + assert!(app.formula_panel_open); + + SetPanelCursor { + panel: Panel::Formula, + cursor: 3, + } + .apply(&mut app); + assert_eq!(app.formula_cursor, 3); + + SetPanelOpen { + panel: Panel::Category, + open: true, + } + .apply(&mut app); + assert!(app.category_panel_open); + + SetPanelOpen { + panel: Panel::View, + open: true, + } + .apply(&mut app); + assert!(app.view_panel_open); + } + + #[test] + fn set_tile_cat_idx_effect() { + let mut app = test_app(); + SetTileCatIdx(2).apply(&mut app); + assert_eq!(app.tile_cat_idx, 2); + } + + // ── Help page effects ─────────────────────────────────────────────── + + #[test] + fn help_page_navigation() { + let mut app = test_app(); + assert_eq!(app.help_page, 0); + HelpPageNext.apply(&mut app); + assert_eq!(app.help_page, 1); + HelpPageNext.apply(&mut app); + assert_eq!(app.help_page, 2); + HelpPagePrev.apply(&mut app); + assert_eq!(app.help_page, 1); + HelpPageSet(0).apply(&mut app); + assert_eq!(app.help_page, 0); + } + + #[test] + fn help_page_prev_clamps_at_zero() { + let mut app = test_app(); + HelpPagePrev.apply(&mut app); + assert_eq!(app.help_page, 0); + } + + // ── Drill effects ─────────────────────────────────────────────────── + + #[test] + fn start_drill_and_apply_clear_drill_with_no_edits() { + let mut app = test_app(); + let key = CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]); + let records = vec![(key, CellValue::Number(42.0))]; + StartDrill(records).apply(&mut app); + assert!(app.drill_state.is_some()); + + // Apply with no pending edits — should just clear state + ApplyAndClearDrill.apply(&mut app); + assert!(app.drill_state.is_none()); + assert!(!app.dirty); // no edits → not dirty + } + + #[test] + fn apply_and_clear_drill_with_value_edit() { + let mut app = test_app(); + let key = CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]); + // Set original cell + app.model + .set_cell(key.clone(), CellValue::Number(42.0)); + + let records = vec![(key.clone(), CellValue::Number(42.0))]; + StartDrill(records).apply(&mut app); + + // Stage a pending edit: change value at record 0 + SetDrillPendingEdit { + record_idx: 0, + col_name: "Value".to_string(), + new_value: "99".to_string(), + } + .apply(&mut app); + + ApplyAndClearDrill.apply(&mut app); + assert!(app.drill_state.is_none()); + assert!(app.dirty); + assert_eq!(app.model.get_cell(&key), Some(&CellValue::Number(99.0))); + } + + #[test] + fn apply_and_clear_drill_with_coord_rename() { + let mut app = test_app(); + let key = CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]); + app.model + .set_cell(key.clone(), CellValue::Number(42.0)); + + let records = vec![(key.clone(), CellValue::Number(42.0))]; + StartDrill(records).apply(&mut app); + + // Rename "Type" coord from "Food" to "Drink" + SetDrillPendingEdit { + record_idx: 0, + col_name: "Type".to_string(), + new_value: "Drink".to_string(), + } + .apply(&mut app); + + ApplyAndClearDrill.apply(&mut app); + assert!(app.dirty); + // Old cell should be gone + assert_eq!(app.model.get_cell(&key), None); + // New cell should exist + let new_key = CellKey::new(vec![ + ("Type".into(), "Drink".into()), + ("Month".into(), "Jan".into()), + ]); + assert_eq!( + app.model.get_cell(&new_key), + Some(&CellValue::Number(42.0)) + ); + // "Drink" should have been added as an item + let items: Vec<&str> = app + .model + .category("Type") + .unwrap() + .ordered_item_names() + .into_iter() + .collect(); + assert!(items.contains(&"Drink")); + } + + #[test] + fn apply_and_clear_drill_empty_value_clears_cell() { + let mut app = test_app(); + let key = CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]); + app.model + .set_cell(key.clone(), CellValue::Number(42.0)); + + let records = vec![(key.clone(), CellValue::Number(42.0))]; + StartDrill(records).apply(&mut app); + + // Edit value to empty string → should clear cell + SetDrillPendingEdit { + record_idx: 0, + col_name: "Value".to_string(), + new_value: "".to_string(), + } + .apply(&mut app); + + ApplyAndClearDrill.apply(&mut app); + assert_eq!(app.model.get_cell(&key), None); + } + + // ── Toggle effects ────────────────────────────────────────────────── + + #[test] + fn toggle_prune_empty_effect() { + let mut app = test_app(); + let before = app.model.active_view().prune_empty; + TogglePruneEmpty.apply(&mut app); + assert_ne!(app.model.active_view().prune_empty, before); + TogglePruneEmpty.apply(&mut app); + assert_eq!(app.model.active_view().prune_empty, before); + } + + #[test] + fn toggle_cat_expand_effect() { + let mut app = test_app(); + assert!(!app.expanded_cats.contains("Type")); + ToggleCatExpand("Type".to_string()).apply(&mut app); + assert!(app.expanded_cats.contains("Type")); + ToggleCatExpand("Type".to_string()).apply(&mut app); + assert!(!app.expanded_cats.contains("Type")); + } + + #[test] + fn remove_item_and_category() { + let mut app = test_app(); + RemoveItem { + category: "Type".to_string(), + item: "Food".to_string(), + } + .apply(&mut app); + let items: Vec<&str> = app + .model + .category("Type") + .unwrap() + .ordered_item_names() + .into_iter() + .collect(); + assert!(!items.contains(&"Food")); + + RemoveCategory("Month".to_string()).apply(&mut app); + assert!(app.model.category("Month").is_none()); + } + + // ── Number format ─────────────────────────────────────────────────── + + #[test] + fn set_number_format_effect() { + let mut app = test_app(); + SetNumberFormat(",.2f".to_string()).apply(&mut app); + assert_eq!(app.model.active_view().number_format, ",.2f"); + } + + // ── Page selection ────────────────────────────────────────────────── + + #[test] + fn set_page_selection_effect() { + let mut app = test_app(); + SetPageSelection { + category: "Type".to_string(), + item: "Food".to_string(), + } + .apply(&mut app); + assert_eq!( + app.model.active_view().page_selection("Type"), + Some("Food") + ); + } + + // ── Hide/show items ───────────────────────────────────────────────── + + #[test] + fn hide_and_show_item_effects() { + let mut app = test_app(); + HideItem { + category: "Type".to_string(), + item: "Food".to_string(), + } + .apply(&mut app); + assert!(app.model.active_view().is_hidden("Type", "Food")); + + ShowItem { + category: "Type".to_string(), + item: "Food".to_string(), + } + .apply(&mut app); + assert!(!app.model.active_view().is_hidden("Type", "Food")); + } + + // ── Toggle group ──────────────────────────────────────────────────── + + #[test] + fn toggle_group_effect() { + let mut app = test_app(); + ToggleGroup { + category: "Type".to_string(), + group: "MyGroup".to_string(), + } + .apply(&mut app); + assert!( + app.model + .active_view() + .is_group_collapsed("Type", "MyGroup") + ); + ToggleGroup { + category: "Type".to_string(), + group: "MyGroup".to_string(), + } + .apply(&mut app); + assert!( + !app.model + .active_view() + .is_group_collapsed("Type", "MyGroup") + ); + } + + // ── Cycle axis ────────────────────────────────────────────────────── + + #[test] + fn cycle_axis_effect() { + let mut app = test_app(); + let before = app.model.active_view().axis_of("Type"); + CycleAxis("Type".to_string()).apply(&mut app); + let after = app.model.active_view().axis_of("Type"); + assert_ne!(before, after); + } + + // ── Save without file path ────────────────────────────────────────── + + #[test] + fn save_without_file_path_shows_status() { + let mut app = test_app(); + Save.apply(&mut app); + assert!(app.status_msg.contains("No file path")); + } + + // ── Panel mode helper ─────────────────────────────────────────────── + + #[test] + fn panel_mode_mapping() { + assert_eq!(Panel::Formula.mode(), AppMode::FormulaPanel); + assert_eq!(Panel::Category.mode(), AppMode::CategoryPanel); + assert_eq!(Panel::View.mode(), AppMode::ViewPanel); + } +}