use crate::model::cell::CellValue; use crate::ui::app::AppMode; use crate::ui::effect::{self, Effect}; use crate::view::AxisEntry; use super::core::{Cmd, CmdContext}; #[cfg(test)] mod tests { use super::*; use crate::command::cmd::test_helpers::*; #[test] fn toggle_group_under_cursor_returns_empty_without_groups() { let m = two_cat_model(); let layout = make_layout(&m); let reg = make_registry(); let ctx = make_ctx(&m, &layout, ®); let cmd = ToggleGroupAtCursor { is_row: true }; let effects = cmd.execute(&ctx); assert!(effects.is_empty()); } #[test] fn law_toggle_group_involution() { let m = two_cat_model(); let layout = make_layout(&m); let reg = make_registry(); let ctx = make_ctx(&m, &layout, ®); let cmd = ToggleGroupAtCursor { is_row: true }; let first = effects_debug(&cmd.execute(&ctx)); let second = effects_debug(&cmd.execute(&ctx)); assert_eq!(first, second, "Toggle should be structurally consistent"); } #[test] fn view_forward_with_empty_stack_shows_status() { let m = two_cat_model(); let layout = make_layout(&m); let reg = make_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 = make_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 = make_registry(); let fwd_stack = vec![crate::ui::app::ViewFrame { view_name: "View 2".to_string(), mode: crate::ui::app::AppMode::Normal, }]; 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 = make_registry(); let back_stack = vec![crate::ui::app::ViewFrame { view_name: "Default".to_string(), mode: crate::ui::app::AppMode::Normal, }]; 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); let dbg = effects_debug(&effects); assert!( dbg.contains("ApplyAndClearDrill"), "Expected ApplyAndClearDrill, got: {dbg}" ); assert!(dbg.contains("ViewBack"), "Expected ViewBack, got: {dbg}"); } #[test] fn toggle_prune_empty_produces_toggle_and_dirty() { let m = two_cat_model(); let layout = make_layout(&m); let reg = make_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}" ); } /// Drilling into a formula cell (e.g. Profit = Revenue - Cost) should /// return the underlying data records, not an empty result set. The /// formula target coordinate is stripped from the drill key so that /// matching_cells finds the raw data backing the formula. #[test] fn drill_into_formula_cell_returns_data_records() { use crate::formula::parse_formula; use crate::model::cell::{CellKey, CellValue}; use crate::workbook::Workbook; let mut m = Workbook::new("Test"); m.add_category("Region").unwrap(); m.model.category_mut("Region").unwrap().add_item("East"); m.model.category_mut("_Measure").unwrap().add_item("Revenue"); m.model.category_mut("_Measure").unwrap().add_item("Cost"); m.model.set_cell( CellKey::new(vec![ ("_Measure".into(), "Revenue".into()), ("Region".into(), "East".into()), ]), CellValue::Number(1000.0), ); m.model.set_cell( CellKey::new(vec![ ("_Measure".into(), "Cost".into()), ("Region".into(), "East".into()), ]), CellValue::Number(600.0), ); m.model.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); let layout = make_layout(&m); let reg = make_registry(); let ctx = make_ctx(&m, &layout, ®); // Drill into the Profit/East cell — a formula-derived cell let key = CellKey::new(vec![ ("_Measure".into(), "Profit".into()), ("Region".into(), "East".into()), ]); let cmd = DrillIntoCell { key }; let effects = cmd.execute(&ctx); let dbg = effects_debug(&effects); // Should find underlying data records, not "0 rows" assert!( !dbg.contains("0 rows"), "Drill into formula cell should find data records, got: {dbg}" ); } } // ── Grid operations ───────────────────────────────────────────────────── /// Toggle the row or column group collapse under the cursor. #[derive(Debug)] pub struct ToggleGroupAtCursor { pub is_row: bool, } impl Cmd for ToggleGroupAtCursor { fn name(&self) -> &'static str { if self.is_row { "toggle-group-under-cursor" } else { "toggle-col-group-under-cursor" } } fn execute(&self, ctx: &CmdContext) -> Vec> { let lookup = if self.is_row { ctx.layout.row_group_for(ctx.selected.0) } else { ctx.layout.col_group_for(ctx.selected.1) }; let Some((cat, group)) = lookup else { return vec![]; }; vec![ Box::new(effect::ToggleGroup { category: cat, group, }), effect::mark_dirty(), ] } } /// Hide the row item at the cursor. #[derive(Debug)] pub struct HideSelectedRowItem; impl Cmd for HideSelectedRowItem { fn name(&self) -> &'static str { "hide-selected-row-item" } fn execute(&self, ctx: &CmdContext) -> Vec> { let Some(cat_name) = ctx.layout.row_cats.first().cloned() else { return vec![]; }; let sel_row = ctx.selected.0; let Some(items) = ctx .layout .row_items .iter() .filter_map(|e| { if let AxisEntry::DataItem(v) = e { Some(v) } else { None } }) .nth(sel_row) else { return vec![]; }; let item_name = items[0].clone(); vec![ Box::new(effect::HideItem { category: cat_name, item: item_name, }), effect::mark_dirty(), ] } } /// Navigate back or forward in view history. #[derive(Debug)] pub struct ViewNavigate { pub forward: bool, } impl Cmd for ViewNavigate { fn name(&self) -> &'static str { if self.forward { "view-forward" } else { "view-back" } } fn execute(&self, ctx: &CmdContext) -> Vec> { if self.forward { if ctx.view_forward_stack.is_empty() { vec![effect::set_status("No forward view")] } else { vec![Box::new(effect::ViewForward)] } } else { if ctx.view_back_stack.is_empty() { vec![effect::set_status("No previous view")] } else { vec![ Box::new(effect::ApplyAndClearDrill), Box::new(effect::ViewBack), ] } } } } /// Drill down into an aggregated cell: create a _Drill view with _Index on /// Row and _Dim on Column (records/long-format view). Fixed coordinates /// from the drilled cell become page filters. #[derive(Debug)] pub struct DrillIntoCell { pub key: crate::model::cell::CellKey, } impl Cmd for DrillIntoCell { fn name(&self) -> &'static str { "drill-into-cell" } fn execute(&self, ctx: &CmdContext) -> Vec> { let drill_name = "_Drill".to_string(); let mut effects: Vec> = Vec::new(); // If drilling into a formula cell, strip the formula target from the // key so matching_cells finds the underlying raw data records instead // of returning nothing. let drill_key = if let Some(measure_val) = self.key.get("_Measure") { let is_formula_target = ctx .model .formulas() .iter() .any(|f| f.target_category == "_Measure" && f.target == measure_val); if is_formula_target { self.key.without("_Measure") } else { self.key.clone() } } else { self.key.clone() }; // Capture the records snapshot NOW (before we switch views). let records: Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)> = if drill_key.0.is_empty() { ctx.model .data .iter_cells() .map(|(k, v)| (k, v.clone())) .collect() } else { ctx.model .data .matching_cells(&drill_key.0) .into_iter() .map(|(k, v)| (k, v.clone())) .collect() }; let n = records.len(); // Freeze the snapshot in the drill state effects.push(Box::new(effect::StartDrill(records))); // Create (or replace) the drill view effects.push(Box::new(effect::CreateView(drill_name.clone()))); effects.push(Box::new(effect::SwitchView(drill_name))); // Records mode: _Index on Row, _Dim on Column effects.push(Box::new(effect::SetAxis { category: "_Index".to_string(), axis: crate::view::Axis::Row, })); effects.push(Box::new(effect::SetAxis { category: "_Dim".to_string(), axis: crate::view::Axis::Column, })); // Fixed coords (from drilled cell) -> Page with that value as filter let fixed_cats: std::collections::HashSet = self.key.0.iter().map(|(c, _)| c.clone()).collect(); for (cat, item) in &self.key.0 { effects.push(Box::new(effect::SetAxis { category: cat.clone(), axis: crate::view::Axis::Page, })); effects.push(Box::new(effect::SetPageSelection { category: cat.clone(), item: item.clone(), })); } // Previously-aggregated categories (none_cats) stay on Axis::None so // they don't filter records; they'll appear as columns in records mode. // Skip virtual categories — we already set _Index/_Dim above. for cat in ctx.none_cats() { if fixed_cats.contains(cat) || cat.starts_with('_') { continue; } effects.push(Box::new(effect::SetAxis { category: cat.clone(), axis: crate::view::Axis::None, })); } effects.push(effect::set_status(format!("Drilled into cell: {n} rows"))); effects } } /// Toggle pruning of empty rows/columns in the current view. #[derive(Debug)] pub struct TogglePruneEmpty; impl Cmd for TogglePruneEmpty { fn name(&self) -> &'static str { "toggle-prune-empty" } fn execute(&self, ctx: &CmdContext) -> Vec> { let currently_on = ctx.view.prune_empty; vec![ Box::new(effect::TogglePruneEmpty), effect::set_status(if currently_on { "Showing all rows/columns" } else { "Hiding empty rows/columns" }), ] } } /// Toggle between records mode and pivot mode using the view stack. /// Entering records mode creates a `_Records` view and switches to it. /// Leaving records mode navigates back to the previous view. #[derive(Debug)] pub struct ToggleRecordsMode; impl Cmd for ToggleRecordsMode { fn name(&self) -> &'static str { "toggle-records-mode" } fn execute(&self, ctx: &CmdContext) -> Vec> { let is_records = ctx.layout.is_records_mode(); if is_records { // Navigate back to the previous view (restores original axes) return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")]; } let mut effects: Vec> = Vec::new(); let records_name = "_Records".to_string(); effects.push(Box::new(effect::SortData)); // Create (or replace) a _Records view and switch to it effects.push(Box::new(effect::CreateView(records_name.clone()))); effects.push(Box::new(effect::SwitchView(records_name))); // _Index on Row, _Dim on Column, everything else -> None effects.push(Box::new(effect::SetAxis { category: "_Index".to_string(), axis: crate::view::Axis::Row, })); effects.push(Box::new(effect::SetAxis { category: "_Dim".to_string(), axis: crate::view::Axis::Column, })); for name in ctx.model.categories.keys() { if name != "_Index" && name != "_Dim" { effects.push(Box::new(effect::SetAxis { category: name.clone(), axis: crate::view::Axis::None, })); } } effects.push(effect::change_mode(AppMode::RecordsNormal)); effects.push(effect::set_status("Records mode")); effects } } /// In records mode, add a new row with an empty value. The new cell gets /// coords from the current page filters. In pivot mode, this is a no-op. #[derive(Debug)] pub struct AddRecordRow; impl Cmd for AddRecordRow { fn name(&self) -> &'static str { "add-record-row" } fn execute(&self, ctx: &CmdContext) -> Vec> { if !ctx.is_records_mode() { return vec![effect::set_status( "add-record-row only works in records mode", )]; } // Build a CellKey from the current page filters let view = ctx.view; let page_cats: Vec = view .categories_on(crate::view::Axis::Page) .into_iter() .map(String::from) .collect(); let coords: Vec<(String, String)> = page_cats .iter() .map(|cat| { let sel = view.page_selection(cat).unwrap_or("").to_string(); (cat.clone(), sel) }) .filter(|(_, v)| !v.is_empty()) .collect(); let key = crate::model::cell::CellKey::new(coords); vec![ Box::new(effect::SetCell(key, CellValue::Number(0.0))), effect::mark_dirty(), effect::set_status("Added new record row"), ] } }