4e8c5d4c9e
Refuse a wire write unless every coordinate names an item (improvise-nacd, improvise-pr8t)
1146 lines
44 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|