use crate::model::cell::{CellKey, CellValue}; use crate::ui::app::AppMode; use crate::ui::effect::{self, Effect}; use super::core::{Cmd, CmdContext}; use super::navigation::{CursorState, EnterAdvance, viewport_effects}; #[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}" ); } /// Formulas always target _Measure by default, even when no regular /// categories exist. _Measure is a virtual category that always exists. #[test] fn commit_formula_without_regular_categories_targets_measure() { 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 add formula targeting _Measure, got: {dbg}" ); assert!( dbg.contains("_Measure"), "target_category should be _Measure, 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 /// in drill mode, or apply directly in plain records mode; for real keys, write /// to the model. fn commit_regular_cell_value(key: &CellKey, value: &str, effects: &mut Vec>) { if value.is_empty() { effects.push(Box::new(effect::ClearCell(key.clone()))); } else if let Ok(n) = value.parse::() { effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n)))); } else { effects.push(Box::new(effect::SetCell( key.clone(), CellValue::Text(value.to_string()), ))); } effects.push(effect::mark_dirty()); } /// Stage a synthetic edit in drill state so it can be applied atomically on exit. fn stage_drill_edit(record_idx: usize, col_name: String, value: &str) -> Box { Box::new(effect::SetDrillPendingEdit { record_idx, col_name, new_value: value.to_string(), }) } /// Apply a synthetic records-mode edit directly to the underlying model cell. fn commit_plain_records_edit( ctx: &CmdContext, record_idx: usize, col_name: &str, value: &str, effects: &mut Vec>, ) { let Some((orig_key, _)) = ctx .layout .records .as_ref() .and_then(|records| records.get(record_idx)) else { return; }; if col_name == "Value" { commit_regular_cell_value(orig_key, value, effects); return; } if value.is_empty() { effects.push(effect::set_status(effect::RECORD_COORDS_CANNOT_BE_EMPTY)); return; } let Some(existing_value) = ctx.model.get_cell(orig_key).cloned() else { return; }; effects.push(Box::new(effect::ClearCell(orig_key.clone()))); effects.push(Box::new(effect::AddItem { category: col_name.to_string(), item: value.to_string(), })); effects.push(Box::new(effect::SetCell( orig_key.clone().with(col_name, value), existing_value, ))); effects.push(effect::mark_dirty()); } fn commit_cell_value( ctx: &CmdContext, key: &CellKey, value: &str, effects: &mut Vec>, ) { if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) { if ctx.has_drill_state { effects.push(stage_drill_edit(record_idx, col_name, value)); return; } commit_plain_records_edit(ctx, record_idx, &col_name, value, effects); return; } commit_regular_cell_value(key, value, effects); } /// Direction to advance after committing a cell edit. #[derive(Debug, Clone, Copy)] pub enum AdvanceDir { /// Move down (typewriter-style, wraps to next column at bottom). Down, /// Move right (clamps at rightmost column). Right, } /// Commit a cell edit, advance the cursor, and re-enter edit mode. /// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right). #[derive(Debug)] pub struct CommitAndAdvance { pub key: CellKey, pub value: String, pub advance: AdvanceDir, pub cursor: CursorState, } impl Cmd for CommitAndAdvance { fn name(&self) -> &'static str { match self.advance { AdvanceDir::Down => "commit-cell-edit", AdvanceDir::Right => "commit-and-advance-right", } } fn execute(&self, ctx: &CmdContext) -> Vec> { let mut effects: Vec> = Vec::new(); commit_cell_value(ctx, &self.key, &self.value, &mut effects); match self.advance { AdvanceDir::Down => { let adv = EnterAdvance { cursor: self.cursor.clone(), }; effects.extend(adv.execute(ctx)); } AdvanceDir::Right => { let col_max = self.cursor.col_count.saturating_sub(1); let nc = (self.cursor.col + 1).min(col_max); effects.extend(viewport_effects( self.cursor.row, nc, self.cursor.row_offset, self.cursor.col_offset, self.cursor.visible_rows, self.cursor.visible_cols, )); } } effects.push(Box::new(effect::EnterEditAtCursor)); effects } } /// Commit a formula from the formula edit buffer. #[derive(Debug)] pub struct CommitFormula; impl Cmd for CommitFormula { fn name(&self) -> &'static str { "commit-formula" } fn execute(&self, ctx: &CmdContext) -> Vec> { let buf = ctx.buffers.get("formula").cloned().unwrap_or_default(); // Default formula target to _Measure (the virtual measure category). // _Measure dynamically includes all formula targets. vec![ Box::new(effect::AddFormula { raw: buf, target_category: "_Measure".to_string(), }), effect::mark_dirty(), effect::set_status("Formula added"), effect::change_mode(AppMode::FormulaPanel), ] } } /// Shared helper: read a buffer, trim it, and if non-empty, produce add + dirty /// + status effects. If empty, return to CategoryPanel. /// /// Buffer clearing is handled by the keymap (Enter → [commit, clear-buffer]). fn commit_add_from_buffer( ctx: &CmdContext, buffer_name: &str, add_effect: impl FnOnce(&str) -> Option>, status_msg: impl FnOnce(&str) -> String, ) -> Vec> { let buf = ctx.buffers.get(buffer_name).cloned().unwrap_or_default(); let trimmed = buf.trim().to_string(); if trimmed.is_empty() { return vec![effect::change_mode(AppMode::CategoryPanel)]; } let Some(add) = add_effect(&trimmed) else { return vec![]; }; vec![ add, effect::mark_dirty(), effect::set_status(status_msg(&trimmed)), ] } /// Commit adding a category, staying in CategoryAdd mode for the next entry. #[derive(Debug)] pub struct CommitCategoryAdd; impl Cmd for CommitCategoryAdd { fn name(&self) -> &'static str { "commit-category-add" } fn execute(&self, ctx: &CmdContext) -> Vec> { commit_add_from_buffer( ctx, "category", |name| Some(Box::new(effect::AddCategory(name.to_string()))), |name| format!("Added category \"{name}\""), ) } } /// Commit adding an item, staying in ItemAdd mode for the next entry. #[derive(Debug)] pub struct CommitItemAdd; impl Cmd for CommitItemAdd { fn name(&self) -> &'static str { "commit-item-add" } fn execute(&self, ctx: &CmdContext) -> Vec> { let category = if let AppMode::ItemAdd { category, .. } = ctx.mode { category.clone() } else { return vec![]; }; commit_add_from_buffer( ctx, "item", |name| { Some(Box::new(effect::AddItem { category: category.clone(), item: name.to_string(), })) }, |name| format!("Added \"{name}\""), ) } } /// Commit an export from the export buffer. #[derive(Debug)] pub struct CommitExport; impl Cmd for CommitExport { fn name(&self) -> &'static str { "commit-export" } fn execute(&self, ctx: &CmdContext) -> Vec> { let buf = ctx.buffers.get("export").cloned().unwrap_or_default(); vec![ Box::new(effect::ExportCsv(std::path::PathBuf::from(buf))), effect::change_mode(AppMode::Normal), ] } }