use std::fmt::Debug; use std::path::PathBuf; use crate::model::cell::{CellKey, CellValue}; use crate::view::Axis; use super::app::{App, AppMode}; /// A discrete state change produced by a command. /// Effects know how to apply themselves to the App. pub trait Effect: Debug { fn apply(&self, app: &mut App); /// Whether this effect changes the app mode. fn changes_mode(&self) -> bool { false } } // ── Model mutations ────────────────────────────────────────────────────────── #[derive(Debug)] pub struct AddCategory(pub String); impl Effect for AddCategory { fn apply(&self, app: &mut App) { let _ = app.model.add_category(&self.0); } } #[derive(Debug)] pub struct AddItem { pub category: String, pub item: String, } impl Effect for AddItem { fn apply(&self, app: &mut App) { if let Some(cat) = app.model.category_mut(&self.category) { cat.add_item(&self.item); } else { app.status_msg = format!("Unknown category '{}'", self.category); } } } #[derive(Debug)] pub struct AddItemInGroup { pub category: String, pub item: String, pub group: String, } impl Effect for AddItemInGroup { fn apply(&self, app: &mut App) { if let Some(cat) = app.model.category_mut(&self.category) { cat.add_item_in_group(&self.item, &self.group); } else { app.status_msg = format!("Unknown category '{}'", self.category); } } } #[derive(Debug)] pub struct SetCell(pub CellKey, pub CellValue); impl Effect for SetCell { fn apply(&self, app: &mut App) { app.model.set_cell(self.0.clone(), self.1.clone()); } } #[derive(Debug)] pub struct ClearCell(pub CellKey); impl Effect for ClearCell { fn apply(&self, app: &mut App) { app.model.clear_cell(&self.0); } } #[derive(Debug)] pub struct AddFormula { pub raw: String, pub target_category: String, } impl Effect for AddFormula { fn apply(&self, app: &mut App) { match crate::formula::parse_formula(&self.raw, &self.target_category) { Ok(formula) => { // Ensure the formula target exists as an item in the target category // so the grid layout includes cells for it. if let Some(cat) = app.model.category_mut(&formula.target_category) { cat.add_item(&formula.target); } app.model.add_formula(formula); } Err(e) => { app.status_msg = format!("Formula error: {e}"); } } } } #[derive(Debug)] pub struct RemoveFormula { pub target: String, pub target_category: String, } impl Effect for RemoveFormula { fn apply(&self, app: &mut App) { app.model .remove_formula(&self.target, &self.target_category); } } /// Re-enter edit mode by reading the cell value at the current cursor. /// Used after commit+advance to continue data entry. #[derive(Debug)] pub struct EnterEditAtCursor; impl Effect for EnterEditAtCursor { fn apply(&self, app: &mut App) { app.rebuild_layout(); let ctx = app.cmd_context( crossterm::event::KeyCode::Null, crossterm::event::KeyModifiers::NONE, ); let value = ctx.display_value.clone(); drop(ctx); app.buffers.insert("edit".to_string(), value); app.mode = AppMode::editing(); } } #[derive(Debug)] pub struct TogglePruneEmpty; impl Effect for TogglePruneEmpty { fn apply(&self, app: &mut App) { let v = app.model.active_view_mut(); v.prune_empty = !v.prune_empty; } } #[derive(Debug)] pub struct ToggleCatExpand(pub String); impl Effect for ToggleCatExpand { fn apply(&self, app: &mut App) { if !app.expanded_cats.remove(&self.0) { app.expanded_cats.insert(self.0.clone()); } } } #[derive(Debug)] pub struct RemoveItem { pub category: String, pub item: String, } impl Effect for RemoveItem { fn apply(&self, app: &mut App) { app.model.remove_item(&self.category, &self.item); } } #[derive(Debug)] pub struct RemoveCategory(pub String); impl Effect for RemoveCategory { fn apply(&self, app: &mut App) { app.model.remove_category(&self.0); } } // ── View mutations ─────────────────────────────────────────────────────────── #[derive(Debug)] pub struct CreateView(pub String); impl Effect for CreateView { fn apply(&self, app: &mut App) { app.model.create_view(&self.0); } } #[derive(Debug)] pub struct DeleteView(pub String); impl Effect for DeleteView { fn apply(&self, app: &mut App) { let _ = app.model.delete_view(&self.0); } } #[derive(Debug)] pub struct SwitchView(pub String); impl Effect for SwitchView { fn apply(&self, app: &mut App) { let current = app.model.active_view.clone(); if current != self.0 { app.view_back_stack.push(current); app.view_forward_stack.clear(); } let _ = app.model.switch_view(&self.0); } } /// Go back in view history (pop back stack, push current to forward stack). #[derive(Debug)] pub struct ViewBack; impl Effect for ViewBack { fn apply(&self, app: &mut App) { if let Some(prev) = app.view_back_stack.pop() { let current = app.model.active_view.clone(); app.view_forward_stack.push(current); let _ = app.model.switch_view(&prev); } } } /// Go forward in view history (pop forward stack, push current to back stack). #[derive(Debug)] pub struct ViewForward; impl Effect for ViewForward { fn apply(&self, app: &mut App) { if let Some(next) = app.view_forward_stack.pop() { let current = app.model.active_view.clone(); app.view_back_stack.push(current); let _ = app.model.switch_view(&next); } } } #[derive(Debug)] pub struct SetAxis { pub category: String, pub axis: Axis, } impl Effect for SetAxis { fn apply(&self, app: &mut App) { app.model .active_view_mut() .set_axis(&self.category, self.axis); } } #[derive(Debug)] pub struct SetPageSelection { pub category: String, pub item: String, } impl Effect for SetPageSelection { fn apply(&self, app: &mut App) { app.model .active_view_mut() .set_page_selection(&self.category, &self.item); } } #[derive(Debug)] pub struct ToggleGroup { pub category: String, pub group: String, } impl Effect for ToggleGroup { fn apply(&self, app: &mut App) { app.model .active_view_mut() .toggle_group_collapse(&self.category, &self.group); } } #[derive(Debug)] pub struct HideItem { pub category: String, pub item: String, } impl Effect for HideItem { fn apply(&self, app: &mut App) { app.model .active_view_mut() .hide_item(&self.category, &self.item); } } #[derive(Debug)] pub struct ShowItem { pub category: String, pub item: String, } impl Effect for ShowItem { fn apply(&self, app: &mut App) { app.model .active_view_mut() .show_item(&self.category, &self.item); } } #[derive(Debug)] pub struct TransposeAxes; impl Effect for TransposeAxes { fn apply(&self, app: &mut App) { app.model.active_view_mut().transpose_axes(); } } #[derive(Debug)] pub struct CycleAxis(pub String); impl Effect for CycleAxis { fn apply(&self, app: &mut App) { app.model.active_view_mut().cycle_axis(&self.0); } } #[derive(Debug)] pub struct SetNumberFormat(pub String); impl Effect for SetNumberFormat { fn apply(&self, app: &mut App) { app.model.active_view_mut().number_format = self.0.clone(); } } // ── Navigation ─────────────────────────────────────────────────────────────── #[derive(Debug)] pub struct SetSelected(pub usize, pub usize); impl Effect for SetSelected { fn apply(&self, app: &mut App) { app.model.active_view_mut().selected = (self.0, self.1); } } #[derive(Debug)] pub struct SetRowOffset(pub usize); impl Effect for SetRowOffset { fn apply(&self, app: &mut App) { app.model.active_view_mut().row_offset = self.0; } } #[derive(Debug)] pub struct SetColOffset(pub usize); impl Effect for SetColOffset { fn apply(&self, app: &mut App) { app.model.active_view_mut().col_offset = self.0; } } // ── App state ──────────────────────────────────────────────────────────────── #[derive(Debug)] pub struct ChangeMode(pub AppMode); impl Effect for ChangeMode { fn apply(&self, app: &mut App) { app.mode = self.0.clone(); } fn changes_mode(&self) -> bool { true } } #[derive(Debug)] pub struct SetStatus(pub String); impl Effect for SetStatus { fn apply(&self, app: &mut App) { app.status_msg = self.0.clone(); } } #[derive(Debug)] pub struct MarkDirty; impl Effect for MarkDirty { fn apply(&self, app: &mut App) { app.dirty = true; } } #[derive(Debug)] pub struct SetYanked(pub Option); impl Effect for SetYanked { fn apply(&self, app: &mut App) { app.yanked = self.0.clone(); } } #[derive(Debug)] pub struct SetSearchQuery(pub String); impl Effect for SetSearchQuery { fn apply(&self, app: &mut App) { app.search_query = self.0.clone(); } } #[derive(Debug)] pub struct SetSearchMode(pub bool); impl Effect for SetSearchMode { fn apply(&self, app: &mut App) { app.search_mode = self.0; } } /// Set a named buffer's contents. #[derive(Debug)] pub struct SetBuffer { pub name: String, pub value: String, } impl Effect for SetBuffer { fn apply(&self, app: &mut App) { // "search" is special — it writes to search_query for backward compat if self.name == "search" { app.search_query = self.value.clone(); } else { app.buffers.insert(self.name.clone(), self.value.clone()); } } } #[derive(Debug)] pub struct SetTileCatIdx(pub usize); impl Effect for SetTileCatIdx { fn apply(&self, app: &mut App) { app.tile_cat_idx = self.0; } } /// Populate the drill state with a frozen snapshot of records. /// Clears any previous drill state. #[derive(Debug)] pub struct StartDrill(pub Vec<(CellKey, CellValue)>); impl Effect for StartDrill { fn apply(&self, app: &mut App) { app.drill_state = Some(super::app::DrillState { records: std::rc::Rc::new(self.0.clone()), pending_edits: std::collections::HashMap::new(), }); } } /// Apply any pending edits to the model and clear the drill state. #[derive(Debug)] pub struct ApplyAndClearDrill; impl Effect for ApplyAndClearDrill { fn apply(&self, app: &mut App) { let Some(drill) = app.drill_state.take() else { return; }; if drill.pending_edits.is_empty() { return; } // For each pending edit, update the cell for ((record_idx, col_name), new_value) in &drill.pending_edits { let Some((orig_key, _)) = drill.records.get(*record_idx) else { continue; }; if col_name == "Value" { // Update the cell's value let value = if new_value.is_empty() { app.model.clear_cell(orig_key); continue; } else if let Ok(n) = new_value.parse::() { CellValue::Number(n) } else { CellValue::Text(new_value.clone()) }; app.model.set_cell(orig_key.clone(), value); } else { // Rename a coordinate: remove old cell, insert new with updated coord let value = match app.model.get_cell(orig_key) { Some(v) => v.clone(), None => continue, }; app.model.clear_cell(orig_key); // Build new key by replacing the coord let new_coords: Vec<(String, String)> = orig_key .0 .iter() .map(|(c, i)| { if c == col_name { (c.clone(), new_value.clone()) } else { (c.clone(), i.clone()) } }) .collect(); let new_key = CellKey::new(new_coords); // Ensure the new item exists in that category if let Some(cat) = app.model.category_mut(col_name) { cat.add_item(new_value.clone()); } app.model.set_cell(new_key, value); } } app.dirty = true; } } /// Stage a pending edit in the drill state. #[derive(Debug)] pub struct SetDrillPendingEdit { pub record_idx: usize, pub col_name: String, pub new_value: String, } impl Effect for SetDrillPendingEdit { fn apply(&self, app: &mut App) { if let Some(drill) = &mut app.drill_state { drill.pending_edits.insert( (self.record_idx, self.col_name.clone()), self.new_value.clone(), ); } } } // ── Side effects ───────────────────────────────────────────────────────────── #[derive(Debug)] pub struct Save; impl Effect for Save { fn apply(&self, app: &mut App) { if let Some(ref path) = app.file_path { match crate::persistence::save(&app.model, path) { Ok(()) => { app.dirty = false; app.status_msg = format!("Saved to {}", path.display()); } Err(e) => { app.status_msg = format!("Save error: {e}"); } } } else { app.status_msg = "No file path — use :w ".to_string(); } } } #[derive(Debug)] pub struct SaveAs(pub PathBuf); impl Effect for SaveAs { fn apply(&self, app: &mut App) { match crate::persistence::save(&app.model, &self.0) { Ok(()) => { app.file_path = Some(self.0.clone()); app.dirty = false; app.status_msg = format!("Saved to {}", self.0.display()); } Err(e) => { app.status_msg = format!("Save error: {e}"); } } } } /// Dispatch a key event to the import wizard. /// The wizard has its own internal state machine; this effect handles /// all wizard key interactions and App-level side effects. #[derive(Debug)] pub struct WizardKey { pub key_code: crossterm::event::KeyCode, } impl Effect for WizardKey { fn apply(&self, app: &mut App) { use crate::import::wizard::WizardStep; let Some(wizard) = &mut app.wizard else { return; }; match &wizard.step.clone() { WizardStep::Preview => match self.key_code { crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Char(' ') => { wizard.advance() } crossterm::event::KeyCode::Esc => { app.mode = AppMode::Normal; app.wizard = None; } _ => {} }, WizardStep::SelectArrayPath => match self.key_code { crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => { wizard.move_cursor(-1) } crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => { wizard.move_cursor(1) } crossterm::event::KeyCode::Enter => wizard.confirm_path(), crossterm::event::KeyCode::Esc => { app.mode = AppMode::Normal; app.wizard = None; } _ => {} }, WizardStep::ReviewProposals => match self.key_code { crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => { wizard.move_cursor(-1) } crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => { wizard.move_cursor(1) } crossterm::event::KeyCode::Char(' ') => wizard.toggle_proposal(), crossterm::event::KeyCode::Char('c') => wizard.cycle_proposal_kind(), crossterm::event::KeyCode::Enter => wizard.advance(), crossterm::event::KeyCode::Esc => { app.mode = AppMode::Normal; app.wizard = None; } _ => {} }, WizardStep::ConfigureDates => match self.key_code { crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => { wizard.move_cursor(-1) } crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => { wizard.move_cursor(1) } crossterm::event::KeyCode::Char(' ') => wizard.toggle_date_component(), crossterm::event::KeyCode::Enter => wizard.advance(), crossterm::event::KeyCode::Esc => { app.mode = AppMode::Normal; app.wizard = None; } _ => {} }, WizardStep::DefineFormulas => { if wizard.formula_editing { match self.key_code { crossterm::event::KeyCode::Enter => wizard.confirm_formula(), crossterm::event::KeyCode::Esc => wizard.cancel_formula_edit(), crossterm::event::KeyCode::Backspace => wizard.pop_formula_char(), crossterm::event::KeyCode::Char(c) => wizard.push_formula_char(c), _ => {} } } else { match self.key_code { crossterm::event::KeyCode::Char('n') => wizard.start_formula_edit(), crossterm::event::KeyCode::Char('d') => wizard.delete_formula(), crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => { wizard.move_cursor(-1) } crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => { wizard.move_cursor(1) } crossterm::event::KeyCode::Enter => wizard.advance(), crossterm::event::KeyCode::Esc => { app.mode = AppMode::Normal; app.wizard = None; } _ => {} } } } WizardStep::NameModel => match self.key_code { crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c), crossterm::event::KeyCode::Backspace => wizard.pop_name_char(), crossterm::event::KeyCode::Enter => match wizard.build_model() { Ok(mut model) => { model.normalize_view_state(); app.model = model; app.formula_cursor = 0; app.dirty = true; app.status_msg = "Import successful! Press :w to save.".to_string(); app.mode = AppMode::Normal; app.wizard = None; } Err(e) => { if let Some(w) = &mut app.wizard { w.message = Some(format!("Error: {e}")); } } }, crossterm::event::KeyCode::Esc => { app.mode = AppMode::Normal; app.wizard = None; } _ => {} }, WizardStep::Done => { app.mode = AppMode::Normal; app.wizard = None; } } } } /// Start the import wizard from a JSON file path. #[derive(Debug)] pub struct StartImportWizard(pub String); impl Effect for StartImportWizard { fn apply(&self, app: &mut App) { match std::fs::read_to_string(&self.0) { Ok(content) => match serde_json::from_str::(&content) { Ok(json) => { app.wizard = Some(crate::import::wizard::ImportWizard::new(json)); app.mode = AppMode::ImportWizard; } Err(e) => { app.status_msg = format!("JSON parse error: {e}"); } }, Err(e) => { app.status_msg = format!("Cannot read file: {e}"); } } } } #[derive(Debug)] pub struct ExportCsv(pub PathBuf); impl Effect for ExportCsv { fn apply(&self, app: &mut App) { let view_name = app.model.active_view.clone(); match crate::persistence::export_csv(&app.model, &view_name, &self.0) { Ok(()) => { app.status_msg = format!("Exported to {}", self.0.display()); } Err(e) => { app.status_msg = format!("Export error: {e}"); } } } } /// Load a model from a file, replacing the current one. #[derive(Debug)] pub struct LoadModel(pub PathBuf); impl Effect for LoadModel { fn apply(&self, app: &mut App) { match crate::persistence::load(&self.0) { Ok(mut loaded) => { loaded.normalize_view_state(); app.model = loaded; app.status_msg = format!("Loaded from {}", self.0.display()); } Err(e) => { app.status_msg = format!("Load error: {e}"); } } } } /// Headless JSON/CSV import: read file, analyze, build model, replace current. #[derive(Debug)] pub struct ImportJsonHeadless { pub path: PathBuf, pub model_name: Option, pub array_path: Option, } impl Effect for ImportJsonHeadless { fn apply(&self, app: &mut App) { use crate::import::analyzer::{ analyze_records, extract_array_at_path, find_array_paths, FieldKind, }; use crate::import::wizard::ImportPipeline; let is_csv = self .path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("csv")); let records = if is_csv { match crate::import::csv_parser::parse_csv(&self.path) { Ok(recs) => recs, Err(e) => { app.status_msg = format!("CSV error: {e}"); return; } } } else { let content = match std::fs::read_to_string(&self.path) { Ok(c) => c, Err(e) => { app.status_msg = format!("Cannot read '{}': {e}", self.path.display()); return; } }; let value: serde_json::Value = match serde_json::from_str(&content) { Ok(v) => v, Err(e) => { app.status_msg = format!("JSON parse error: {e}"); return; } }; if let Some(ap) = self.array_path.as_deref().filter(|s| !s.is_empty()) { match extract_array_at_path(&value, ap) { Some(arr) => arr.clone(), None => { app.status_msg = format!("No array at path '{ap}'"); return; } } } else if let Some(arr) = value.as_array() { arr.clone() } else { let paths = find_array_paths(&value); if let Some(first) = paths.first() { match extract_array_at_path(&value, first) { Some(arr) => arr.clone(), None => { app.status_msg = "Could not extract records array".to_string(); return; } } } else { app.status_msg = "No array found in JSON".to_string(); return; } } }; let proposals = analyze_records(&records); let raw = if is_csv { serde_json::Value::Array(records.clone()) } else { serde_json::from_str(&std::fs::read_to_string(&self.path).unwrap_or_default()) .unwrap_or(serde_json::Value::Array(records.clone())) }; let pipeline = ImportPipeline { raw, array_paths: vec![], selected_path: self.array_path.as_deref().unwrap_or("").to_string(), records, proposals: proposals .into_iter() .map(|mut p| { p.accepted = p.kind != FieldKind::Label; p }) .collect(), model_name: self .model_name .as_deref() .unwrap_or("Imported Model") .to_string(), formulas: vec![], }; match pipeline.build_model() { Ok(new_model) => { app.model = new_model; app.status_msg = "Imported successfully".to_string(); } Err(e) => { app.status_msg = format!("Import error: {e}"); } } } } #[derive(Debug)] pub struct SetPanelOpen { pub panel: Panel, pub open: bool, } #[derive(Debug, Clone, Copy)] pub enum Panel { Formula, Category, View, } impl Panel { pub fn mode(self) -> AppMode { match self { Panel::Formula => AppMode::FormulaPanel, Panel::Category => AppMode::CategoryPanel, Panel::View => AppMode::ViewPanel, } } } impl Effect for SetPanelOpen { fn apply(&self, app: &mut App) { match self.panel { Panel::Formula => app.formula_panel_open = self.open, Panel::Category => app.category_panel_open = self.open, Panel::View => app.view_panel_open = self.open, } } } #[derive(Debug)] pub struct SetPanelCursor { pub panel: Panel, pub cursor: usize, } impl Effect for SetPanelCursor { fn apply(&self, app: &mut App) { match self.panel { Panel::Formula => app.formula_cursor = self.cursor, Panel::Category => app.cat_panel_cursor = self.cursor, Panel::View => app.view_panel_cursor = self.cursor, } } } // ── Convenience constructors ───────────────────────────────────────────────── pub fn mark_dirty() -> Box { Box::new(MarkDirty) } pub fn set_status(msg: impl Into) -> Box { Box::new(SetStatus(msg.into())) } pub fn change_mode(mode: AppMode) -> Box { Box::new(ChangeMode(mode)) } pub fn set_selected(row: usize, col: usize) -> Box { Box::new(SetSelected(row, col)) } // ── Help page navigation ──────────────────────────────────────────────────── #[derive(Debug)] pub struct HelpPageNext; impl Effect for HelpPageNext { fn apply(&self, app: &mut App) { let max = crate::ui::help::HELP_PAGE_COUNT.saturating_sub(1); app.help_page = app.help_page.saturating_add(1).min(max); } } #[derive(Debug)] pub struct HelpPagePrev; impl Effect for HelpPagePrev { fn apply(&self, app: &mut App) { app.help_page = app.help_page.saturating_sub(1); } } #[derive(Debug)] pub struct HelpPageSet(pub usize); impl Effect for HelpPageSet { fn apply(&self, app: &mut App) { app.help_page = self.0; } } pub fn help_page_next() -> Box { Box::new(HelpPageNext) } pub fn help_page_prev() -> Box { Box::new(HelpPagePrev) } 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()); } /// Regression: AddFormula must add the target item to the target category /// so the grid layout includes cells for it. Without this, a formula like /// `Margin = Profit / Revenue` would be registered but invisible in the grid. #[test] fn add_formula_adds_target_item_to_category() { let mut app = test_app(); // "Margin" does not exist as an item in "Type" before adding the formula assert!(!app .model .category("Type") .unwrap() .ordered_item_names() .contains(&"Margin")); AddFormula { raw: "Margin = Food * 2".to_string(), target_category: "Type".to_string(), } .apply(&mut app); let items: Vec<&str> = app .model .category("Type") .unwrap() .ordered_item_names() .into_iter() .collect(); assert!( items.contains(&"Margin"), "formula target 'Margin' should be added as item to 'Type' category, got: {:?}", items ); } #[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); } }