Model is now pure data (categories, cells, formulas, measure_agg) with no references to view/. The Workbook struct owns the Model together with views and the active view name, and is responsible for cross-slice operations (add/remove category → notify views, view management). - New: src/workbook.rs with Workbook wrapper and cross-slice helpers (add_category, add_label_category, remove_category, create_view, switch_view, delete_view, normalize_view_state). - Model: strip view state and view-touching methods. recompute_formulas remains on Model as a primitive; the view-derived none_cats list is gathered at each call site (App::rebuild_layout, persistence::load) so the view dependency is explicit, not hidden behind a wrapper. - View: add View::none_cats() helper. - CmdContext: add workbook and view fields so commands can reach both slices without threading Model + View through every call. - App: rename `model` field to `workbook`. - Persistence (save/load/format_md/parse_md/export_csv): take/return Workbook so the on-disk format carries model + views together. - Widgets (GridWidget, TileBar, CategoryContent, ViewContent): take explicit &Model + &View instead of routing through Model. Tests updated throughout to reflect the new shape. View-management tests that previously lived on Model continue to cover the same behaviour via a build_workbook() helper in model/types.rs. All 573 tests pass; clippy is clean. This is Phase A of improvise-36h. Phase B will mechanically extract crates/improvise-core/ containing model/, view/, format.rs, workbook.rs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
199 lines
6.0 KiB
Rust
199 lines
6.0 KiB
Rust
use crate::ui::effect::{self, Effect};
|
|
|
|
use super::core::{Cmd, CmdContext};
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::command::cmd::test_helpers::*;
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
|
|
#[test]
|
|
fn clear_selected_cell_produces_clear_and_dirty() {
|
|
let mut m = two_cat_model();
|
|
let key = CellKey::new(vec![
|
|
("Type".to_string(), "Food".to_string()),
|
|
("Month".to_string(), "Jan".to_string()),
|
|
]);
|
|
m.model.set_cell(key, CellValue::Number(42.0));
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let cmd = ClearCellCommand {
|
|
key: ctx.cell_key().clone().unwrap(),
|
|
};
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn yank_cell_produces_set_yanked() {
|
|
let mut m = two_cat_model();
|
|
let key = CellKey::new(vec![
|
|
("Type".to_string(), "Food".to_string()),
|
|
("Month".to_string(), "Jan".to_string()),
|
|
]);
|
|
m.model.set_cell(key, CellValue::Number(99.0));
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let cmd = YankCell {
|
|
key: ctx.cell_key().clone().unwrap(),
|
|
};
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn paste_with_yanked_value_produces_set_cell() {
|
|
let mut m = two_cat_model();
|
|
m.model.set_cell(
|
|
CellKey::new(vec![
|
|
("Type".into(), "Food".into()),
|
|
("Month".into(), "Jan".into()),
|
|
]),
|
|
CellValue::Number(42.0),
|
|
);
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let yanked = Some(CellValue::Number(99.0));
|
|
let mut ctx = make_ctx(&m, &layout, ®);
|
|
ctx.yanked = &yanked;
|
|
let key = CellKey::new(vec![
|
|
("Type".into(), "Clothing".into()),
|
|
("Month".into(), "Feb".into()),
|
|
]);
|
|
let cmd = PasteCell { key };
|
|
let effects = cmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 2);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}");
|
|
}
|
|
|
|
#[test]
|
|
fn paste_without_yanked_value_produces_nothing() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let key = CellKey::new(vec![
|
|
("Type".into(), "Food".into()),
|
|
("Month".into(), "Jan".into()),
|
|
]);
|
|
let cmd = PasteCell { key };
|
|
let effects = cmd.execute(&ctx);
|
|
assert!(effects.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn transpose_produces_transpose_and_dirty() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let effects = TransposeAxes.execute(&ctx);
|
|
assert_eq!(effects.len(), 2);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(
|
|
dbg.contains("TransposeAxes"),
|
|
"Expected TransposeAxes, got: {dbg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn save_produces_save_effect() {
|
|
let m = two_cat_model();
|
|
let layout = make_layout(&m);
|
|
let reg = make_registry();
|
|
let ctx = make_ctx(&m, &layout, ®);
|
|
let effects = SaveCmd.execute(&ctx);
|
|
assert_eq!(effects.len(), 1);
|
|
let dbg = effects_debug(&effects);
|
|
assert!(dbg.contains("Save"), "Expected Save, got: {dbg}");
|
|
}
|
|
}
|
|
|
|
// ── Cell operations ──────────────────────────────────────────────────────────
|
|
// All cell commands take an explicit CellKey. The interactive spec fills it
|
|
// from ctx.cell_key(); the parser fills it from Cat/Item coordinate args.
|
|
|
|
/// Clear a cell.
|
|
#[derive(Debug)]
|
|
pub struct ClearCellCommand {
|
|
pub key: crate::model::cell::CellKey,
|
|
}
|
|
impl Cmd for ClearCellCommand {
|
|
fn name(&self) -> &'static str {
|
|
"clear-cell"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
vec![
|
|
Box::new(effect::ClearCell(self.key.clone())),
|
|
effect::mark_dirty(),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Yank (copy) a cell value.
|
|
#[derive(Debug)]
|
|
pub struct YankCell {
|
|
pub key: crate::model::cell::CellKey,
|
|
}
|
|
impl Cmd for YankCell {
|
|
fn name(&self) -> &'static str {
|
|
"yank"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
let value = ctx.model.evaluate_aggregated(&self.key, ctx.none_cats());
|
|
vec![
|
|
Box::new(effect::SetYanked(value)),
|
|
effect::set_status("Yanked"),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Paste the yanked value into a cell.
|
|
#[derive(Debug)]
|
|
pub struct PasteCell {
|
|
pub key: crate::model::cell::CellKey,
|
|
}
|
|
impl Cmd for PasteCell {
|
|
fn name(&self) -> &'static str {
|
|
"paste"
|
|
}
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
if let Some(value) = ctx.yanked.clone() {
|
|
vec![
|
|
Box::new(effect::SetCell(self.key.clone(), value)),
|
|
effect::mark_dirty(),
|
|
]
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── View commands ────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug)]
|
|
pub struct TransposeAxes;
|
|
impl Cmd for TransposeAxes {
|
|
fn name(&self) -> &'static str {
|
|
"transpose"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
vec![Box::new(effect::TransposeAxes), effect::mark_dirty()]
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SaveCmd;
|
|
impl Cmd for SaveCmd {
|
|
fn name(&self) -> &'static str {
|
|
"save"
|
|
}
|
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
|
vec![Box::new(effect::Save)]
|
|
}
|
|
}
|