Files
improvise/crates/improvise-command/src/ui/reduce.rs
T
Ed Langley 4e8c5d4c9e Merge pull request #252 from fiddlerwoaroof/fix/pr8t-nacd-item-coordinates
Refuse a wire write unless every coordinate names an item (improvise-nacd, improvise-pr8t)
2026-09-23 22:39:11 -07:00

1146 lines
44 KiB
Rust

//! A keypress, or a wire command's document half, applied to the authoritative
//! state (improvise-gxi/s17o). See `context/code/command.md`.
use improvise_core::model::record::RecordHash;
pub use improvise_protocol::records_named;
use improvise_protocol::{Basis, Command, ModelCommand, Rejection};
use crate::command::key::KeyEvent;
use crate::ui::app::App;
use crate::ui::effect::{self, Effect, ViewTarget};
use crate::ui::project::{Dirty, document_projections};
#[cfg(test)]
mod vocabulary_is_complete;
/// A wire [`Command`] whose basis the authoritative state has answered for
/// (improvise-ondj/7v62, ADR 001). See `context/code/command.md`.
pub struct Checked<'a>(&'a Command);
/// A whole frame, answered before any of it is applied (improvise-955n/08wb,
/// ADR 001). Its four clauses are in `context/code/command.md`.
pub struct CheckedBatch<'a>(Vec<Checked<'a>>);
impl<'a> CheckedBatch<'a> {
/// The document tiers are answered once, on the first command that
/// carries a basis; the record tier is asked of every mutation.
pub fn against(app: &App, cmds: &'a [Command]) -> Result<Self, Rejection> {
// The frame's basis is the one its first mutation stated; a later
// command's own `Basis` field is deliberately not read (clause 1).
let mut frame_basis: Option<&Basis> = None;
let mut checked = Vec::with_capacity(cmds.len());
for cmd in cmds {
let one = match frame_basis {
Some(basis) => Checked::within_a_frame(app, basis, cmd)?,
None => {
if let Command::Model { basis, .. } = cmd {
frame_basis = Some(basis);
}
Checked::against(app, cmd)?
}
};
checked.push(one);
}
Ok(Self(checked))
}
}
/// The **structural** half of the record tier, asked again just before each
/// mutation runs. Clause 4's loud failure — `context/code/command.md`.
pub fn broken_chain(app: &App, cmd: &Command) -> Option<Rejection> {
let Command::Model { cmd: model, .. } = cmd else {
return None;
};
// No create-exemption here, unlike the pre-frame check: a record that does
// not resolve NOW is one `resolve_cell` will drop the write for, silently.
records_named(model)
.into_iter()
.find(|hash| app.resolve_record(*hash).is_none())
.map(Rejection::ChainBroken)
}
impl<'a> IntoIterator for CheckedBatch<'a> {
type Item = Checked<'a>;
type IntoIter = std::vec::IntoIter<Checked<'a>>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl<'a> Checked<'a> {
/// Answer for a command, against the state it would be applied to. Total
/// over the vocabulary; only [`Command::Model`] can be refused.
pub fn against(app: &App, cmd: &'a Command) -> Result<Self, Rejection> {
let refused = match cmd {
Command::Model { basis, cmd: model } => stale_basis(app, basis, model),
_ => None,
};
match refused {
Some(reason) => Err(reason),
None => Ok(Self(cmd)),
}
}
/// A command in a frame whose document tiers are already answered
/// (clause 2), answering only for its own records. Private, not a bypass.
fn within_a_frame(app: &App, basis: &Basis, cmd: &'a Command) -> Result<Self, Rejection> {
let refused = match cmd {
Command::Model { cmd: model, .. } => stale_records_in_a_frame(app, basis, model),
_ => None,
};
match refused {
Some(reason) => Err(reason),
None => Ok(Self(cmd)),
}
}
/// The command this proof is about, borrowed from `'a` rather than from
/// `&self` so a caller can dispatch and still move the proof onward.
pub(crate) fn command(&self) -> &'a Command {
self.0
}
}
/// Apply one checked wire command's **document half** to the authoritative state,
/// and say what it dirtied. See `context/code/command.md` for what it will not do.
pub fn apply_command(app: &mut App, cmd: Checked<'_>) -> Dirty {
match cmd.0 {
Command::SwitchView(name) => {
// The mutation is `effect::SwitchView`'s, not a second copy: the
// effect records the history `ModelCommand::ViewBack` pops.
app.apply_effects(vec![Box::new(effect::SwitchView(name.clone()))]);
// Deliberately narrower than `Dirty::of(Model(LayoutGeometry))`:
// a switch leaves the view list alone (improvise-pca).
Dirty {
grid: true,
category_tree: true,
tile_bar: true,
..Dirty::NOTHING
}
}
// The basis was answered for by `Checked::against`; there is no other
// way to have got here with this variant.
Command::Model { cmd, basis: _ } => {
let effects = model_effects(app, cmd);
app.apply_effects(effects)
}
// A projection travels one way and a client's own view slice belongs to
// that client: `Sessions` folds it into the mirror, never into the host.
_ => Dirty::NOTHING,
}
}
/// The other half of the derivation: a [`ModelCommand`] back to the effect it
/// was derived from (improvise-kmgs). Exhaustive on purpose.
pub fn model_effects(app: &App, cmd: &ModelCommand) -> Vec<Box<dyn Effect>> {
let one: Box<dyn Effect> = match cmd {
// A cell the host projects as no `Record` is refused, so the frame is
// Rejected rather than written (improvise-ciw8, ADR 002).
ModelCommand::SetCell { cell, value } => {
match (app.unwritable(cell), app.resolve_cell(cell)) {
(Some(why), _) => effect::refuse(why),
(None, None) => return vec![],
(None, Some(key)) => Box::new(effect::SetCell(key, value.clone())),
}
}
ModelCommand::ClearCell(cell) => match (app.unwritable(cell), app.resolve_cell(cell)) {
(Some(why), _) => effect::refuse(why),
(None, None) => return vec![],
(None, Some(key)) => Box::new(effect::ClearCell(key)),
},
ModelCommand::AddItem { category, item } => Box::new(effect::AddItem {
category: category.clone(),
item: item.clone(),
}),
ModelCommand::AddItemInGroup {
category,
item,
group,
} => Box::new(effect::AddItemInGroup {
category: category.clone(),
item: item.clone(),
group: group.clone(),
}),
ModelCommand::RemoveItem { category, item } => Box::new(effect::RemoveItem {
category: category.clone(),
item: item.clone(),
}),
ModelCommand::AddFormula {
raw,
target_category,
} => Box::new(effect::AddFormula {
raw: raw.clone(),
target_category: target_category.clone(),
}),
ModelCommand::RemoveFormula {
target,
target_category,
} => Box::new(effect::RemoveFormula {
target: target.clone(),
target_category: target_category.clone(),
}),
ModelCommand::ReplaceFormula { id, was, raw } => Box::new(effect::ReplaceFormula {
id: id.clone(),
was: was.clone(),
raw: raw.clone(),
}),
ModelCommand::CleanEmptyRecords => Box::new(effect::CleanEmptyRecords),
ModelCommand::SortData => Box::new(effect::SortData),
ModelCommand::AddCategory(name) => Box::new(effect::AddCategory(name.clone())),
ModelCommand::RemoveCategory(name) => Box::new(effect::RemoveCategory(name.clone())),
ModelCommand::SetAxis {
view,
category,
axis,
} => Box::new(effect::SetAxis {
view: ViewTarget::Named(view.clone()),
category: category.clone(),
axis: *axis,
}),
ModelCommand::CycleAxis { view, category } => Box::new(effect::CycleAxis(
ViewTarget::Named(view.clone()),
category.clone(),
)),
ModelCommand::TransposeAxes { view } => {
Box::new(effect::TransposeAxes(ViewTarget::Named(view.clone())))
}
ModelCommand::SetPageSelection {
view,
category,
item,
} => Box::new(effect::SetPageSelection {
view: ViewTarget::Named(view.clone()),
category: category.clone(),
item: item.clone(),
}),
ModelCommand::HideItem {
view,
category,
item,
} => Box::new(effect::HideItem {
view: ViewTarget::Named(view.clone()),
category: category.clone(),
item: item.clone(),
}),
ModelCommand::ShowItem {
view,
category,
item,
} => Box::new(effect::ShowItem {
view: ViewTarget::Named(view.clone()),
category: category.clone(),
item: item.clone(),
}),
ModelCommand::ToggleGroup {
view,
category,
group,
} => Box::new(effect::ToggleGroup {
view: ViewTarget::Named(view.clone()),
category: category.clone(),
group: group.clone(),
}),
ModelCommand::TogglePruneEmpty { view } => {
Box::new(effect::TogglePruneEmpty(ViewTarget::Named(view.clone())))
}
ModelCommand::SetNumberFormat { view, format } => Box::new(effect::SetNumberFormat(
ViewTarget::Named(view.clone()),
format.clone(),
)),
ModelCommand::CreateView(name) => Box::new(effect::CreateView(name.clone())),
ModelCommand::DeleteView(name) => Box::new(effect::DeleteView(name.clone())),
ModelCommand::LoadModel(path) => Box::new(effect::LoadModel(path.clone())),
ModelCommand::ImportJsonHeadless {
path,
model_name,
array_path,
} => Box::new(effect::ImportJsonHeadless {
path: path.clone(),
model_name: model_name.clone(),
array_path: array_path.clone(),
}),
};
vec![one]
}
/// Is this batch's basis still current? `None` means apply it. The three tiers
/// are checked largest-first, so the answer names the largest thing that moved.
pub fn stale_basis(app: &App, basis: &Basis, cmd: &ModelCommand) -> Option<Rejection> {
let document_moved = (basis.document_generation != app.document_generation())
.then_some(Rejection::DocumentReplaced);
let layout_moved =
(basis.layout_version != app.layout_version()).then_some(Rejection::LayoutMoved);
document_moved.or(layout_moved).or_else(|| {
records_named(cmd)
.into_iter()
.find_map(|h| stale_record(app, basis, h))
})
}
/// The record tier for a command inside a frame whose document tiers are
/// already answered, skipping the records the frame is about to create.
fn stale_records_in_a_frame(app: &App, basis: &Basis, cmd: &ModelCommand) -> Option<Rejection> {
records_named(cmd)
.into_iter()
.filter(|hash| !will_be_created_by_the_frame(app, basis, *hash))
.find_map(|hash| stale_record(app, basis, hash))
}
/// Is this a record the frame is about to create? **A property, not a list of
/// commands** — see `context/code/command.md`. Only the pair exempts.
fn will_be_created_by_the_frame(app: &App, basis: &Basis, hash: RecordHash) -> bool {
app.resolve_record(hash).is_none() && basis.record(hash).is_none()
}
fn stale_record(app: &App, basis: &Basis, hash: RecordHash) -> Option<Rejection> {
match (app.resolve_record(hash), basis.record(hash)) {
(None, _) => Some(Rejection::RecordGone(hash)),
(Some(_), None) => Some(Rejection::RecordUnstated(hash)),
(Some(_), Some(claimed)) => {
(claimed != app.record_version_of(hash)).then_some(Rejection::RecordMoved(hash))
}
}
}
/// The command a client that is **fully caught up** would compose. It takes
/// `&App`, so nothing reachable from a wire can call it: not a bypass.
pub fn caught_up(app: &App, cmd: ModelCommand) -> Command {
Command::Model {
basis: Basis {
document_generation: app.document_generation(),
layout_version: app.layout_version(),
records: records_named(&cmd)
.into_iter()
.map(|h| (h, app.record_version_of(h)))
.collect(),
},
cmd,
}
}
/// The same, for a keypress: resolve it through the keymap, run the
/// commands, apply the effects, and report what to send onward.
pub fn reduce_key(app: &mut App, key: KeyEvent) -> Vec<Command> {
let dirty = app.dispatch_key(key);
document_projections(app, dirty)
.into_iter()
.map(Command::Project)
.collect()
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::model::cell::CellValue;
use crate::ui::app::AppMode;
use crate::view::View;
use crate::workbook::Workbook;
use improvise_protocol::{AxisFrame, Projection};
fn app() -> App {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.views.insert("Second".to_string(), View::new("Second"));
App::new(wb, None)
}
/// A command the current state accepts. The staleness tests build their
/// own [`Rejection`] instead, so neither kind borrows the other's luck.
fn checked<'a>(app: &App, cmd: &'a Command) -> Checked<'a> {
Checked::against(app, cmd).expect("composed against the current state")
}
fn apply_checked(app: &mut App, cmd: &Command) -> Dirty {
let cleared = checked(app, cmd);
apply_command(app, cleared)
}
/// What a host would send onward for the document-scoped widgets. The grid is
/// per-viewport and is `Sessions::project_all`'s, which is why this is no `pub fn`.
fn reduce_checked(app: &mut App, cmd: &Command) -> Vec<Command> {
let cleared = checked(app, cmd);
let dirty = apply_command(app, cleared);
document_projections(app, dirty)
.into_iter()
.map(Command::Project)
.collect()
}
/// The model is shared, the UI is not: a client's view command must not move
/// the host's own slice, and this door is the document half (improvise-s17o).
#[test]
fn a_view_slice_command_does_not_move_the_authoritative_view_state() {
let mut app = app();
app.view_state.status_msg = "host".into();
for cmd in [
Command::SetStatus("saved".into()),
Command::SetSelected { row: 2, col: 3 },
Command::ChangeMode(AppMode::Help),
Command::SetYanked(Some(CellValue::Number(1.0))),
] {
let label = format!("{cmd:?}");
let outbound = reduce_checked(&mut app, &cmd);
assert!(outbound.is_empty(), "{label} reached a document widget");
}
assert_eq!(app.view_state.status_msg, "host");
assert_eq!(app.cursor().selected, (0, 0));
assert!(matches!(app.view_state.mode, AppMode::Normal));
assert_eq!(app.view_state.yanked, None);
}
/// Which view is active is the client's; the document keeps the default a
/// fresh client opens (improvise-jytm, gotcha 29).
#[test]
fn switch_view_moves_the_client_and_leaves_the_documents_default() {
let mut app = app();
reduce_checked(&mut app, &Command::SwitchView("Second".into()));
assert_eq!(app.view_state.active_view, "Second");
assert_eq!(app.model_state.workbook.active_view, "Default");
}
/// `reduce_view` is the mirror's copy of `effect::SwitchView`, so the
/// history must agree. See `context/test-evidence/command.md`.
#[test]
fn a_client_folding_a_switch_records_the_history_the_host_does() {
let mut app = app();
let mut mirror = app.view_state.clone();
let cmd = Command::SwitchView("Second".into());
apply_checked(&mut app, &cmd);
improvise_protocol::reduce_view(&mut mirror, &cmd);
assert_eq!(
mirror.view_back_stack, app.view_state.view_back_stack,
"the client's own history is what `view-back` pops"
);
assert_eq!(mirror.view_forward_stack, app.view_state.view_forward_stack);
}
/// A step back restores the mode it left, so the fixture sets one the
/// default cannot supply. See `context/test-evidence/command.md`.
#[test]
fn a_client_stepping_back_lands_where_the_host_lands() {
let mut app = app();
app.view_state.mode = AppMode::Help;
let mut mirror = app.view_state.clone();
// The host runs the effects; the client folds the commands derived from
// them. Both then leave Help behind, so the restore is observable.
app.apply_effects(vec![Box::new(effect::SwitchView("Second".into()))]);
improvise_protocol::reduce_view(&mut mirror, &Command::SwitchView("Second".into()));
app.view_state.mode = AppMode::Normal;
mirror.mode = AppMode::Normal;
app.apply_effects(vec![Box::new(effect::ViewBack)]);
improvise_protocol::reduce_view(&mut mirror, &Command::ViewBack);
assert_eq!(mirror.active_view, "Default");
assert_eq!(mirror.active_view, app.view_state.active_view);
assert_eq!(
mirror.mode,
AppMode::Help,
"the step back must restore the mode the frame recorded"
);
assert_eq!(mirror.mode, app.view_state.mode);
assert_eq!(mirror.view_back_stack, app.view_state.view_back_stack);
assert_eq!(mirror.view_forward_stack, app.view_state.view_forward_stack);
assert_eq!(mirror.view_forward_stack.len(), 1, "the step left a frame");
}
/// A frame is a place you can go back to, so a deleted view must leave the
/// history rather than sit in it. See `context/test-evidence/command.md`.
#[test]
fn deleting_a_view_forgets_every_frame_that_names_it() {
let mut app = app();
app.model_state.workbook.create_view("Third");
for name in ["Second", "Third", "Default"] {
app.apply_effects(vec![Box::new(effect::SwitchView(name.into()))]);
}
assert!(
app.view_state
.view_back_stack
.iter()
.any(|f| f.view_name == "Second"),
"the fixture must put the doomed view on the stack, or this proves nothing"
);
app.apply_effects(vec![Box::new(effect::DeleteView("Second".into()))]);
let named: Vec<&str> = app
.view_state
.view_back_stack
.iter()
.chain(app.view_state.view_forward_stack.iter())
.map(|f| f.view_name.as_str())
.filter(|n| *n == "Second")
.collect();
assert!(
named.is_empty(),
"the history still offers to go back to a view the document has not got"
);
}
/// Stepping forward is the inverse of stepping back, not a second walk of
/// the same stack — the direction is an enum for that reason.
#[test]
fn stepping_forward_undoes_stepping_back() {
let mut view = app().view_state.clone();
for cmd in [
Command::SwitchView("Second".into()),
Command::ViewBack,
Command::ViewForward,
] {
improvise_protocol::reduce_view(&mut view, &cmd);
}
assert_eq!(view.active_view, "Second");
assert!(view.view_forward_stack.is_empty());
assert_eq!(view.view_back_stack.len(), 1);
}
/// An empty stack is a no-op, never a panic and never a move.
#[test]
fn a_step_off_the_end_of_the_history_moves_nothing() {
let mut view = app().view_state.clone();
for cmd in [Command::ViewBack, Command::ViewForward] {
improvise_protocol::reduce_view(&mut view, &cmd);
}
assert_eq!(view.active_view, "Default");
assert!(view.view_back_stack.is_empty());
assert!(view.view_forward_stack.is_empty());
}
/// A wire-driven view switch owes the same `view_back_stack` history a
/// key-driven one leaves behind (Copilot, PR #11).
#[test]
fn a_wire_view_switch_records_history_the_way_a_key_switch_does() {
let mut app = app();
reduce_checked(&mut app, &Command::SwitchView("Second".into()));
assert_eq!(app.view_state.active_view, "Second");
// `ViewBack` is off the wire since improvise-jytm: it pops *this*
// client's history, which is session-local (gotcha 38).
app.apply_effects(vec![Box::new(effect::ViewBack)]);
assert_eq!(
app.view_state.active_view, "Default",
"the switch pushed nothing onto the back stack"
);
}
/// Switching views changes what the axis-bearing widgets read, so they
/// are told; the list of views itself did not change, so it is not.
#[test]
fn switching_views_projects_the_axis_widgets_only() {
let mut app = app();
let outbound = reduce_checked(&mut app, &Command::SwitchView("Second".into()));
assert!(
outbound
.iter()
.any(|c| matches!(c, Command::Project(Projection::CategoryTree(_)))),
"the category panel's axis column moved: {outbound:?}"
);
assert!(
outbound
.iter()
.any(|c| matches!(c, Command::Project(Projection::TileBar(_)))),
"the tile bar's axes moved: {outbound:?}"
);
assert!(
!outbound
.iter()
.any(|c| matches!(c, Command::Project(Projection::ViewList(_)))),
"the set of views did not change: {outbound:?}"
);
}
/// Projections travel one way. A client that sends one back must not be
/// able to write it into the server's state.
#[test]
fn an_inbound_projection_is_ignored() {
use improvise_protocol::GridUpdate;
let mut app = app();
let before = format!("{:?}", app.view_state);
let outbound = reduce_checked(
&mut app,
&Command::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([
"x",
])))),
);
assert!(outbound.is_empty());
assert_eq!(before, format!("{:?}", app.view_state));
}
/// The key path is the model's door, and it reports the widgets it
/// touched so a host can project them.
#[test]
fn a_keypress_that_changes_the_model_projects_it() {
use crate::command::key::{KeyCode, KeyModifiers};
let mut app = app();
app.view_state.mode = AppMode::category_add();
app.view_state
.buffers
.insert("category".to_string(), "Product".to_string());
let outbound = reduce_key(
&mut app,
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
},
);
assert!(
app.model_state.workbook.model.category("Product").is_some(),
"the key path still reaches the model"
);
assert!(
outbound
.iter()
.any(|c| matches!(c, Command::Project(Projection::CategoryTree(_)))),
"adding a category must reach the category panel: {outbound:?}"
);
}
/// A keypress that moves only this client's cursor reaches no other
/// client (gotcha 3).
#[test]
fn a_view_only_keypress_projects_nothing() {
use crate::command::key::{KeyCode, KeyModifiers};
let mut app = app();
let outbound = reduce_key(
&mut app,
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
},
);
assert!(
outbound.is_empty(),
"a cursor move is nobody else's: {outbound:?}"
);
}
// ── The model half of the vocabulary (improvise-kmgs) ───────────────
use crate::command::key::{KeyCode, KeyModifiers};
use crate::model::category::MEASURE_CATEGORY;
use crate::model::cell::CellKey;
use crate::ui::effect::{EffectKind, ModelChange};
use crate::view::Axis;
use improvise_core::model::record::RecordHash;
use improvise_protocol::{ModelCommand, RecordCell};
use std::path::PathBuf;
/// The cell `every_model_command`'s `SetCell`/`ClearCell` name.
fn a_stored_cell() -> CellKey {
CellKey::new(vec![
("Region".into(), "North".into()),
(MEASURE_CATEGORY.into(), "Revenue".into()),
])
}
/// The surrogate a client would have been projected for that cell.
fn record_cell(key: &CellKey) -> RecordCell {
RecordCell {
record: RecordHash::of(key),
measure: key.get(MEASURE_CATEGORY).map(String::from),
}
}
/// `app()` with a cell actually stored, so a `RecordCell` surrogate for it
/// resolves — otherwise every walk below fails for a reason not its own.
fn model_app() -> App {
let mut app = app();
let workbook = &mut app.model_state.workbook;
workbook.add_item(MEASURE_CATEGORY, "Revenue").unwrap();
app.model_state
.workbook
.model
.set_cell(a_stored_cell(), CellValue::Number(1.0));
app.rebuild_layout();
app
}
/// One value per `ModelCommand` variant — by hand, and NOT compiler-forced:
/// [`variant_name`]'s exhaustive match only forces an arm there (PR #96).
pub(crate) fn every_model_command() -> Vec<ModelCommand> {
vec![
ModelCommand::SetCell {
cell: record_cell(&a_stored_cell()),
value: CellValue::Number(1.0),
},
ModelCommand::ClearCell(record_cell(&a_stored_cell())),
ModelCommand::AddItem {
category: "Region".into(),
item: "South".into(),
},
ModelCommand::AddItemInGroup {
category: "Region".into(),
item: "East".into(),
group: "Coastal".into(),
},
ModelCommand::RemoveItem {
category: "Region".into(),
item: "North".into(),
},
ModelCommand::AddFormula {
raw: "Profit = Revenue - Cost".into(),
target_category: "_Measure".into(),
},
ModelCommand::RemoveFormula {
target: "Profit".into(),
target_category: "_Measure".into(),
},
ModelCommand::ReplaceFormula {
id: crate::formula::parse_formula("Profit = Revenue", "_Measure")
.unwrap()
.id(),
was: "Profit = Revenue".into(),
raw: "Profit = Revenue - Cost".into(),
},
ModelCommand::CleanEmptyRecords,
ModelCommand::SortData,
ModelCommand::AddCategory("Product".into()),
ModelCommand::RemoveCategory("Region".into()),
ModelCommand::SetAxis {
view: "Default".into(),
category: "Region".into(),
axis: Axis::Row,
},
ModelCommand::CycleAxis {
view: "Default".into(),
category: "Region".into(),
},
ModelCommand::TransposeAxes {
view: "Default".into(),
},
ModelCommand::SetPageSelection {
view: "Default".into(),
category: "Region".into(),
item: "North".into(),
},
ModelCommand::HideItem {
view: "Default".into(),
category: "Region".into(),
item: "North".into(),
},
ModelCommand::ShowItem {
view: "Default".into(),
category: "Region".into(),
item: "North".into(),
},
ModelCommand::ToggleGroup {
view: "Default".into(),
category: "Region".into(),
group: "Coastal".into(),
},
ModelCommand::TogglePruneEmpty {
view: "Default".into(),
},
ModelCommand::SetNumberFormat {
view: "Default".into(),
format: ",.2f".into(),
},
ModelCommand::CreateView("Third".into()),
ModelCommand::DeleteView("Second".into()),
ModelCommand::LoadModel(PathBuf::from("doc.improv")),
ModelCommand::ImportJsonHeadless {
path: PathBuf::from("rows.json"),
model_name: Some("Rows".into()),
array_path: Some("data".into()),
},
]
}
/// Exhaustive on purpose — see [`every_model_command`].
fn variant_name(cmd: &ModelCommand) -> &'static str {
match cmd {
ModelCommand::SetCell { .. } => "SetCell",
ModelCommand::ClearCell(_) => "ClearCell",
ModelCommand::AddItem { .. } => "AddItem",
ModelCommand::AddItemInGroup { .. } => "AddItemInGroup",
ModelCommand::RemoveItem { .. } => "RemoveItem",
ModelCommand::AddFormula { .. } => "AddFormula",
ModelCommand::RemoveFormula { .. } => "RemoveFormula",
ModelCommand::ReplaceFormula { .. } => "ReplaceFormula",
ModelCommand::CleanEmptyRecords => "CleanEmptyRecords",
ModelCommand::SortData => "SortData",
ModelCommand::AddCategory(_) => "AddCategory",
ModelCommand::RemoveCategory(_) => "RemoveCategory",
ModelCommand::SetAxis { .. } => "SetAxis",
ModelCommand::CycleAxis { .. } => "CycleAxis",
ModelCommand::TransposeAxes { .. } => "TransposeAxes",
ModelCommand::SetPageSelection { .. } => "SetPageSelection",
ModelCommand::HideItem { .. } => "HideItem",
ModelCommand::ShowItem { .. } => "ShowItem",
ModelCommand::ToggleGroup { .. } => "ToggleGroup",
ModelCommand::TogglePruneEmpty { .. } => "TogglePruneEmpty",
ModelCommand::SetNumberFormat { .. } => "SetNumberFormat",
ModelCommand::CreateView(_) => "CreateView",
ModelCommand::DeleteView(_) => "DeleteView",
ModelCommand::LoadModel(_) => "LoadModel",
ModelCommand::ImportJsonHeadless { .. } => "ImportJsonHeadless",
}
}
/// The derivation, stated as a check: every `ModelCommand` names an effect
/// that declares `EffectKind::Model(..)` — not a `ViewOnly` one (gotcha 35).
#[test]
fn every_model_command_names_an_effect_that_declares_a_model_change() {
for cmd in every_model_command() {
let effects = model_effects(&model_app(), &cmd);
assert!(
!effects.is_empty(),
"{} names no effect at all",
variant_name(&cmd)
);
for effect in &effects {
assert!(
matches!(effect.kind(), EffectKind::Model(_)),
"{} names {effect:?}, which declares {:?}",
variant_name(&cmd),
effect.kind()
);
}
}
}
/// The two `Model` effects whose operand is session-local residue
/// (gotcha 30): one client's drill staging, one client's wizard workbook.
const SESSION_LOCAL_EFFECTS: [&str; 2] = ["ApplyAndClearDrill", "AcceptWizardModel"];
/// improvise-3x3u: the wire vocabulary is the *addressable* model effects.
/// Re-adding either variant means addressing it, not restoring the nullary form.
#[test]
fn no_model_command_commits_another_clients_session_local_state() {
for cmd in every_model_command() {
for effect in &model_effects(&model_app(), &cmd) {
let name = effect_type_name(effect.as_ref());
assert!(
!SESSION_LOCAL_EFFECTS.contains(&name.as_str()),
"{} is on the wire but runs {name}, whose operand belongs \
to one session and cannot be addressed by a nullary \
command (improvise-3x3u)",
variant_name(&cmd)
);
}
}
}
/// The guard above is only worth having while the effects it names still
/// exist and are still model changes (`doctrine(check_the_checker)`).
#[test]
fn the_session_local_effects_are_real_model_effects() {
let effects: [Box<dyn Effect>; 2] = [
Box::new(effect::ApplyAndClearDrill),
Box::new(effect::AcceptWizardModel),
];
for effect in &effects {
assert!(
SESSION_LOCAL_EFFECTS.contains(&effect_type_name(effect.as_ref()).as_str()),
"{effect:?} is not the effect this guard thinks it names"
);
assert!(
matches!(effect.kind(), EffectKind::Model(_)),
"{effect:?} stopped being a model change, so keeping it off \
the wire no longer costs anything"
);
}
}
/// An effect's type name, off the leading identifier of its derived `Debug`.
/// `Effect` is a trait object, so `std::any::type_name` cannot reach it.
pub(crate) fn effect_type_name(effect: &dyn Effect) -> String {
format!("{effect:?}")
.split(|c: char| !c.is_alphanumeric() && c != '_')
.next()
.unwrap_or_default()
.to_string()
}
/// The reader above is itself a check, so it gets the injection too: one
/// effect of each `Debug` shape — nothing, `(`, and ` {` after the name.
#[test]
fn the_effect_name_reader_reads_all_three_debug_shapes() {
assert_eq!(
effect_type_name(&effect::TransposeAxes(ViewTarget::Active)),
"TransposeAxes"
);
assert_eq!(
effect_type_name(&effect::AddCategory("Region".into())),
"AddCategory"
);
assert_eq!(
effect_type_name(&effect::HideItem {
view: ViewTarget::Active,
category: "Region".into(),
item: "North".into(),
}),
"HideItem"
);
}
/// Every `ModelCommand` variant names the effect of the same name. This is
/// the check that separates an effect from its inverse — see the evidence.
#[test]
fn every_model_command_names_the_effect_of_the_same_name() {
for cmd in every_model_command() {
let effects = model_effects(&model_app(), &cmd);
let names: Vec<String> = effects.iter().map(|e| effect_type_name(&**e)).collect();
assert_eq!(
names,
vec![variant_name(&cmd).to_string()],
"ModelCommand::{} names {effects:?}",
variant_name(&cmd)
);
}
}
/// The list above covers the enum once. A duplicated entry would leave a
/// variant untested while the count still looked right.
#[test]
fn the_enumeration_names_each_variant_once() {
let mut names: Vec<&str> = every_model_command().iter().map(variant_name).collect();
let total = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), total, "a variant is listed twice: {names:?}");
}
/// ...and covers it WHOLLY, against serde's derive — the one enumeration the
/// compiler maintains (improvise-5ubs). `context/code/command.md`.
#[test]
fn the_enumeration_names_every_variant_the_derive_declares() {
let listed: Vec<String> = every_model_command()
.iter()
.map(|cmd| variant_name(cmd).to_string())
.collect();
let missed: Vec<String> = improvise_protocol::variants::declared::<ModelCommand>()
.into_iter()
.filter(|name| !listed.contains(name))
.collect();
assert!(
missed.is_empty(),
"every_model_command() never constructs {missed:?} — adding an arm to \
variant_name is compiler-forced, adding a VALUE to the list is not"
);
}
/// A model `Command` applied authoritatively lands where the keypress bound
/// to the same mutation lands, `Dirty` included.
#[test]
fn a_model_command_lands_where_its_keypress_lands() {
/// Two categories on two axes, plus cells and 2+-entry view maps: why each
/// is needed is in `context/test-evidence/command.md` (improvise-djcn).
fn pivoted() -> App {
use crate::model::cell::{CellKey, CellValue};
let mut app = app();
let wb = &mut app.model_state.workbook;
let items = [
("Region", ["South", "East", "West"]),
("Month", ["Jan", "Feb", "Mar"]),
("Channel", ["Web", "Store", "Phone"]),
("Year", ["2024", "2025", "2026"]),
];
for (category, names) in items {
wb.add_category(category).ok();
names.iter().for_each(|n| drop(wb.add_item(category, *n)));
}
for (n, (region, month)) in [("North", "Jan"), ("South", "Feb"), ("East", "Jan")]
.into_iter()
.enumerate()
{
let key = CellKey::new(vec![
("Region".into(), region.into()),
("Month".into(), month.into()),
("Channel".into(), "Web".into()),
("Year".into(), "2024".into()),
]);
wb.model.set_cell(key, CellValue::Number(n as f64 + 1.0));
}
// Several entries, so two instances' HashMaps can disagree on order (improvise-m6op).
use crate::formula::AggFunc;
for (measure, agg) in [
("Revenue", AggFunc::Avg),
("Cost", AggFunc::Min),
("Units", AggFunc::Max),
("Orders", AggFunc::Count),
] {
wb.model.measure_agg.insert(measure.into(), agg);
}
let view = wb.active_view_mut();
view.set_axis("Region", Axis::Row);
view.set_axis("Month", Axis::Column);
view.set_axis("Channel", Axis::Page);
view.set_axis("Year", Axis::Page);
view.set_page_selection("Channel", "Web");
view.set_page_selection("Year", "2024");
view.hide_item("Region", "West");
view.hide_item("Region", "South");
view.hide_item("Month", "Mar");
view.toggle_group_collapse("Region", "Coastal");
view.toggle_group_collapse("Region", "Inland");
view.toggle_group_collapse("Month", "Q1");
app.rebuild_layout();
app
}
fn by_key(key: KeyEvent, setup: impl Fn(&mut App)) -> (String, Dirty) {
let mut app = pivoted();
setup(&mut app);
let dirty = app.dispatch_key(key);
(format!("{:?}", app.model_state.workbook), dirty)
}
fn by_command(cmd: ModelCommand, setup: impl Fn(&mut App)) -> (String, Dirty) {
let mut app = pivoted();
setup(&mut app);
let wire = caught_up(&app, cmd);
let dirty = apply_checked(&mut app, &wire);
(format!("{:?}", app.model_state.workbook), dirty)
}
let plain = |_: &mut App| {};
let char_key = |c: char| KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE,
};
assert_eq!(
by_key(char_key('t'), plain),
by_command(
ModelCommand::TransposeAxes {
view: "Default".into(),
},
plain
),
"`t` and Model(TransposeAxes)"
);
assert_eq!(
by_key(char_key('P'), plain),
by_command(
ModelCommand::TogglePruneEmpty {
view: "Default".into(),
},
plain
),
"`P` and Model(TogglePruneEmpty)"
);
// The minibuffer path: the keypress carries the name in a buffer,
// the command carries it in the variant.
let typed = |app: &mut App| {
app.view_state.mode = AppMode::category_add();
app.view_state
.buffers
.insert("category".to_string(), "Product".to_string());
};
assert_eq!(
by_key(
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE
},
typed
),
by_command(ModelCommand::AddCategory("Product".into()), plain),
"commit-buffer category and Model(AddCategory)"
);
}
/// A wire `Command::Model` cannot reach the document unchecked (ADR 001,
/// improvise-ondj). The regression guard is the signature, not this test.
#[test]
fn a_stale_wire_model_command_cannot_be_applied() {
let mut app = app();
let wire = caught_up(&app, ModelCommand::AddCategory("Product".into()));
// The layout moves under the client's feet, which is the tier
// `Sessions::apply` answers with `Rejection::LayoutMoved`.
let bump = caught_up(
&app,
ModelCommand::TransposeAxes {
view: app.view_name().to_string(),
},
);
apply_checked(&mut app, &bump);
let refused = Checked::against(&app, &wire).err();
assert_eq!(refused, Some(Rejection::LayoutMoved));
assert!(
app.model_state.workbook.model.category("Product").is_none(),
"a batch whose basis had moved reached the document"
);
}
/// The `Dirty` a model command reports is the one its effect declares,
/// not a second table kept alongside the enum (gotcha 35).
#[test]
fn a_model_commands_dirty_comes_from_its_effects_kind() {
let mut sorted = app();
let wire = caught_up(&sorted, ModelCommand::SortData);
let dirty = apply_checked(&mut sorted, &wire);
assert_eq!(dirty, Dirty::of(EffectKind::Model(ModelChange::CellData)));
let mut added = app();
let wire = caught_up(&added, ModelCommand::AddCategory("Product".into()));
let dirty = apply_checked(&mut added, &wire);
assert_eq!(
dirty,
Dirty::of(EffectKind::Model(ModelChange::LayoutGeometry))
);
}
/// improvise-gv9r: a client's edit used to leave the document reporting
/// *clean* on every transport. All three `ModelChange` levels, separately.
#[test]
fn a_wire_model_command_marks_the_document_dirty() {
for cmd in [
ModelCommand::SetCell {
cell: RecordCell {
record: RecordHash::of(&crate::model::cell::CellKey::new(vec![(
"Region".to_string(),
"North".to_string(),
)])),
measure: Some("Revenue".to_string()),
},
value: CellValue::Number(1.0),
},
ModelCommand::AddCategory("Product".into()),
ModelCommand::TransposeAxes {
view: "Default".into(),
},
] {
let mut app = app();
let workbook = &mut app.model_state.workbook;
workbook.add_item(MEASURE_CATEGORY, "Revenue").unwrap();
app.rebuild_layout();
assert!(!app.model_state.dirty, "the fixture starts clean");
let wire = caught_up(&app, cmd.clone());
apply_checked(&mut app, &wire);
assert!(
app.model_state.dirty,
"{cmd:?} changed the document and left it reporting clean"
);
}
}
/// Since improvise-jytm a switch moves no document state, so there is
/// nothing to persist and the worker must not be woken.
#[test]
fn a_wire_view_switch_leaves_the_document_clean() {
let mut app = app();
assert!(!app.model_state.dirty, "the fixture starts clean");
apply_checked(&mut app, &Command::SwitchView("Second".into()));
assert_eq!(
app.view_state.active_view, "Second",
"the fixture must actually switch, or this proves nothing"
);
assert!(
!app.model_state.dirty,
"a view switch dirtied a document it did not change"
);
}
}