diff --git a/crates/improvise-command/src/command/cmd/grid.rs b/crates/improvise-command/src/command/cmd/grid.rs index 3097273c..0d02e7cd 100644 --- a/crates/improvise-command/src/command/cmd/grid.rs +++ b/crates/improvise-command/src/command/cmd/grid.rs @@ -34,10 +34,12 @@ mod tests { wb } + /// The real path: the App's own context, the command, then `apply_effects`, + /// so a placement the toggle's reframe throws away is seen. fn toggle_group_at_cursor(app: &mut crate::ui::app::App) -> String { + use crate::command::key::{KeyCode, KeyModifiers}; let effects = { - let reg = make_registry(); - let ctx = make_ctx(&app.model_state.workbook, &app.layout, ®); + let ctx = app.cmd_context(KeyCode::Char('z'), KeyModifiers::NONE); ToggleGroupAtCursor { is_row: true }.execute(&ctx) }; let dbg = effects_debug(&effects); @@ -45,6 +47,12 @@ mod tests { dbg } + fn row_labels(app: &crate::ui::app::App) -> Vec { + (0..app.layout.row_count()) + .map(|r| app.layout.row_label(r)) + .collect() + } + /// Evidence: `context/test-evidence/command.md`. #[test] fn toggling_at_the_cursor_names_the_group_the_cursor_sits_in_and_collapses_it() { @@ -52,68 +60,122 @@ mod tests { let mut app = App::new(two_groups_on_the_row_axis(), None); app.rebuild_layout(); + // Off row 0, or a toggle that ignores the cursor names Q1 and passes. + app.cursor_mut().selected = (2, 0); assert!( !app.model_state .workbook .active_view() - .is_group_collapsed("Month", "Q1"), + .is_group_collapsed("Month", "Q2"), "the fixture starts expanded, or the assertion below proves nothing" ); let dbg = toggle_group_at_cursor(&mut app); assert!( - dbg.contains(r#"ToggleGroup { view: Active, category: "Month", group: "Q1" }"#), - "the cursor sits on Jan, which is in Q1, got: {dbg}" - ); - assert!( - app.model_state - .workbook - .active_view() - .is_group_collapsed("Month", "Q1"), - "the effect was returned but the view did not move" - ); - } - - /// Evidence: `context/test-evidence/command.md`. - #[test] - fn a_second_toggle_at_the_same_cursor_names_the_next_group_not_the_collapsed_one() { - use crate::ui::app::App; - - let mut app = App::new(two_groups_on_the_row_axis(), None); - app.rebuild_layout(); - toggle_group_at_cursor(&mut app); - - let dbg = toggle_group_at_cursor(&mut app); - - assert!( - dbg.contains(r#"group: "Q2""#), - "collapsing Q1 removes its rows, so data row 0 is now Apr in Q2, got: {dbg}" + dbg.contains(r#"ToggleGroup { view: Active, category: "Month", group: "Q2" }"#), + "the cursor sits on Apr, which is in Q2, got: {dbg}" ); let view = app.model_state.workbook.active_view(); assert!( - view.is_group_collapsed("Month", "Q1") && view.is_group_collapsed("Month", "Q2"), - "pressing twice collapses both groups; it is not an involution" + view.is_group_collapsed("Month", "Q2"), + "the effect was returned but the view did not move" ); + assert!(!view.is_group_collapsed("Month", "Q1"), "the neighbour was left alone"); } /// Evidence: `context/test-evidence/command.md`. #[test] - fn a_fully_collapsed_row_axis_leaves_the_command_nothing_to_address() { + fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() { use crate::ui::app::App; let mut app = App::new(two_groups_on_the_row_axis(), None); app.rebuild_layout(); - toggle_group_at_cursor(&mut app); - toggle_group_at_cursor(&mut app); - assert_eq!(app.layout.row_count(), 0); + app.cursor_mut().selected = (2, 0); + assert_eq!(app.layout.row_label(2), "Apr", "the cursor starts inside Q2"); let dbg = toggle_group_at_cursor(&mut app); + assert!(dbg.contains(r#"group: "Q2""#), "Apr is in Q2, got: {dbg}"); + assert_eq!(row_labels(&app), ["Jan", "Feb", "Q2"]); + assert_eq!(app.cursor().selected, (2, 0), "the cursor is on the Q2 row"); + + let dbg = toggle_group_at_cursor(&mut app); + + assert!(dbg.contains(r#"group: "Q2""#), "z again names Q2, got: {dbg}"); + assert_eq!(row_labels(&app), ["Jan", "Feb", "Apr", "May"], "z z is a no-op"); + assert_eq!(app.cursor().selected, (2, 0), "back on Q2's first item"); + } + + /// Evidence: `context/test-evidence/command.md`. + #[test] + fn a_collapse_from_the_tail_of_a_run_lands_on_the_runs_first_row() { + use crate::ui::app::App; + + let mut app = App::new(two_groups_on_the_row_axis(), None); + app.rebuild_layout(); + app.cursor_mut().selected = (3, 0); + assert_eq!(app.layout.row_label(3), "May"); + + toggle_group_at_cursor(&mut app); + + assert_eq!(row_labels(&app), ["Jan", "Feb", "Q2"]); + assert_eq!(app.cursor().selected, (2, 0), "May folded into the Q2 row"); + } + + /// Evidence: `context/test-evidence/command.md`. + #[test] + fn collapsing_an_inner_group_lands_where_every_earlier_run_shrank() { + use crate::ui::app::App; + use crate::view::Axis; + + let mut wb = crate::workbook::Workbook::new("T"); + wb.add_category("Region").unwrap(); + wb.add_category("Month").unwrap(); + wb.add_category("Type").unwrap(); + wb.add_item("Region", "North").unwrap(); + wb.add_item("Region", "South").unwrap(); + for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { + wb.add_item_in_group("Month", month, quarter).unwrap(); + } + wb.add_item("Type", "Food").unwrap(); + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("Month", Axis::Row); + wb.active_view_mut().set_axis("Type", Axis::Column); + let mut app = App::new(wb, None); + app.rebuild_layout(); + assert_eq!(app.layout.row_cats, ["Region", "Month"], "Month is the INNER level"); + app.cursor_mut().selected = (6, 0); + assert_eq!(app.layout.row_tuple(6), Some(&["South".into(), "Apr".into()][..])); + + toggle_group_at_cursor(&mut app); + assert_eq!( - dbg, "[]", - "there is no row under the cursor to name a group" + row_labels(&app), + ["North/Jan", "North/Feb", "North/Q2", "South/Jan", "South/Feb", "South/Q2"] ); + assert_eq!(app.cursor().selected, (5, 0), "South's Q2, not the old index 6"); + } + + /// Evidence: `context/test-evidence/command.md`. + #[test] + fn a_collapsed_group_can_be_re_expanded_from_the_grid() { + use crate::ui::app::App; + + let mut app = App::new(two_groups_on_the_row_axis(), None); + app.rebuild_layout(); + app.cursor_mut().selected = (2, 0); + toggle_group_at_cursor(&mut app); + app.cursor_mut().selected = (1, 0); + toggle_group_at_cursor(&mut app); + assert_eq!(row_labels(&app), ["Q1", "Q2"], "both groups collapsed"); + app.cursor_mut().selected = (1, 0); + + let dbg = toggle_group_at_cursor(&mut app); + + assert!(dbg.contains(r#"group: "Q2""#), "the Q2 row names Q2, got: {dbg}"); + assert_eq!(row_labels(&app), ["Q1", "Apr", "May"]); + assert_eq!(app.cursor().selected, (1, 0), "on Q2's first item"); } #[test] diff --git a/crates/improvise-command/src/command/cmd/mode.rs b/crates/improvise-command/src/command/cmd/mode.rs index bdf80cc3..6d5d31b7 100644 --- a/crates/improvise-command/src/command/cmd/mode.rs +++ b/crates/improvise-command/src/command/cmd/mode.rs @@ -188,6 +188,39 @@ mod tests { ); } + /// Evidence: `context/test-evidence/command.md`. + #[test] + fn edit_or_drill_declines_a_collapsed_groups_row() { + let mut wb = crate::workbook::Workbook::new("T"); + wb.add_category("Month").unwrap(); + wb.add_category("Type").unwrap(); + for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { + wb.add_item_in_group("Month", month, quarter).unwrap(); + } + wb.add_item("Type", "Food").unwrap(); + wb.active_view_mut().toggle_group_collapse("Month", "Q2"); + let layout = make_layout(&wb); + let reg = make_registry(); + let mut ctx = make_ctx(&wb, &layout, ®); + let edit = EditOrDrill { + edit_mode: AppMode::editing(), + }; + ctx.selected = (1, 0); + assert!( + effects_debug(&edit.execute(&ctx)).contains("Editing"), + "Feb, beside the fold, still edits" + ); + + ctx.selected = (2, 0); + let dbg = effects_debug(&edit.execute(&ctx)); + + assert_eq!(layout.row_label(2), "Q2"); + assert!( + dbg.contains("Collapsed group") && !dbg.contains("Editing") && !dbg.contains("Drill"), + "the fold names no record to edit or drill into, got: {dbg}" + ); + } + /// improvise-ir7l: the cell under the cursor is a formula target, so there is /// nothing to type into. `context/test-evidence/command.md`. #[test] diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 6ee0b6f3..40792ba2 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -1319,8 +1319,8 @@ mod tests { .collect() } - /// A collapsed group at the bottom has no items to hang off and is still the - /// only row saying those rows exist, so the last window carries it. + /// A collapsed group at the bottom folds into one row, and the last window + /// carries its header above that row. Evidence: context/test-evidence/command.md. #[test] fn a_window_reaching_the_last_row_carries_the_trailing_group_header() { let app = app_with_a_trailing_group(true); @@ -1335,13 +1335,13 @@ mod tests { ); assert_eq!( grid.row_frame.len(), - 2, - "East is inside the collapsed group" + 3, + "East folds into the collapsed group's one row" ); assert_eq!( frame_shape(&grid.row_frame), - vec!["North", "South", "[Late]"], - "the header is the last entry, and it is what the user expands" + vec!["North", "South", "[Late]", "Late"], + "the header, then the row the group collapsed into" ); } @@ -1370,11 +1370,11 @@ mod tests { /// header comes. Two headers above, or the compaction is invisible. #[test] fn a_window_opening_inside_a_group_names_only_the_nearest() { - let app = app_with_consecutive_headers(); + let app = app_with_a_collapsed_group_between_rows(); let grid = grid_of( &app, Viewport { - row_offset: 1, + row_offset: 2, col_offset: 0, rows: 20, cols: 20, @@ -1396,9 +1396,9 @@ mod tests { ); } - /// Two consecutive group headers between two rows: `Hidden` is collapsed, so it - /// emits a header and no items, and `Late`'s follows immediately. - fn app_with_consecutive_headers() -> App { + /// Two group headers between `North` and `East`: `Hidden`, collapsed into one + /// row, and `Late`, open. + fn app_with_a_collapsed_group_between_rows() -> App { let mut wb = Workbook::new("Consecutive"); wb.add_category("Region").unwrap(); wb.add_item("Region", "North").unwrap(); @@ -1421,11 +1421,11 @@ mod tests { app } - /// Headers between two in-window rows all come, not just the nearest: the far - /// one is the only row saying those items exist. Above the window, the opposite. + /// Headers between two in-window rows all come, each above the row it opens. + /// Evidence: context/test-evidence/command.md. #[test] - fn consecutive_headers_between_two_rows_all_come() { - let app = app_with_consecutive_headers(); + fn every_header_between_two_in_window_rows_comes() { + let app = app_with_a_collapsed_group_between_rows(); let grid = grid_of( &app, Viewport { @@ -1437,25 +1437,14 @@ mod tests { ); assert_eq!( frame_shape(&grid.row_frame), - vec!["North", "[Hidden]", "[Late]", "East"], - "both headers sit between the two rows and both are drawn" - ); - let group = grid.row_frame.group_of(1).expect("East is under Late"); - assert_eq!(group.name, "Late", "the NEAREST header wins membership"); - let hidden = grid - .row_frame - .entries - .iter() - .find_map(|e| match e { - FrameEntry::Group(g) if g.name == "Hidden" => Some(g), - _ => None, - }) - .expect("kept"); - assert!( - hidden.collapsed, - "and it arrives collapsed, which is what makes it the row that \ - says those rows still exist" + vec!["North", "[Hidden]", "Hidden", "[Late]", "East"], + "both headers are drawn, the collapsed one above its folded row" ); + let hidden = grid.row_frame.group_of(1).expect("the fold is under Hidden"); + assert_eq!(hidden.name, "Hidden"); + assert!(hidden.collapsed, "and it arrives collapsed"); + let late = grid.row_frame.group_of(2).expect("East is under Late"); + assert_eq!(late.name, "Late", "the NEAREST header wins membership"); } fn app_with_data() -> App { @@ -4303,6 +4292,45 @@ mod cell_identity { .unwrap_or_else(|| panic!("cell ({row}, {col}) was projected with no identity")) } + /// Nothing regular on None, so only the fold can make the row an aggregate. + /// Evidence: context/test-evidence/command.md. + #[test] + fn a_collapsed_groups_row_is_projected_as_an_aggregate() { + let mut wb = Workbook::new("Folded"); + wb.add_category("Region").unwrap(); + wb.add_item("Region", "North").unwrap(); + wb.add_item_in_group("Region", "East", "Coastal").unwrap(); + wb.add_item_in_group("Region", "West", "Coastal").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + for (region, value) in [("North", 1.0), ("East", 3.0), ("West", 4.0)] { + wb.model.set_cell( + CellKey::new(vec![ + ("Region".to_string(), region.to_string()), + ("_Measure".to_string(), "Revenue".to_string()), + ]), + CellValue::Number(value), + ); + } + wb.active_view_mut() + .toggle_group_collapse("Region", "Coastal"); + let mut app = App::new(wb, None); + app.rebuild_layout(); + assert_eq!(app.layout.row_label(1), "Coastal"); + + assert!( + matches!(id_at(&app, 0, 0), CellId::Record(_)), + "North is stored, so the none-axis arm is not what answers below" + ); + assert!( + matches!(id_at(&app, 1, 0), CellId::Aggregate(_)), + "a fold has no record under it and must not be offered as settable" + ); + let grid = grid_of(&app, WHOLE); + assert_eq!(grid.cell(1, 0).expect("the fold's cell").text.trim(), "7.00"); + } + fn record_of_id(id: CellId) -> RecordCell { match id { CellId::Record(cell) => cell, diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index b68c4a07..173e2a38 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -1451,6 +1451,80 @@ mod model_tests { let result = m.evaluate_aggregated(&key, Aggregation::of(&none_cats)); assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20 } + + /// `Month` with `Q1 = {Jan, Feb}` built through `add_item_in_group`, which + /// registers no `Group` — the shape a document built in code has. + fn quarter_model(cells: &[(&str, &str, f64)]) -> Model { + let mut m = Model::new("T"); + m.add_category("Month").unwrap(); + m.add_category("Store").unwrap(); + for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2")] { + m.category_mut("Month") + .unwrap() + .add_item_in_group(month, quarter); + } + for store in ["A", "B"] { + m.category_mut("Store").unwrap().add_item(store); + } + m.category_mut("_Measure").unwrap().add_item("Revenue"); + for (month, store, value) in cells { + m.set_cell( + coord(&[("Month", month), ("Store", store), ("_Measure", "Revenue")]), + CellValue::Number(*value), + ); + } + m + } + + /// Evidence: context/test-evidence/core.md. + #[test] + fn a_collapsed_groups_cell_aggregates_its_items() { + let m = quarter_model(&[("Jan", "A", 3.0), ("Feb", "A", 4.0), ("Apr", "A", 100.0)]); + let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "Revenue")]); + assert_eq!( + m.evaluate_aggregated(&key, Aggregation::Exact), + Some(CellValue::Number(7.0)), + "Jan + Feb; not absent, not Jan alone, not a count, not Q2's Apr" + ); + } + + /// Evidence: context/test-evidence/core.md. + #[test] + fn a_groups_stored_values_fold_once_not_per_member() { + use crate::formula::AggFunc; + let mut m = quarter_model(&[("Jan", "A", 2.0), ("Jan", "B", 4.0), ("Feb", "A", 9.0)]); + let key = coord(&[("Month", "Q1"), ("_Measure", "Revenue")]); + let over_store = Aggregation::of(&["Store".to_string()]); + m.measure_agg.insert("Revenue".to_string(), AggFunc::Avg); + assert_eq!( + m.evaluate_aggregated(&key, over_store), + Some(CellValue::Number(5.0)), + "avg(2, 4, 9); folding the members' averages gives avg(3, 9) = 6" + ); + m.measure_agg.insert("Revenue".to_string(), AggFunc::Count); + assert_eq!( + m.evaluate_aggregated(&key, over_store), + Some(CellValue::Number(3.0)), + "three records; counting the members' counts gives 2" + ); + } + + /// Evidence: context/test-evidence/core.md. + #[test] + fn an_item_named_like_a_group_is_read_as_the_item() { + let mut m = quarter_model(&[("Jan", "A", 3.0), ("Feb", "A", 4.0)]); + m.category_mut("Month").unwrap().add_item("Q1"); + m.set_cell( + coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "Revenue")]), + CellValue::Number(50.0), + ); + let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "Revenue")]); + assert_eq!( + m.evaluate_aggregated(&key, Aggregation::Exact), + Some(CellValue::Number(50.0)), + "the stored item, not Jan + Feb" + ); + } } #[cfg(test)]