use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use std::rc::Rc; use ratatui::style::Color; use crate::command::cmd::CmdContext; use crate::command::keymap::{Keymap, KeymapSet}; use crate::import::wizard::ImportWizard; use crate::model::cell::CellValue; use crate::model::Model; use crate::persistence; use crate::ui::grid::{ compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format, }; use crate::view::GridLayout; /// Drill-down state: frozen record snapshot + pending edits that have not /// yet been applied to the model. #[derive(Debug, Clone, Default)] pub struct DrillState { /// Frozen snapshot of records shown in the drill view (Rc for cheap cloning). pub records: Rc>, /// Pending edits keyed by (record_idx, column_name) → new string value. /// column_name is either "Value" or a category name. pub pending_edits: std::collections::HashMap<(usize, String), String>, } /// Display configuration for the bottom-bar minibuffer. /// Carried structurally by text-entry `AppMode` variants. #[derive(Debug, Clone, PartialEq)] pub struct MinibufferConfig { pub buffer_key: &'static str, pub prompt: String, pub color: Color, } #[derive(Debug, Clone, PartialEq)] pub enum AppMode { Normal, Editing { minibuf: MinibufferConfig, }, FormulaEdit { minibuf: MinibufferConfig, }, FormulaPanel, CategoryPanel, /// Quick-add a new category: Enter adds and stays open, Esc closes. CategoryAdd { minibuf: MinibufferConfig, }, /// Quick-add items to `category`: Enter adds and stays open, Esc closes. ItemAdd { category: String, minibuf: MinibufferConfig, }, ViewPanel, TileSelect, ImportWizard, ExportPrompt { minibuf: MinibufferConfig, }, /// Vim-style `:` command line CommandMode { minibuf: MinibufferConfig, }, Help, Quit, } impl AppMode { /// Extract the minibuffer config from text-entry modes, if present. pub fn minibuffer(&self) -> Option<&MinibufferConfig> { match self { Self::Editing { minibuf, .. } | Self::FormulaEdit { minibuf, .. } | Self::CommandMode { minibuf, .. } | Self::CategoryAdd { minibuf, .. } | Self::ItemAdd { minibuf, .. } | Self::ExportPrompt { minibuf, .. } => Some(minibuf), _ => None, } } pub fn editing() -> Self { Self::Editing { minibuf: MinibufferConfig { buffer_key: "edit", prompt: "edit: ".into(), color: Color::Green, }, } } pub fn formula_edit() -> Self { Self::FormulaEdit { minibuf: MinibufferConfig { buffer_key: "formula", prompt: "formula: ".into(), color: Color::Cyan, }, } } pub fn command_mode() -> Self { Self::CommandMode { minibuf: MinibufferConfig { buffer_key: "command", prompt: ":".into(), color: Color::Yellow, }, } } pub fn category_add() -> Self { Self::CategoryAdd { minibuf: MinibufferConfig { buffer_key: "category", prompt: "new category: ".into(), color: Color::Yellow, }, } } pub fn item_add(category: String) -> Self { let prompt = format!("add item to {category}: "); Self::ItemAdd { category, minibuf: MinibufferConfig { buffer_key: "item", prompt, color: Color::Green, }, } } pub fn export_prompt() -> Self { Self::ExportPrompt { minibuf: MinibufferConfig { buffer_key: "export", prompt: "export path: ".into(), color: Color::Yellow, }, } } } pub struct App { pub model: Model, pub file_path: Option, pub mode: AppMode, pub status_msg: String, pub wizard: Option, pub last_autosave: Instant, pub search_query: String, pub search_mode: bool, pub formula_panel_open: bool, pub category_panel_open: bool, pub view_panel_open: bool, pub cat_panel_cursor: usize, pub view_panel_cursor: usize, pub formula_cursor: usize, pub dirty: bool, /// Yanked cell value for `p` paste pub yanked: Option, /// Tile select cursor (which category index is highlighted) pub tile_cat_idx: usize, /// View navigation history: views visited before the current one. /// Pushed on SwitchView, popped by `<` (back). pub view_back_stack: Vec, /// Views that were "back-ed" from, available for forward navigation (`>`). pub view_forward_stack: Vec, /// Frozen records list for the drill view. When present, this is the /// snapshot that records-mode layouts iterate — records don't disappear /// when filters would change. Pending edits are stored alongside and /// applied to the model on commit/navigate-away. pub drill_state: Option, /// Current page index in the Help screen (0-based). pub help_page: usize, /// Terminal dimensions (updated on resize and at startup). pub term_width: u16, pub term_height: u16, /// Categories expanded in the category panel tree view. pub expanded_cats: std::collections::HashSet, /// Named text buffers for text-entry modes pub buffers: HashMap, /// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.) pub transient_keymap: Option>, /// Current grid layout, derived from model + view + drill_state. /// Rebuilt via `rebuild_layout()` after state changes. pub layout: GridLayout, keymap_set: KeymapSet, } impl App { pub fn new(model: Model, file_path: Option) -> Self { let layout = { let view = model.active_view(); GridLayout::with_frozen_records(&model, view, None) }; Self { model, file_path, mode: AppMode::Normal, status_msg: String::new(), wizard: None, last_autosave: Instant::now(), search_query: String::new(), search_mode: false, formula_panel_open: false, category_panel_open: false, view_panel_open: false, cat_panel_cursor: 0, view_panel_cursor: 0, formula_cursor: 0, dirty: false, yanked: None, tile_cat_idx: 0, view_back_stack: Vec::new(), view_forward_stack: Vec::new(), drill_state: None, help_page: 0, term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80), term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24), expanded_cats: std::collections::HashSet::new(), buffers: HashMap::new(), transient_keymap: None, layout, keymap_set: KeymapSet::default_keymaps(), } } /// Rebuild the grid layout from current model, view, and drill state. /// Note: `with_frozen_records` already handles pruning internally. pub fn rebuild_layout(&mut self) { let view = self.model.active_view(); let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records)); self.layout = GridLayout::with_frozen_records(&self.model, view, frozen); } pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> { let view = self.model.active_view(); let layout = &self.layout; let (sel_row, sel_col) = view.selected; CmdContext { model: &self.model, layout, registry: self.keymap_set.registry(), mode: &self.mode, selected: view.selected, row_offset: view.row_offset, col_offset: view.col_offset, search_query: &self.search_query, yanked: &self.yanked, dirty: self.dirty, search_mode: self.search_mode, formula_panel_open: self.formula_panel_open, category_panel_open: self.category_panel_open, view_panel_open: self.view_panel_open, buffers: &self.buffers, formula_cursor: self.formula_cursor, cat_panel_cursor: self.cat_panel_cursor, view_panel_cursor: self.view_panel_cursor, tile_cat_idx: self.tile_cat_idx, view_back_stack: &self.view_back_stack, view_forward_stack: &self.view_forward_stack, display_value: { let key = layout.cell_key(sel_row, sel_col); if let Some(k) = &key { if let Some((idx, dim)) = crate::view::synthetic_record_info(k) { self.drill_state .as_ref() .and_then(|s| s.pending_edits.get(&(idx, dim)).cloned()) .or_else(|| layout.resolve_display(k)) .unwrap_or_default() } else { self.model .get_cell(k) .map(|v| v.to_string()) .unwrap_or_default() } } else { String::new() } }, visible_rows: (self.term_height as usize).saturating_sub(8), visible_cols: { let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format); let col_widths = compute_col_widths(&self.model, layout, fmt_comma, fmt_decimals); let row_header_width = compute_row_header_width(layout); compute_visible_cols( &col_widths, row_header_width, self.term_width, view.col_offset, ) }, expanded_cats: &self.expanded_cats, key_code: key, } } pub fn apply_effects(&mut self, effects: Vec>) { for effect in effects { effect.apply(self); } self.rebuild_layout(); } /// True when the model has no user-defined categories (show welcome/help). /// Virtual categories (_Index, _Dim) are always present and don't count. pub fn is_empty_model(&self) -> bool { use crate::model::category::CategoryKind; self.model .categories .values() .all(|c| matches!(c.kind, CategoryKind::VirtualIndex | CategoryKind::VirtualDim)) } pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { self.rebuild_layout(); // Transient keymap (prefix key sequence) takes priority if let Some(transient) = self.transient_keymap.take() { let effects = { let ctx = self.cmd_context(key.code, key.modifiers); self.keymap_set .dispatch_transient(&transient, &ctx, key.code, key.modifiers) }; if let Some(effects) = effects { self.apply_effects(effects); } // Whether matched or not, transient is consumed return Ok(()); } // Try mode keymap — if a binding matches, apply effects and return let effects = { let ctx = self.cmd_context(key.code, key.modifiers); self.keymap_set.dispatch(&ctx, key.code, key.modifiers) }; if let Some(effects) = effects { self.apply_effects(effects); } Ok(()) } pub fn autosave_if_needed(&mut self) { if self.dirty && self.last_autosave.elapsed() > Duration::from_secs(30) { if let Some(path) = &self.file_path.clone() { let ap = persistence::autosave_path(path); let _ = persistence::save(&self.model, &ap); self.last_autosave = Instant::now(); } } } pub fn start_import_wizard(&mut self, json: serde_json::Value) { self.wizard = Some(ImportWizard::new(json)); self.mode = AppMode::ImportWizard; } /// Hint text for the status bar (context-sensitive) pub fn hint_text(&self) -> &'static str { match &self.mode { AppMode::Normal => "hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd", AppMode::Editing { .. } => "Enter:commit Tab:commit+right Esc:cancel", AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back", AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression", AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back", AppMode::CategoryAdd { .. } => "Enter:add & continue Tab:same Esc:done — type a category name", AppMode::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name", AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back", AppMode::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back", AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :show-item :help", AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel", AppMode::Help => "h/l:pages q/Esc:close", _ => "", } } } #[cfg(test)] mod tests { use super::*; use crate::model::Model; fn two_col_model() -> App { let mut m = Model::new("T"); m.add_category("Row").unwrap(); // → Row axis m.add_category("Col").unwrap(); // → Column axis m.category_mut("Row").unwrap().add_item("A"); m.category_mut("Row").unwrap().add_item("B"); m.category_mut("Row").unwrap().add_item("C"); m.category_mut("Col").unwrap().add_item("X"); m.category_mut("Col").unwrap().add_item("Y"); App::new(m, None) } fn run_cmd(app: &mut App, cmd: &dyn crate::command::cmd::Cmd) { let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); let effects = cmd.execute(&ctx); drop(ctx); app.apply_effects(effects); } fn enter_advance_cmd(app: &App) -> crate::command::cmd::EnterAdvance { use crate::command::cmd::CursorState; let view = app.model.active_view(); let cursor = CursorState { row: view.selected.0, col: view.selected.1, row_count: 3, col_count: 2, row_offset: 0, col_offset: 0, visible_rows: 20, visible_cols: 8, }; crate::command::cmd::EnterAdvance { cursor } } #[test] fn enter_advance_moves_down_within_column() { let mut app = two_col_model(); app.model.active_view_mut().selected = (0, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.model.active_view().selected, (1, 0)); } #[test] fn enter_advance_wraps_to_top_of_next_column() { let mut app = two_col_model(); // row_max = 2 (A,B,C), col 0 → should wrap to (0, 1) app.model.active_view_mut().selected = (2, 0); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.model.active_view().selected, (0, 1)); } #[test] fn enter_advance_stays_at_bottom_right() { let mut app = two_col_model(); app.model.active_view_mut().selected = (2, 1); let cmd = enter_advance_cmd(&app); run_cmd(&mut app, &cmd); assert_eq!(app.model.active_view().selected, (2, 1)); } #[test] fn import_command_switches_to_import_wizard_mode() { // Regression: execute_command was resetting mode to Normal after // :import set it to ImportWizard, so the wizard never appeared. let mut app = two_col_model(); let json: serde_json::Value = serde_json::json!([{"cat": "A", "val": 1}]); app.start_import_wizard(json); assert!( matches!(app.mode, AppMode::ImportWizard), "mode should be ImportWizard after start_import_wizard" ); } #[test] fn execute_import_command_leaves_mode_as_import_wizard() { let mut app = two_col_model(); // Inject JSON via start_import_wizard to simulate what :import does app.start_import_wizard(serde_json::json!([{"x": 1}])); // After the command the mode must NOT be reset to Normal assert!( !matches!(app.mode, AppMode::Normal), "mode must not be Normal after import wizard is opened" ); } #[test] fn command_mode_typing_appends_to_buffer() { use crossterm::event::KeyEvent; let mut app = two_col_model(); // Enter command mode with ':' app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!(matches!(app.mode, AppMode::CommandMode { .. })); assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("")); // Type 'q' app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("q")); } #[test] fn col_offset_scrolls_when_cursor_moves_past_visible_columns() { use crate::model::cell::{CellKey, CellValue}; // Create a model with 8 wide columns. Column item names are 30 chars // each → column widths ~31 chars. With term_width=80, row header ~4, // data area ~76 → only ~2 columns actually fit. But the rough estimate // (80−30)/12 = 4 over-counts, so viewport_effects never scrolls. let mut m = Model::new("T"); m.add_category("Row").unwrap(); m.add_category("Col").unwrap(); m.category_mut("Row").unwrap().add_item("R1"); for i in 0..8 { let name = format!("VeryLongColumnItemName_{i:03}"); m.category_mut("Col").unwrap().add_item(&name); } // Populate a value so the model isn't empty let key = CellKey::new(vec![ ("Row".to_string(), "R1".to_string()), ("Col".to_string(), "VeryLongColumnItemName_000".to_string()), ]); m.set_cell(key, CellValue::Number(1.0)); let mut app = App::new(m, None); app.term_width = 80; // Press 'l' (right) 3 times to move cursor to column 3. // Only ~2 columns fit in 76 chars of data area (each col ~26 chars wide), // so column 3 is well off-screen. The buggy estimate (80−30)/12 = 4 // thinks 4 columns fit, so it won't scroll until col 4. for _ in 0..3 { app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)) .unwrap(); } assert_eq!( app.model.active_view().selected.1, 3, "cursor should be at column 3" ); assert!( app.model.active_view().col_offset > 0, "col_offset should scroll when cursor moves past visible area (only ~2 cols fit \ in 80-char terminal with 26-char-wide columns), but col_offset is {}", app.model.active_view().col_offset ); } #[test] fn home_jumps_to_first_col() { let mut app = two_col_model(); app.model.active_view_mut().selected = (1, 1); app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.model.active_view().selected, (1, 0)); } #[test] fn end_jumps_to_last_col() { let mut app = two_col_model(); app.model.active_view_mut().selected = (1, 0); app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.model.active_view().selected, (1, 1)); } #[test] fn page_down_scrolls_by_three_quarters_visible() { let mut app = two_col_model(); // Add enough rows for i in 0..30 { app.model .category_mut("Row") .unwrap() .add_item(&format!("R{i}")); } app.term_height = 28; // ~20 visible rows → delta = 15 app.model.active_view_mut().selected = (0, 0); app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.model.active_view().selected.1, 0, "column preserved"); assert!( app.model.active_view().selected.0 > 0, "row should advance on PageDown" ); // 3/4 of ~20 = 15 assert_eq!(app.model.active_view().selected.0, 15); } #[test] fn page_up_scrolls_backward() { let mut app = two_col_model(); for i in 0..30 { app.model .category_mut("Row") .unwrap() .add_item(&format!("R{i}")); } app.term_height = 28; app.model.active_view_mut().selected = (20, 0); app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) .unwrap(); assert_eq!(app.model.active_view().selected.0, 5); } #[test] fn jump_last_row_scrolls_with_small_terminal() { let mut app = two_col_model(); // Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12. for i in 0..10 { app.model .category_mut("Row") .unwrap() .add_item(&format!("R{i}")); } app.term_height = 13; // ~5 visible rows app.model.active_view_mut().selected = (0, 0); // G jumps to last row (row 12) app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)) .unwrap(); let last = app.model.active_view().selected.0; assert_eq!(last, 12, "should be at last row"); // With only ~5 visible rows and 13 rows, offset should scroll. // Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll. let offset = app.model.active_view().row_offset; assert!( offset > 0, "row_offset should scroll when last row is beyond visible area, but is {offset}" ); } #[test] fn ctrl_d_scrolls_viewport_with_small_terminal() { let mut app = two_col_model(); for i in 0..30 { app.model .category_mut("Row") .unwrap() .add_item(&format!("R{i}")); } app.term_height = 13; // ~5 visible rows app.model.active_view_mut().selected = (0, 0); // Ctrl+d scrolls by 5 rows app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) .unwrap(); assert_eq!(app.model.active_view().selected.0, 5); // Press Ctrl+d again — now at row 10 with only 5 visible rows, // row_offset should have scrolled (not stay at 0 due to hardcoded 20) app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)) .unwrap(); assert_eq!(app.model.active_view().selected.0, 10); assert!( app.model.active_view().row_offset > 0, "row_offset should scroll with small terminal, but is {}", app.model.active_view().row_offset ); } #[test] fn tab_in_edit_mode_commits_and_moves_right() { let mut app = two_col_model(); app.model.active_view_mut().selected = (0, 0); // Enter edit mode app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) .unwrap(); assert!(matches!(app.mode, AppMode::Editing { .. })); // Type a digit app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE)) .unwrap(); // Press Tab — should commit, move right, re-enter edit mode app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) .unwrap(); // Should be in edit mode on column 1 assert!( matches!(app.mode, AppMode::Editing { .. }), "should be in edit mode after Tab, but mode is {:?}", app.mode ); assert_eq!( app.model.active_view().selected.1, 1, "should have moved to column 1" ); } #[test] fn command_mode_buffer_cleared_on_reentry() { use crossterm::event::KeyEvent; let mut app = two_col_model(); // Enter command mode, type something, escape app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("x")); app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) .unwrap(); // Re-enter command mode — buffer should be cleared app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.buffers.get("command").map(|s| s.as_str()), Some("")); } // ── is_empty_model ────────────────────────────────────────────────── #[test] fn fresh_model_is_empty() { let app = App::new(Model::new("T"), None); assert!( app.is_empty_model(), "a brand-new model with only virtual categories should be empty" ); } #[test] fn model_with_user_category_is_not_empty() { let mut m = Model::new("T"); m.add_category("Sales").unwrap(); let app = App::new(m, None); assert!( !app.is_empty_model(), "a model with a user-defined category should not be empty" ); } // ── Help mode navigation ──────────────────────────────────────────── #[test] fn help_page_next_advances_page() { let mut app = App::new(Model::new("T"), None); app.mode = AppMode::Help; app.help_page = 0; app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.help_page, 1, "l should advance to page 1"); } #[test] fn help_page_prev_goes_back() { let mut app = App::new(Model::new("T"), None); app.mode = AppMode::Help; app.help_page = 2; app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.help_page, 1, "h should go back to page 1"); } #[test] fn help_page_clamps_at_zero() { let mut app = App::new(Model::new("T"), None); app.mode = AppMode::Help; app.help_page = 0; app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)) .unwrap(); assert_eq!(app.help_page, 0, "page should not go below 0"); } #[test] fn help_page_clamps_at_max() { use crate::ui::help::HELP_PAGE_COUNT; let mut app = App::new(Model::new("T"), None); app.mode = AppMode::Help; app.help_page = HELP_PAGE_COUNT - 1; app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)) .unwrap(); assert_eq!( app.help_page, HELP_PAGE_COUNT - 1, "page should not exceed the last page" ); } // ── Help mode exits ───────────────────────────────────────────────── #[test] fn help_q_returns_to_normal() { let mut app = App::new(Model::new("T"), None); app.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.mode, AppMode::Normal), "q should return to Normal mode" ); } #[test] fn help_esc_returns_to_normal() { let mut app = App::new(Model::new("T"), None); app.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.mode, AppMode::Normal), "Esc should return to Normal mode" ); } #[test] fn help_colon_enters_command_mode() { let mut app = App::new(Model::new("T"), None); app.mode = AppMode::Help; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.mode, AppMode::CommandMode { .. }), "colon in Help mode should enter CommandMode, got {:?}", app.mode ); } // ── Effect error feedback ─────────────────────────────────────────── #[test] fn add_item_to_nonexistent_category_sets_status() { use crate::ui::effect::Effect; let mut app = App::new(Model::new("T"), None); let effect = crate::ui::effect::AddItem { category: "Nonexistent".to_string(), item: "x".to_string(), }; effect.apply(&mut app); assert!( app.status_msg.contains("Unknown category"), "should report unknown category, got: {:?}", app.status_msg ); } #[test] fn add_formula_with_bad_syntax_sets_status() { use crate::ui::effect::Effect; let mut app = App::new(Model::new("T"), None); let effect = crate::ui::effect::AddFormula { raw: "!!!invalid".to_string(), target_category: "X".to_string(), }; effect.apply(&mut app); assert!( app.status_msg.contains("Formula error"), "should report formula error, got: {:?}", app.status_msg ); } // ── Tile select stays in mode ─────────────────────────────────────── #[test] fn tile_axis_change_stays_in_tile_select() { let mut app = two_col_model(); app.mode = AppMode::TileSelect; app.tile_cat_idx = 0; // Press 'r' to set axis to Row — should stay in TileSelect app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.mode, AppMode::TileSelect), "should stay in TileSelect after axis change, got {:?}", app.mode ); assert!( !app.status_msg.is_empty(), "should show status feedback after axis change" ); } // ── Panel colon bindings ──────────────────────────────────────────── #[test] fn category_panel_colon_enters_command_mode() { let mut app = two_col_model(); app.mode = AppMode::CategoryPanel; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.mode, AppMode::CommandMode { .. }), "colon in CategoryPanel should enter CommandMode, got {:?}", app.mode ); } #[test] fn view_panel_colon_enters_command_mode() { let mut app = two_col_model(); app.mode = AppMode::ViewPanel; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.mode, AppMode::CommandMode { .. }), "colon in ViewPanel should enter CommandMode, got {:?}", app.mode ); } #[test] fn tile_select_colon_enters_command_mode() { let mut app = two_col_model(); app.mode = AppMode::TileSelect; app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)) .unwrap(); assert!( matches!(app.mode, AppMode::CommandMode { .. }), "colon in TileSelect should enter CommandMode, got {:?}", app.mode ); } }