Files
improvise/crates/improvise-command/src/ui/project.rs
T
Edward Langley 516ad66af2 Merge gh/main (666de199) into fix/fdk1-coordinate-rename
Conflicts:
- cmd/core.rs committable_cell_key: take main's `uneditable` door; its
  pattern names fdk1's struct `CellId::Coordinate { .. }`.
- resolve.rs reopened_at (main, clean text): same pattern fix, a
  compile-only semantic conflict.
- context/test-evidence/{clients,command}.md: both sides' entries kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 22:47:10 -07:00

6140 lines
236 KiB
Rust

//! Turning what the server did into what each client must be told
//! (improvise-cqq/rkeq). The three shaping decisions: `context/code/command.md`.
use std::collections::BTreeMap;
use improvise_core::format::FormatPlan;
use improvise_core::model::Model;
use improvise_core::model::category::MEASURE_CATEGORY;
use improvise_core::model::cell::CellValue;
use improvise_core::model::record::{AggregateHash, RecordHash};
use improvise_core::view::layout::{AxisEntry, GridLayout, RecordCol};
use improvise_core::view::{Axis, View};
use improvise_core::workbook::Workbook;
use improvise_io::import::analyzer::{DateComponent, FieldKind};
use improvise_io::import::wizard::{ImportWizard, WizardStep};
use improvise_protocol::{
AxisFrame, Basis, BasisCache, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay,
CellId, Command, CreateKind, DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup,
GridCache, GridExtent, GridUpdate, Projection, RecordCell, Rejection, Snapshot, TileBarCache,
TileEntry, ViewListCache, ViewState, ViewSummary, WidgetCache, WizardCache, WizardDateField,
WizardFieldKind, WizardProposal, WizardStepKind, basis, reduce_view,
};
use crate::ui::app::App;
use crate::ui::effect::{EffectKind, ModelChange};
use crate::ui::reduce::{Checked, apply_command};
use crate::ui::viewport::{GridBody, grid_body};
/// Which widgets a batch of effects could have changed. A gate, not a description:
/// over-reporting costs work, under-reporting is a correctness bug (gotcha 35).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Dirty {
pub grid: bool,
pub category_tree: bool,
pub formulas: bool,
pub view_list: bool,
pub tile_bar: bool,
}
impl Dirty {
pub const NOTHING: Self = Self {
grid: false,
category_tree: false,
formulas: false,
view_list: false,
tile_bar: false,
};
pub const EVERYTHING: Self = Self {
grid: true,
category_tree: true,
formulas: true,
view_list: true,
tile_bar: true,
};
/// The widgets one effect's classification can reach. **The derivation is in
/// `context/code/command.md`** — re-read it when an effect is reclassified.
pub fn of(kind: EffectKind) -> Self {
match kind {
EffectKind::ViewOnly => Self::NOTHING,
EffectKind::Model(ModelChange::CellData) => Self {
grid: true,
category_tree: true,
formulas: true,
..Self::NOTHING
},
// Spelled out rather than written as `EVERYTHING`: the two coincide
// today but are derived from different effects.
EffectKind::Model(ModelChange::LayoutGeometry) => Self {
grid: true,
category_tree: true,
formulas: true,
view_list: true,
tile_bar: true,
},
EffectKind::Model(ModelChange::WholeWorkbook) => Self::EVERYTHING,
}
}
pub fn union(self, other: Self) -> Self {
Self {
grid: self.grid || other.grid,
category_tree: self.category_tree || other.category_tree,
formulas: self.formulas || other.formulas,
view_list: self.view_list || other.view_list,
tile_bar: self.tile_bar || other.tile_bar,
}
}
pub fn is_empty(self) -> bool {
self == Self::NOTHING
}
}
/// The window of the grid one client can see: where it is scrolled to, and
/// how much of the grid fits.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Viewport {
pub row_offset: usize,
pub col_offset: usize,
pub rows: usize,
pub cols: usize,
}
impl Viewport {
/// The indices this viewport covers, clipped to a grid of `total`.
fn window(offset: usize, len: usize, total: usize) -> std::ops::Range<usize> {
let start = offset.min(total);
start..(start.saturating_add(len)).min(total)
}
fn rows_in(&self, total: usize) -> std::ops::Range<usize> {
Self::window(self.row_offset, self.rows, total)
}
fn cols_in(&self, total: usize) -> std::ops::Range<usize> {
Self::window(self.col_offset, self.cols, total)
}
}
// ── building a widget's current state from the authoritative App ─────────
/// The grid as `viewport` sees it. Indexed by position *within the viewport*,
/// which is what `GridCache` means by row and column.
pub fn grid_of(app: &App, viewport: Viewport) -> GridCache {
grid_in(app, app.view_name(), viewport)
}
/// The same grid, for a client that may be on another view (improvise-jytm). The
/// host's own layout is reused where the names agree; see `context/code/command.md`.
pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache {
let workbook = &app.model_state.workbook;
let (name, view) = app.resolve_view(view_name);
// `formula_cache` holds the HOST's derivation, keyed by the coordinates ITS
// `none_cats` aggregated over — improvise-ac2z, `context/code/command.md`.
let derived = rederived_for(app, name, view);
let model = derived.as_ref().unwrap_or(&workbook.model);
// `with_frozen_records`, not `new`: `new` skips `prune_empty`, so a client on
// another view was projected rows its own config prunes — `context/code/command.md`.
let elsewhere =
(name != app.view_name()).then(|| GridLayout::with_frozen_records(model, view, None));
let layout = elsewhere.as_ref().unwrap_or(&app.layout);
let plan = FormatPlan::of(model, view);
let rows = viewport.rows_in(layout.row_count());
let cols = viewport.cols_in(layout.col_count());
// ONE walk: the widths, the totals and this window's values all come off the
// same evaluation — `context/code/command.md` (improvise-jmjz).
let body = grid_body(model, layout, &plan, rows.clone(), cols.clone());
GridCache {
row_frame: axis_frame(&layout.row_cats, &layout.row_items, view, rows.clone()),
col_frame: axis_frame(&layout.col_cats, &layout.col_items, view, cols.clone()),
col_widths: cols
.clone()
.map(|c| body.col_widths.get(c).copied().unwrap_or(0))
.collect(),
cells: rows
.clone()
.zip(body.window.iter())
.map(|(r, values)| {
cols.clone()
.zip(values.iter())
.map(|(c, value)| cell_display(app, layout, r, c, value, &plan))
.collect()
})
.collect(),
totals: column_totals(&body, cols),
page_coords: layout.page_coords.clone(),
// The whole grid, not the window: this is what a client clamps its own
// cursor against (improvise-bpq2). Off the layout, not the clipped ranges.
extent: GridExtent {
rows: layout.row_count(),
cols: layout.col_count(),
},
// Asked of the layout, the same question the host's own `normal_mode`
// asks, so the two front ends cannot answer it differently (e5pf).
records: layout.is_records_mode(),
}
}
/// A copy of the model whose formulas were evaluated for **this** view, where the
/// host's own derivation does not answer for it — `context/code/command.md`.
fn rederived_for(app: &App, name: &str, view: &View) -> Option<crate::model::types::Model> {
let workbook = &app.model_state.workbook;
// Three conditions, and each one is what makes the clone rare: another view,
// a different aggregation, and something derived to get wrong.
let owed = name != app.view_name()
&& view.none_cats() != app.view().none_cats()
&& !workbook.model.formulas().is_empty();
owed.then(|| {
let mut model = workbook.model.clone();
model.recompute_formulas(&view.none_cats());
model
})
}
/// One axis's frame, windowed to the data indices `window` names. Three edge rules,
/// all about group headers — see `context/code/command.md`.
fn axis_frame(
cats: &[String],
items: &[AxisEntry],
view: &View,
window: std::ops::Range<usize>,
) -> AxisFrame {
let total = items
.iter()
.filter(|e| matches!(e, AxisEntry::DataItem { .. }))
.count();
let mut data_idx = 0usize;
let mut entries: Vec<FrameEntry> = Vec::new();
let mut pending: Vec<FrameEntry> = Vec::new();
for entry in items {
match entry {
AxisEntry::GroupHeader {
cat_name,
group_name,
} => {
// Before the window opens only the NEAREST header matters; keeping
// the run would make the payload O(groups above a deep scroll).
if entries.is_empty() {
pending.clear();
}
pending.push(FrameEntry::Group(FrameGroup {
name: group_name.clone(),
collapsed: view.is_group_collapsed(cat_name, group_name),
}));
}
// Queued like a header, never clearing one: a COLLAPSED group has
// no item to carry it and reaches the frame only through `pending`.
AxisEntry::GroupEnd => pending.push(FrameEntry::GroupEnd),
AxisEntry::DataItem { tuple, .. } => {
let idx = data_idx;
data_idx += 1;
if window.contains(&idx) {
entries.append(&mut pending);
entries.push(FrameEntry::Item(tuple.clone()));
}
}
}
}
if window.end >= total {
entries.append(&mut pending);
}
AxisFrame {
cats: cats.to_vec(),
first: window.start,
entries,
}
}
/// The totals strip: each visible column summed over **every** row, not just the
/// viewport's — which is why it is projected. Empty means "no totals" (gotcha 33).
fn column_totals(body: &GridBody, cols: std::ops::Range<usize>) -> Vec<CellDisplay> {
match body.totals.is_empty() {
true => Vec::new(),
false => cols
.filter_map(|c| Some((body.totals.get(c)?, body.col_formats.get(c)?)))
.map(|(total, fmt)| total_display(total.as_ref(), fmt))
.collect(),
}
}
/// One total as the strip draws it: a number, a unanimous text, an error, or
/// nothing where the column holds no record (improvise-wm20).
fn total_display(total: Option<&CellValue>, fmt: &crate::format::NumberFormat) -> CellDisplay {
match total {
Some(CellValue::Number(n)) => CellDisplay::number(fmt.number(*n)),
Some(v @ CellValue::Error(_)) => CellDisplay::error(fmt.value(Some(v))),
Some(v @ CellValue::Text(_)) => CellDisplay::text(fmt.value(Some(v))),
None => CellDisplay::default(),
}
}
/// One cell as the client draws it. The server knows whether a value was a number
/// and whether the cell is settable, so it says both (ADR 002, [`cell_id`]).
fn cell_display(
app: &App,
layout: &GridLayout,
row: usize,
col: usize,
value: &Option<CellValue>,
plan: &FormatPlan,
) -> CellDisplay {
if layout.is_records_mode() {
// The value, not its spelling: this module used to infer numeric-ness
// with `parse::<f64>()`, which called a coordinate `2024` a number.
let text = value.as_ref().map(|v| v.to_string()).unwrap_or_default();
return CellDisplay {
edit_text: Some(value.as_ref().map(CellValue::edit_text).unwrap_or_default()),
text,
numeric: matches!(value, Some(CellValue::Number(_))),
error: matches!(value, Some(CellValue::Error(_))),
id: cell_id(&app.model_state.workbook.model, layout, row, col),
value: value.clone(),
};
}
let key = layout.cell_key(row, col);
CellDisplay {
text: plan.at(key.as_ref()).value(value.as_ref()),
numeric: matches!(value, Some(CellValue::Number(_))),
error: matches!(value, Some(CellValue::Error(_))),
id: cell_id(&app.model_state.workbook.model, layout, row, col),
// The *unformatted* value the TUI seeds its edit buffer from. Projecting it
// beats a client un-formatting `text` (gotcha 47).
edit_text: key.map(|key| app.display_value_in(layout, &key)),
value: value.clone(),
}
}
/// What the cell at `(row, col)` *is*, as the surrogate the client echoes back. The
/// fork is `EditOrDrill`'s, asked the same way — see `context/code/command.md`.
pub(crate) fn cell_id(
model: &Model,
layout: &GridLayout,
row: usize,
col: usize,
) -> Option<CellId> {
if layout.is_records_mode() {
// A measure can be BOTH an explicit `_Measure` item and a formula target,
// so the document is asked about the record's real key (improvise-ir7l).
if let Some(formula) = layout
.record_cell_key(row, col)
.and_then(|key| model.formula_for(&key))
{
return Some(CellId::Derived {
target: formula.target.clone(),
});
}
// The row's *coordinates*, never the synthetic `(_Index, _Dim)` key: that
// one carries positions, which is what ADR 002 rejected `_Index` for.
let record = &layout.record_row(row)?.coords;
let measure = match layout.record_col(col)? {
RecordCol::Measure(measure) => Some(measure.clone()),
// A creating column takes a NAME and writes no cell, so it is not a
// `SetCell` target and not "no cell" either (improvise-poia).
RecordCol::NewMeasure => return Some(CellId::Create(CreateKind::Measure)),
RecordCol::NewCategory => return Some(CellId::Create(CreateKind::Category)),
// Renaming a coordinate moves every measure cell in the row, which is a
// different intent from `SetCell`: its own operands (gotcha 1, fdk1).
RecordCol::Category(category) => {
return Some(CellId::Coordinate {
record: RecordHash::of(record),
category: category.clone(),
});
}
};
return Some(CellId::Record(RecordCell {
record: RecordHash::of(record),
measure,
}));
}
let key = layout.cell_key(row, col)?;
// A folded group is an aggregate whatever is on None: no record carries
// its coordinate, so a `SetCell` there would write a non-item (§14).
let aggregated = layout.folds_a_group(row, col)
|| layout.none_cats.iter().any(|cat| {
model
.category(cat)
.is_some_and(|category| category.kind.is_regular())
});
// A derived cell is asked about before a stored one: a formula target is a
// real coordinate, so the record branch would otherwise claim it (ir7l).
let derived = model.formula_for(&key).map(|f| CellId::Derived {
target: f.target.clone(),
});
Some(match aggregated {
true => CellId::Aggregate(AggregateHash::of(&key)),
false => derived.unwrap_or_else(|| {
CellId::Record(RecordCell {
measure: key.get(MEASURE_CATEGORY).map(String::from),
record: RecordHash::of(&key),
})
}),
})
}
/// The categories the category panel draws: **every** category, virtual ones
/// included, with their items and their axis (improvise-vjms, gotcha 7).
pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
model
.category_names()
.into_iter()
.map(|name| CategoryRow {
name: name.to_string(),
items: model.effective_item_names(name),
// `try_axis_of`, not `axis_of`: a view that has never seen a category
// has no entry and `axis_of` panics. Unseen shows as `None`.
axis: view.try_axis_of(name).unwrap_or(Axis::None),
})
.collect()
}
/// Every formula's text, which the panel draws, and its identity, which an
/// edit names it by (improvise-km71).
pub fn formula_entries(app: &App) -> Vec<FormulaEntry> {
app.model_state
.workbook
.model
.formulas()
.iter()
.map(FormulaEntry::from)
.collect()
}
/// Every view and where each category sits in it. Which view is *active* is
/// per-client and travels as `Command::SwitchView`, so it is not here.
pub fn view_summaries(app: &App) -> Vec<ViewSummary> {
app.model_state
.workbook
.views
.iter()
.map(|(name, view)| ViewSummary {
name: name.clone(),
axes: view
.category_axes
.iter()
.map(|(cat, axis)| (cat.clone(), *axis))
.collect(),
})
.collect()
}
/// The tiles the tile bar offers — the same set the TUI's tile bar draws,
/// including the virtual categories, minus the ones a drill has fixed.
pub fn tile_entries(app: &App, view: &View) -> Vec<TileEntry> {
tiles_of(&app.model_state.workbook.model, view)
}
/// The same list from a `Model` alone, for a command that has no `App`.
pub fn tiles_of(model: &Model, view: &View) -> Vec<TileEntry> {
view.tileable_cats(&model.category_names())
.into_iter()
.map(|cat| TileEntry {
category: cat.to_string(),
axis: view.try_axis_of(cat).unwrap_or(Axis::None),
regular: model.category(cat).is_some_and(|c| c.kind.is_regular()),
})
.collect()
}
// ── the whole of one widget's cache, for a host that holds the App ──────
// The same derivation `document_projections` sends. See `context/code/command.md`.
/// The category panel's cache: every category, its items and its axis in the
/// active view.
pub fn category_tree_of(app: &App) -> CategoryTreeCache {
category_tree_in(app, app.view())
}
/// The same cache against a view that may be another client's (improvise-jytm):
/// a category's axis is a fact about the view, so it is per-client.
pub fn category_tree_in(app: &App, view: &View) -> CategoryTreeCache {
CategoryTreeCache {
categories: category_rows(&app.model_state.workbook.model, view),
}
}
/// The formula panel's cache: every formula's text and identity.
pub fn formulas_of(app: &App) -> FormulaCache {
FormulaCache {
formulas: formula_entries(app),
}
}
/// The view panel's cache: every view and where each category sits in it.
pub fn view_list_of(app: &App) -> ViewListCache {
ViewListCache {
views: view_summaries(app),
}
}
/// The tile bar's cache: the tileable categories and their axes.
pub fn tile_bar_of(app: &App) -> TileBarCache {
tile_bar_in(app, app.view())
}
/// The same cache against a view that may be another client's (improvise-jytm).
pub fn tile_bar_in(app: &App, view: &View) -> TileBarCache {
TileBarCache {
tiles: tile_entries(app, view),
}
}
/// The projections for every widget whose content does not depend on a client's
/// viewport. Each replaces its cache wholesale, so `dirty` is the whole decision.
pub fn document_projections(app: &App, dirty: Dirty) -> Vec<Projection> {
projections_in(app, app.view(), dirty)
}
/// The same projections for a client on `view`. Two of the four are view-dependent
/// and so are per-client since improvise-jytm; the formula list and view list are not.
pub fn projections_in(app: &App, view: &View, dirty: Dirty) -> Vec<Projection> {
let mut out = Vec::new();
if dirty.category_tree {
out.push(Projection::CategoryTree(CategoryTreeUpdate::Categories(
category_tree_in(app, view).categories,
)));
}
if dirty.formulas {
out.push(Projection::Formulas(formulas_of(app).formulas));
}
if dirty.view_list {
out.push(Projection::ViewList(view_list_of(app).views));
}
if dirty.tile_bar {
out.push(Projection::TileBar(tile_bar_in(app, view).tiles));
}
out
}
/// The basis a client holding `grid` is owed: the two document tiers, plus a
/// counter per record that grid names. One home — `context/code/command.md`.
pub fn published_basis(app: &App, grid: &GridCache) -> Basis {
let records = grid
.cells
.iter()
.flatten()
.filter_map(|cell| match cell.id.as_ref()? {
CellId::Record(RecordCell { record, .. }) | CellId::Coordinate { record, .. } => {
Some((*record, app.record_version_of(*record)))
}
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None,
})
.collect();
basis::published(app.document_generation(), app.layout_version(), records)
}
/// The document as a title bar draws it. `file_name`, never the path: a remote
/// client has no use for the host's directories — `context/code/command.md`.
pub fn document_of(app: &App) -> DocumentCache {
DocumentCache {
name: app.model_state.workbook.model.name.clone(),
dirty: app.model_state.dirty,
stored_as: app
.model_state
.file_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(str::to_string),
}
}
/// The import wizard as the widget draws it (improvise-764). Where the wizard's
/// `improvise-io` types stop, and deliberately fed to no [`Projection`].
pub fn wizard_of(app: &App) -> Option<WizardCache> {
app.wizard.as_ref().map(wizard_cache)
}
/// The mapping itself, split from [`wizard_of`] so it can be driven without an
/// `App`. This half is where every `improvise-io` type is spent.
pub fn wizard_cache(w: &ImportWizard) -> WizardCache {
WizardCache {
step: step_kind(&w.step),
cursor: w.cursor,
message: w.message.clone(),
preview_summary: w.pipeline.preview_summary(),
array_paths: w
.pipeline
.array_paths
.iter()
.map(|path| match path.is_empty() {
true => "(root)".to_string(),
false => path.clone(),
})
.collect(),
proposals: w
.pipeline
.proposals
.iter()
.map(|p| WizardProposal {
field: p.field.clone(),
kind: field_kind(&p.kind),
kind_label: p.kind_label().to_string(),
accepted: p.accepted,
})
.collect(),
date_fields: w
.time_category_proposals()
.iter()
.map(|p| WizardDateField {
field: p.field.clone(),
format: p.date_format.clone().unwrap_or_else(|| "?".to_string()),
components: DATE_COMPONENTS
.iter()
.map(|(component, label)| {
((*label).to_string(), p.date_components.contains(component))
})
.collect(),
})
.collect(),
formulas: w.pipeline.formulas.clone(),
sample_formulas: w.sample_formulas(),
formula_editing: w.formula_editing,
formula_buffer: w.formula_buffer.clone(),
model_name: w.pipeline.model_name.clone(),
built_summary: w
.built_summary()
.map(|(workbook, _)| summarize_workbook(workbook)),
built_warnings: w
.built_summary()
.map(|(_, warnings)| warnings.to_vec())
.unwrap_or_default(),
}
}
/// The date components the wizard offers, **in the order it offers them**:
/// `ImportWizard::date_config_at_cursor` counts these same rows.
const DATE_COMPONENTS: [(DateComponent, &str); 3] = [
(DateComponent::Year, "Year"),
(DateComponent::Month, "Month"),
(DateComponent::Quarter, "Quarter"),
];
fn step_kind(step: &WizardStep) -> WizardStepKind {
match step {
WizardStep::Preview => WizardStepKind::Preview,
WizardStep::SelectArrayPath => WizardStepKind::SelectArrayPath,
WizardStep::ReviewProposals => WizardStepKind::ReviewProposals,
WizardStep::ConfigureDates => WizardStepKind::ConfigureDates,
WizardStep::DefineFormulas => WizardStepKind::DefineFormulas,
WizardStep::NameModel => WizardStepKind::NameModel,
WizardStep::Done => WizardStepKind::Done,
}
}
fn field_kind(kind: &FieldKind) -> WizardFieldKind {
match kind {
FieldKind::Category => WizardFieldKind::Category,
FieldKind::Measure => WizardFieldKind::Measure,
FieldKind::TimeCategory => WizardFieldKind::TimeCategory,
FieldKind::Label => WizardFieldKind::Label,
}
}
/// One-line description of the workbook awaiting confirmation. It lived in the
/// widget, which is how a renderer came to be calling `Model` accessors.
fn summarize_workbook(workbook: &Workbook) -> String {
let categories = workbook.model.regular_category_names().len();
let measures = workbook.model.measure_item_names().len();
format!(
"{} — {categories} categories, {measures} measures",
workbook.model.name
)
}
// ── the grid diff ───────────────────────────────────────────────────────
/// The updates that carry `mirror` to `desired`. Labels first, cells last, diffed
/// against the *framed* mirror — the fold is not commutative (gotcha 33).
pub fn grid_updates(mirror: &GridCache, desired: &GridCache) -> Vec<GridUpdate> {
let mut updates = Vec::new();
if mirror.extent != desired.extent {
updates.push(GridUpdate::Extent(desired.extent));
}
if mirror.records != desired.records {
updates.push(GridUpdate::Records(desired.records));
}
if mirror.row_frame != desired.row_frame {
updates.push(GridUpdate::RowFrame(desired.row_frame.clone()));
}
if mirror.col_frame != desired.col_frame {
updates.push(GridUpdate::ColFrame(desired.col_frame.clone()));
}
if mirror.col_widths != desired.col_widths {
updates.push(GridUpdate::ColWidths(desired.col_widths.clone()));
}
// Totals are diffed against the framed mirror too (improvise-hkt6); the widths
// stay on `mirror`. Both halves are argued in `context/code/command.md`.
let mut framed = mirror.clone();
for update in &updates {
framed.apply(update);
}
if framed.totals != desired.totals {
let totals = GridUpdate::Totals(desired.totals.clone());
framed.apply(&totals);
updates.push(totals);
}
if mirror.page_coords != desired.page_coords {
updates.push(GridUpdate::PageCoords(desired.page_coords.clone()));
}
let mut changes = Vec::new();
for (row, cells) in desired.cells.iter().enumerate() {
for (col, cell) in cells.iter().enumerate() {
if framed.cell(row, col) != Some(cell) {
changes.push(((row, col), cell.clone()));
}
}
}
if !changes.is_empty() {
updates.push(GridUpdate::Cells(changes));
}
updates
}
// ── per-client sessions ─────────────────────────────────────────────────
/// One connected client, as the server sees it: the mirror of its [`ViewState`],
/// kept by folding the same commands it folds, and the grid it is believed to hold.
#[derive(Debug, Clone)]
pub struct ClientSession {
view: ViewState,
extent: (usize, usize),
grid: GridCache,
/// The basis this client was last told (ADR 001, improvise-fkfg). Without
/// publication the check would be satisfiable only by forging.
basis: BasisCache,
/// The title bar's mirror. Diffed rather than gated, because `Save` clears
/// `dirty` and is `ViewOnly` — no `EffectKind` can see it (improvise-z754).
document: DocumentCache,
projected: Option<Viewport>,
/// The view the mirror's grid was last built from. A switch is `ViewOnly`, so
/// `Dirty` cannot carry it and this client's own move is **observed** instead.
projected_view: Option<String>,
/// The document generation this mirror was last seeded at. When the app's
/// has moved past it, the whole mirror belongs to a document that is gone.
generation: u64,
}
impl ClientSession {
pub fn new(view: ViewState, rows: usize, cols: usize, generation: u64) -> Self {
Self {
view,
extent: (rows, cols),
grid: GridCache::default(),
basis: BasisCache::default(),
document: DocumentCache::default(),
projected: None,
projected_view: None,
generation,
}
}
/// Fold a client→server command into this client's mirrored view slice — the
/// same function the client runs. Private: [`Sessions::apply`] is the door.
fn fold(&mut self, cmd: &Command) {
reduce_view(&mut self.view, cmd);
self.settle_mode();
}
/// Settle against the grid this mirror **holds**, after a fold and after
/// every projection that moves it (improvise-e5pf/el9h).
fn settle_mode(&mut self) {
self.view.mode = improvise_protocol::settled_mode(&self.view.mode, self.grid.records);
}
/// The server's mirror of this client's view slice.
pub fn view(&self) -> &ViewState {
&self.view
}
/// Tell the server how much of the grid this client can see. Called by
/// the host on connect and on resize.
pub fn set_extent(&mut self, rows: usize, cols: usize) {
self.extent = (rows, cols);
}
pub fn viewport(&self) -> Viewport {
let cursor = self.view.cursor();
Viewport {
row_offset: cursor.row_offset,
col_offset: cursor.col_offset,
rows: self.extent.0,
cols: self.extent.1,
}
}
/// Everything this client must be told. A mirror seeded against a replaced
/// document is reseeded rather than diffed (gotcha 24).
pub fn project(&mut self, app: &App, dirty: Dirty) -> Vec<Projection> {
match self.generation == app.document_generation() {
true => self.project_diff(app, dirty),
false => self.reseed(app),
}
}
fn project_diff(&mut self, app: &App, dirty: Dirty) -> Vec<Projection> {
// Every projection is view-dependent, so a client that has moved is owed
// its whole widget set, not the batch's `Dirty` (improvise-jytm).
let moved = self.projected_view.as_deref() != Some(self.view.active_view.as_str());
let dirty = match moved {
true => Dirty::EVERYTHING,
false => dirty,
};
let view = self.view_in(app);
let mut out = projections_in(app, view, dirty);
out.extend(self.document_update(app).map(Projection::Document));
let viewport = self.viewport();
if dirty.grid || self.projected != Some(viewport) {
let desired = grid_in(app, &self.view.active_view, viewport);
out.extend(
grid_updates(&self.grid, &desired)
.into_iter()
.map(Projection::Grid),
);
self.grid = desired;
self.projected = Some(viewport);
self.projected_view = Some(self.view.active_view.clone());
// The client re-settles on every fold, this projection included, so
// a mirror that settled only in `fold` diverges (improvise-el9h).
self.settle_mode();
}
out.extend(self.basis_update(app).map(Projection::Basis));
out
}
/// The title bar, if it moved. Observed rather than declared, for the reason
/// `record_versions` is (gotcha 40): no classification names it.
fn document_update(&mut self, app: &App) -> Option<DocumentCache> {
let desired = document_of(app);
match self.document == desired {
true => None,
false => {
self.document = desired.clone();
Some(desired)
}
}
}
/// The document's view this client is on, falling back to the default for a
/// slice naming a view the document no longer has.
fn view_in<'a>(&self, app: &'a App) -> &'a View {
app.resolve_view(&self.view.active_view).1
}
/// The basis to publish, if it moved. Emitted **after** the grid updates it
/// describes, and only for the records this client's grid names (ADR 002).
fn basis_update(&mut self, app: &App) -> Option<Basis> {
let desired = published_basis(app, &self.grid);
match self.basis.current() == Some(&desired) {
true => None,
false => {
self.basis.apply(&desired);
Some(desired)
}
}
}
/// Point this mirror at the current document holding nothing, and return the
/// projections that carry it back — a compaction, not a replay.
fn reseed(&mut self, app: &App) -> Vec<Projection> {
self.view = fresh_view(app);
self.generation = app.document_generation();
self.grid = GridCache::default();
self.basis = BasisCache::default();
self.document = DocumentCache::default();
self.projected = None;
self.projected_view = None;
self.project_diff(app, Dirty::EVERYTHING)
}
}
/// Identifies one connected client. Opaque on purpose: a transport maps it
/// to whatever it calls a connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SessionId(pub u64);
/// Every client attached to one authoritative `App`.
#[derive(Debug, Default, Clone)]
pub struct Sessions {
next_id: u64,
sessions: BTreeMap<SessionId, ClientSession>,
}
/// One relayed command and the clients it is addressed to. The audience is part of
/// the message because a host cannot infer it from the command.
#[derive(Debug, Clone, PartialEq)]
pub enum Broadcast {
/// Every client, the asker included: a **consequence**, not a correction. A
/// `Command::Model` is never folded optimistically (improvise-gvnb).
ToAll(Command),
/// One named client: a correction of what only that client's UI holds. Its two
/// cases are a refused switch and a deleted view (improvise-6c6q, improvise-jytm).
To(SessionId, Command),
}
/// Is a client on `active_view` looking at a grid this batch renumbered? Empty
/// means "not known", never "nobody" — `context/code/command.md` (improvise-nynk).
fn cursor_invalidated(reshaped: &std::collections::BTreeSet<String>, active_view: &str) -> bool {
reshaped.is_empty() || reshaped.contains(active_view)
}
/// What one client's command did, as the host needs to hear it. An enum, so a host
/// that wants the [`Dirty`] must name the other cases (gotcha 39).
#[derive(Debug, Clone, PartialEq)]
pub enum Applied {
/// The document is still the same one. `broadcast` is a `Vec` because one
/// command can owe two relays with different audiences.
Diff {
dirty: Dirty,
broadcast: Vec<Broadcast>,
},
/// The document was replaced and every client owes a bootstrap. No `Dirty`: a
/// diff against the old document would describe nothing that still exists.
Rebased {
snapshots: Vec<(SessionId, Snapshot)>,
},
/// The batch's basis had moved and **nothing was applied** (ADR 001). A normal
/// outcome, addressed to the asker alone — nothing happened.
Rejected { reason: Rejection },
}
impl Sessions {
/// Attach a client and hand it the bootstrap [`Snapshot`]: a fresh view slice
/// pointed at the document's active view, plus the seed it folds to catch up.
pub fn connect(&mut self, app: &App, rows: usize, cols: usize) -> (SessionId, Snapshot) {
let id = SessionId(self.next_id);
self.next_id += 1;
let mut session =
ClientSession::new(fresh_view(app), rows, cols, app.document_generation());
let seed = session.reseed(app);
// The snapshot's view is the mirror's, cloned — not a second value built to
// the same recipe, which would start out disagreeing with the server.
let snapshot = Snapshot {
view: session.view().clone(),
seed,
};
self.sessions.insert(id, session);
(id, snapshot)
}
pub fn disconnect(&mut self, id: SessionId) -> Option<ClientSession> {
self.sessions.remove(&id)
}
pub fn get_mut(&mut self, id: SessionId) -> Option<&mut ClientSession> {
self.sessions.get_mut(&id)
}
/// One mirror, read-only. `ClientSession::fold` is private, so this hands
/// out the server's picture of a client without handing out a way to move it.
pub fn get(&self, id: SessionId) -> Option<&ClientSession> {
self.sessions.get(&id)
}
/// One command answered and applied. A host answers the whole frame first, so
/// this is the tests' door, not a host's (improvise-s17o, `context/code/command.md`).
#[cfg(test)]
fn apply(&mut self, app: &mut App, id: SessionId, cmd: &Command) -> Applied {
match Checked::against(app, cmd) {
Ok(checked) => self.apply_checked(app, id, checked),
Err(reason) => Applied::Rejected { reason },
}
}
/// The one door for a command arriving from a connected client (gotcha 36). It
/// takes the `App`, and `Host::commands` answered the frame first (improvise-955n).
pub fn apply_checked(&mut self, app: &mut App, id: SessionId, cmd: Checked<'_>) -> Applied {
match cmd.command() {
Command::SwitchView(asked) => self.apply_switch(app, id, asked),
Command::Model { .. } => self.apply_model(app, cmd),
other => {
if let Some(session) = self.sessions.get_mut(&id) {
session.fold(other);
}
Applied::Diff {
dirty: Dirty::NOTHING,
broadcast: Vec::new(),
}
}
}
}
/// Which view a client is on is **that client's** (improvise-jytm): only the
/// asker's mirror moves, and only to a view the document has.
fn apply_switch(&mut self, app: &App, id: SessionId, asked: &str) -> Applied {
let Some(session) = self.sessions.get_mut(&id) else {
return Applied::Diff {
dirty: Dirty::NOTHING,
broadcast: Vec::new(),
};
};
let leaving = session.view.active_view.clone();
// The mirror folds what the client folded, refused or not: a correction
// can only undo what BOTH ends hold (ChatGPT Codex, PR #115).
reduce_view(&mut session.view, &Command::SwitchView(asked.to_string()));
let refused = !app.model_state.workbook.views.contains_key(asked);
// Asking for the view you are on moves nothing, so there is nothing to
// undo and `ViewBack` would pop somebody else's frame.
let moved = leaving != asked;
let broadcast = match refused && moved {
false => Vec::new(),
// The exact inverse of the fold, to the asker alone — why it is not
// a `SwitchView` back: `context/code/command.md` (improvise-6c6q).
true => [Command::ViewBack, Command::ForgetView(asked.to_string())]
.into_iter()
.map(|command| {
reduce_view(&mut session.view, &command);
Broadcast::To(id, command)
})
.collect(),
};
Applied::Diff {
dirty: Dirty::NOTHING,
broadcast,
}
}
/// The document half of a [`Command::Model`] (improvise-kmgs). Same lockstep as
/// `apply_switch`, but `ToAll` — see `context/code/command.md`.
fn apply_model(&mut self, app: &mut App, cmd: Checked<'_>) -> Applied {
let generation = app.document_generation();
let epoch = app.cursor_epoch();
let before = view_names(app);
let dirty = apply_command(app, cmd);
match app.document_generation() == generation {
true => {
let mut broadcast = self.adopt_deleted_views(app);
broadcast.extend(self.forget_vanished_views(app, before));
broadcast.extend(self.adopt_cursor_epoch(app, epoch));
Applied::Diff { dirty, broadcast }
}
// Whether a `WholeWorkbook` command really replaced the document is not
// a property of the variant, so ask the generation (gotcha 24).
false => Applied::Rebased {
snapshots: self.rebase(app),
},
}
}
/// A view that has stopped existing leaves every client's history. Observed
/// by diffing the names — `context/code/command.md`, gotchas 24 and 39.
fn forget_vanished_views(
&mut self,
app: &App,
before: std::collections::BTreeSet<String>,
) -> Vec<Broadcast> {
let after = view_names(app);
before
.into_iter()
.filter(|name| !after.contains(name))
.map(|name| {
let command = Command::ForgetView(name);
for session in self.sessions.values_mut() {
reduce_view(&mut session.view, &command);
}
// `ToAll`: any client may have BEEN there, the asker included,
// and a client that was not folds a no-op.
Broadcast::ToAll(command)
})
.collect()
}
/// A mirror naming a view the batch deleted is moved to the document's default
/// and told. Observed, never predicted — see `context/code/command.md`.
fn adopt_deleted_views(&mut self, app: &App) -> Vec<Broadcast> {
let workbook = &app.model_state.workbook;
self.sessions
.iter_mut()
.filter(|(_, session)| !workbook.views.contains_key(&session.view.active_view))
.map(|(id, session)| {
let command = Command::SwitchView(workbook.active_view.clone());
reduce_view(&mut session.view, &command);
Broadcast::To(*id, command)
})
.collect()
}
/// If the batch renumbered the grid, invalidate the mirrors that were looking
/// at it **and** say so on the wire; neither half works alone (improvise-gvnb).
fn adopt_cursor_epoch(&mut self, app: &App, before: u64) -> Vec<Broadcast> {
// The epoch is the HOST's cursor, so it is blind to a view the host is not
// on — which a view-named command reframes (improvise-lltt).
let host_moved = app.cursor_epoch() != before;
let elsewhere = app.last_reframed_elsewhere();
if !host_moved && elsewhere.is_empty() {
return Vec::new();
}
// The REFRAME set, not the reshape set: a cursor is invalidated by the
// rows moving, which is a different question from the basis's (ni6k).
let reshaped = match host_moved {
true => app.last_reframed_views(),
false => elsewhere,
};
self.sessions
.iter_mut()
.filter(|(_, session)| cursor_invalidated(reshaped, &session.view.active_view))
.map(|(id, session)| {
reduce_view(&mut session.view, &Command::ResetCursor);
Broadcast::To(*id, Command::ResetCursor)
})
.collect()
}
/// Re-attach every session to a document that has just replaced theirs, and
/// hand each client the [`Snapshot`] only it can reset its own cursors with.
pub fn rebase(&mut self, app: &App) -> Vec<(SessionId, Snapshot)> {
self.sessions
.iter_mut()
.map(|(id, session)| {
let seed = session.reseed(app);
(
*id,
Snapshot {
view: session.view().clone(),
seed,
},
)
})
.collect()
}
/// [`Sessions::apply`] for a client that is fully caught up, for the tests whose
/// subject is not the basis. The staleness tests build the [`Basis`] by hand.
#[cfg(test)]
fn apply_caught_up(
&mut self,
app: &mut App,
id: SessionId,
cmd: improvise_protocol::ModelCommand,
) -> Applied {
let wire = crate::ui::reduce::caught_up(app, cmd);
self.apply(app, id, &wire)
}
pub fn ids(&self) -> Vec<SessionId> {
self.sessions.keys().copied().collect()
}
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
/// What to dispatch to each client after a batch. Clients with nothing
/// to hear are omitted rather than sent an empty list.
pub fn project_all(&mut self, app: &App, dirty: Dirty) -> Vec<(SessionId, Vec<Projection>)> {
self.sessions
.iter_mut()
.map(|(id, session)| (*id, session.project(app, dirty)))
.filter(|(_, projections)| !projections.is_empty())
.collect()
}
}
/// Every view name the document has, as a set a later reading can be diffed
/// against. The observation `forget_vanished_views` is built on.
fn view_names(app: &App) -> std::collections::BTreeSet<String> {
app.model_state.workbook.views.keys().cloned().collect()
}
/// The view slice a newly-connected client starts from: empty, but pointed at the
/// document's active view, because that is what keys its cursor map (gotcha 29).
fn fresh_view(app: &App) -> ViewState {
// Pointed, not switched: a fold would record a visit to `Default` (improvise-lxg0).
ViewState {
active_view: app.model_state.workbook.active_view.clone(),
..ViewState::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::cell::{CellKey, CellValue};
use crate::ui::effect::{
AddFormula, Effect, RemoveCategory, RemoveItem, SetAxis, SetCell, ViewTarget,
};
use crate::workbook::Workbook;
use improvise_protocol::{
BasisCache, CategoryTreeCache, DocumentCache, FormulaCache, TileBarCache, ViewListCache,
WidgetCache,
};
use std::path::{Path, PathBuf};
/// A client, as a client really is: independent caches, each folding only
/// the projections addressed to it.
#[derive(Debug, Default, PartialEq)]
struct ClientCaches {
grid: GridCache,
category_tree: CategoryTreeCache,
formulas: FormulaCache,
view_list: ViewListCache,
tile_bar: TileBarCache,
document: DocumentCache,
basis: BasisCache,
}
impl ClientCaches {
fn fold(&mut self, projections: &[Projection]) {
for projection in projections {
match projection {
Projection::Grid(u) => self.grid.apply(u),
Projection::CategoryTree(u) => self.category_tree.apply(u),
Projection::Formulas(u) => self.formulas.apply(u),
Projection::ViewList(u) => self.view_list.apply(u),
Projection::TileBar(u) => self.tile_bar.apply(u),
Projection::Document(u) => self.document.apply(u),
Projection::Basis(u) => self.basis.apply(u),
}
}
}
}
/// What the server believes the client should hold, built straight from the
/// authoritative state — the yardstick every fold is measured against.
fn server_truth(app: &App, viewport: Viewport) -> ClientCaches {
let grid = grid_of(app, viewport);
let mut basis = BasisCache::default();
basis.apply(&published_basis(app, &grid));
ClientCaches {
grid,
category_tree: category_tree_of(app),
formulas: formulas_of(app),
view_list: view_list_of(app),
tile_bar: tile_bar_of(app),
document: document_of(app),
basis,
}
}
// ── the title bar (improvise-z754) ──────────────────────────────────
/// A document with somewhere to be saved, so `Save` can succeed and the
/// dirty marker can be seen to clear.
fn stored_app() -> App {
let mut wb = Workbook::new("Corner Coffee");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").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);
let mut app = App::with_storage(
wb,
Some(PathBuf::from("/home/someone/private/books.improv")),
Box::new(improvise_io::storage::MemoryFs::new()),
);
app.rebuild_layout();
app
}
/// A remote client has no use for the host's directories, and a socket
/// server would be handing them out. `context/test-evidence/command.md`.
#[test]
fn the_title_bar_names_the_document_without_naming_the_path() {
let doc = document_of(&stored_app());
assert_eq!(doc.name, "Corner Coffee");
assert_eq!(doc.stored_as.as_deref(), Some("books.improv"));
assert!(
!doc.dirty,
"a freshly opened document has no unsaved changes"
);
}
fn document_updates(projections: &[Projection]) -> Vec<&DocumentCache> {
projections
.iter()
.filter_map(|p| match p {
Projection::Document(d) => Some(d),
_ => None,
})
.collect()
}
/// The sharp arm: `Save` is `ViewOnly`, so `Dirty::of` reports NOTHING and
/// no classification can gate this widget. `context/test-evidence/command.md`.
#[test]
fn a_save_clears_the_dirty_marker_on_a_client_although_nothing_is_dirty() {
let mut app = stored_app();
let mut sessions = Sessions::default();
sessions.connect(&app, 10, 10);
sessions.project_all(&app, Dirty::EVERYTHING);
let edit = SetCell(
CellKey::new(vec![
("Region".to_string(), "North".to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(1.0),
);
let dirty = app.apply_effects(vec![Box::new(edit)]);
let raised = sessions.project_all(&app, dirty);
let raised = document_updates(&raised[0].1);
assert_eq!(
raised.iter().map(|d| d.dirty).collect::<Vec<_>>(),
vec![true],
"an edit must tell the client the document has unsaved changes"
);
let cleared = app.apply_effects(vec![Box::new(crate::ui::effect::Save)]);
assert_eq!(
cleared,
Dirty::NOTHING,
"Save is ViewOnly — if this ever changes, the test below proves nothing"
);
let after = sessions.project_all(&app, cleared);
let after = document_updates(&after[0].1);
assert_eq!(
after.iter().map(|d| d.dirty).collect::<Vec<_>>(),
vec![false],
"the marker must clear on the client, and no EffectKind says so"
);
}
/// Nothing moved, so nothing is sent: the diff is what keeps an ungated
/// widget from projecting on every batch.
#[test]
fn a_title_bar_that_did_not_move_is_not_projected() {
let app = stored_app();
let mut sessions = Sessions::default();
sessions.connect(&app, 10, 10);
sessions.project_all(&app, Dirty::EVERYTHING);
let again = sessions.project_all(&app, Dirty::NOTHING);
assert!(
again.is_empty(),
"an unchanged title bar must not be re-sent: {again:?}"
);
}
/// `.improv` carries `nan` as a value (improvise-55v2); the NaN sits at
/// (1, 1), off the origin. Evidence file.
fn app_with_a_nan_cell() -> App {
let wb = crate::persistence::parse_md(
"v2025-04-09\n# NaN\nInitial View: Default\n\n\
## View: Default\nRegion: row\n_Measure: column\n_Index: none\n_Dim: none\n\n\
## Category: _Measure\n- Revenue, Cost\n\n\
## Category: Region\n- North, South\n\n\
## Data\n\
Region=North, _Measure=Revenue = 1\n\
Region=North, _Measure=Cost = 2\n\
Region=South, _Measure=Revenue = 3\n\
Region=South, _Measure=Cost = nan\n",
)
.expect("a document holding nan parses");
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// A NaN cell compares unequal to itself under `CellValue`'s `PartialEq`,
/// so the grid never quiesced (improvise-4w09). Evidence file.
#[test]
fn a_grid_holding_nan_quiesces() {
let app = app_with_a_nan_cell();
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows: 10,
cols: 10,
},
);
assert!(
matches!(grid.cell(1, 1).and_then(|c| c.value.clone()),
Some(CellValue::Number(n)) if n.is_nan()),
"precondition: the NaN must be projected, or nothing below is tested"
);
assert_eq!(
grid_updates(&grid, &grid),
vec![],
"a grid diffed against itself owes the client nothing"
);
assert!(
GridCache::folded(&grid.seed()) == grid,
"the seed must compact to the cache it came from (gotcha 32)"
);
let mut sessions = Sessions::default();
sessions.connect(&app, 10, 10);
sessions.project_all(&app, Dirty::EVERYTHING);
let again = sessions.project_all(&app, Dirty::EVERYTHING);
let grid_again: Vec<_> = again
.iter()
.flat_map(|(_, projections)| projections)
.filter(|p| matches!(p, Projection::Grid(_)))
.collect();
assert!(
grid_again.is_empty(),
"an unchanged grid must not be re-sent: {grid_again:?}"
);
}
/// Region on Row with `East` in a group that comes **last**, so the header is
/// the final entry and nothing follows it: `axis_frame`'s trailing rule.
fn app_with_a_trailing_group(collapsed: bool) -> App {
let mut wb = Workbook::new("Grouped");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").unwrap();
wb.add_item_in_group("Region", "East", "Late").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 item in ["North", "South", "East"] {
let key = CellKey::new(vec![
("Region".to_string(), item.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]);
wb.model.set_cell(key, CellValue::Number(1.0));
}
if collapsed {
wb.active_view_mut().toggle_group_collapse("Region", "Late");
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
fn frame_shape(frame: &improvise_protocol::AxisFrame) -> Vec<String> {
frame
.entries
.iter()
.map(|e| match e {
FrameEntry::Group(g) => format!("[{}]", g.name),
FrameEntry::GroupEnd => "[/]".to_string(),
FrameEntry::Item(v) => v.join("+"),
})
.collect()
}
/// 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);
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows: 20,
cols: 20,
},
);
assert_eq!(
grid.row_frame.len(),
3,
"East folds into the collapsed group's one row"
);
assert_eq!(
frame_shape(&grid.row_frame),
vec!["North", "South", "[Late]", "Late"],
"the header, then the row the group collapsed into"
);
}
/// The other arm: a window that stops short of the last row carries nothing
/// below its last item, or a mid-document browser draws the bottom.
#[test]
fn a_window_that_stops_short_does_not_carry_what_is_below_it() {
let app = app_with_a_trailing_group(true);
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows: 1,
cols: 20,
},
);
assert_eq!(
frame_shape(&grid.row_frame),
vec!["North"],
"South and the group below it are outside this window"
);
}
/// A window opening inside a group still names it, and **only the nearest**
/// 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_a_collapsed_group_between_rows();
let grid = grid_of(
&app,
Viewport {
row_offset: 2,
col_offset: 0,
rows: 20,
cols: 20,
},
);
assert_eq!(
frame_shape(&grid.row_frame),
vec!["[Late]", "East"],
"the nearest header above the window comes with it; `Hidden` is \
above that and is not carried — nothing above the window is \
painted, so only the one `group_of` answers with is worth sending"
);
let group = grid.row_frame.group_of(0).expect("East is in Late");
assert_eq!(group.name, "Late");
assert!(!group.collapsed);
assert!(
matches!(grid.row_frame.body_from(0), [FrameEntry::Item(_)]),
"but a renderer starts at the item, not at the header above it"
);
}
/// 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();
wb.add_item_in_group("Region", "Inner", "Hidden").unwrap();
wb.add_item_in_group("Region", "East", "Late").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 item in ["North", "Inner", "East"] {
let key = CellKey::new(vec![
("Region".to_string(), item.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]);
wb.model.set_cell(key, CellValue::Number(1.0));
}
wb.active_view_mut()
.toggle_group_collapse("Region", "Hidden");
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// Headers between two in-window rows all come, each above the row it opens.
/// Evidence: context/test-evidence/command.md.
#[test]
fn every_header_between_two_in_window_rows_comes() {
let app = app_with_a_collapsed_group_between_rows();
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows: 20,
cols: 20,
},
);
assert_eq!(
frame_shape(&grid.row_frame),
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 {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
for item in ["North", "South", "East"] {
wb.add_item("Region", item).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
for (row, item) in ["North", "South", "East"].iter().enumerate() {
for (col, measure) in ["Revenue", "Cost"].iter().enumerate() {
let key = CellKey::new(vec![
("Region".to_string(), (*item).to_string()),
("_Measure".to_string(), (*measure).to_string()),
]);
wb.model
.set_cell(key, CellValue::Number((row * 10 + col) as f64));
}
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// A pivot-shaped default view plus a records-shaped second one, so a
/// switch between them crosses the boundary `settled_mode` watches.
fn app_with_a_records_shaped_second_view() -> App {
let mut app = app_with_data();
// Cloned, not `View::new`: a fresh view has no axis entry for the
// virtual categories, so `is_records_mode` never fires on one.
let mut records = app.model_state.workbook.active_view().clone();
records.name = "Records".to_string();
records.set_axis("_Index", Axis::Row);
records.set_axis("_Dim", Axis::Column);
records.set_axis("Region", Axis::None);
records.set_axis("_Measure", Axis::None);
app.model_state
.workbook
.views
.insert("Records".to_string(), records);
app.rebuild_layout();
assert!(
grid_in(&app, "Records", full_viewport()).records,
"the second view must really reach records mode, or the test \
asserts a settle that had nothing to settle"
);
app
}
/// A document with the views `names`, opening on `active`.
fn opening_on(names: &[&str], active: &str) -> Workbook {
let mut wb = Workbook::new("Fresh");
for name in names.iter().filter(|n| **n != "Default") {
wb.create_view(name.to_string());
}
wb.active_view = active.to_string();
if !names.contains(&"Default") {
wb.delete_view("Default")
.expect("setup: drop the Default view");
}
wb
}
/// improvise-lxg0: a new session has visited nothing, and `<` must not
/// take it anywhere. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_fresh_session_has_no_history_to_step_back_through() {
for names in [&["Default", "Second"][..], &["Main", "Second"][..]] {
let app = App::new(opening_on(names, "Second"), None);
let mut sessions = Sessions::default();
let (_, snapshot) = sessions.connect(&app, 10, 10);
assert_eq!(snapshot.view.active_view, "Second", "setup: {names:?}");
assert_eq!(snapshot.view.view_back_stack, vec![], "{names:?}");
let mut client = snapshot.view.clone();
reduce_view(&mut client, &Command::ViewBack);
assert_eq!(
client.active_view, "Second",
"`<` moved a fresh {names:?} client"
);
}
}
/// improvise-v8ab: a fresh client starts from the host's own startup slice.
/// See `context/test-evidence/command.md`.
#[test]
fn a_fresh_sessions_slice_is_the_hosts_startup_slice() {
for (names, records) in [
(&["Default", "Second"][..], false),
(&["Main", "Second"][..], false),
(&["Main", "Second"][..], true),
] {
let mut wb = opening_on(names, "Second");
if records {
// `Second` opens on a records grid (improvise-y5eo).
wb.active_view_mut().set_axis("_Index", Axis::Row);
wb.active_view_mut().set_axis("_Dim", Axis::Column);
}
let app = App::new(wb, None);
assert_eq!(app.view_state.active_view, "Second", "setup: {names:?}");
assert_eq!(app.layout.is_records_mode(), records, "setup: {names:?}");
let (_, snapshot) = Sessions::default().connect(&app, 10, 10);
assert_eq!(
snapshot.view, app.view_state,
"{names:?}, records {records}"
);
}
}
/// improvise-lxg0: a document swap reseeds through the same door.
#[test]
fn a_rebased_session_has_no_history_to_step_back_through() {
for names in [&["Default", "Second"][..], &["Main", "Second"][..]] {
let mut app = App::new(opening_on(&["Default", "Other"], "Default"), None);
let mut sessions = Sessions::default();
sessions.connect(&app, 10, 10);
app.install_workbook(opening_on(names, "Second"));
let snapshots = sessions.rebase(&app);
assert_eq!(snapshots.len(), 1, "setup: one session rebased");
let view = &snapshots[0].1.view;
assert_eq!(view.active_view, "Second", "setup: {names:?}");
assert_eq!(view.view_back_stack, vec![], "{names:?}");
}
}
/// improvise-el9h: the mirror must settle against the grid it ends up
/// holding. Evidence: `context/test-evidence/command.md`.
#[test]
fn the_mirror_settles_its_mode_against_every_grid_it_is_later_projected() {
use improvise_protocol::AppMode;
let mut app = app_with_a_records_shaped_second_view();
let mut sessions = Sessions::default();
let (id, snapshot) = sessions.connect(&app, 100, 100);
let mut client = improvise_protocol::ClientState::default();
client.bootstrap(snapshot);
assert_eq!(
client.view.mode,
AppMode::Normal,
"the fixture must START on the pivot view, or the settle has \
nowhere to move the mode from"
);
for cmd in [
Command::SwitchView("Records".to_string()),
Command::ChangeMode(AppMode::Normal),
] {
sessions.apply(&mut app, id, &cmd);
}
for (_, projections) in sessions.project_all(&app, Dirty::EVERYTHING) {
for projection in projections {
client.fold(&Command::Project(projection));
}
}
assert_eq!(
client.view.mode,
AppMode::RecordsNormal,
"the CLIENT re-settles on every fold, the projected grid included, \
so it is the yardstick and must have moved"
);
assert_eq!(
sessions.get_mut(id).map(|s| s.view().mode.clone()).as_ref(),
Some(&client.view.mode),
"and the mirror is the same function run on the same stream — it \
settled against the grid it was LAST projected and never again"
);
}
/// The yardstick `server_truth` used to be, spelled out instead of derived:
/// see `context/test-evidence/command.md`.
#[test]
fn the_published_basis_names_the_records_the_grid_draws_and_no_others() {
let mut app = app_with_data();
let named = |app: &App| -> Vec<RecordHash> {
published_basis(app, &grid_of(app, full_viewport()))
.records
.into_iter()
.map(|(hash, _)| hash)
.collect()
};
let by_hand = |item: &str| {
RecordHash::of(&CellKey::new(vec![(
"Region".to_string(),
item.to_string(),
)]))
};
let mut expected = vec![by_hand("North"), by_hand("South"), by_hand("East")];
expected.sort();
assert_eq!(
named(&app),
expected,
"six record cells over three records: the basis names the records, once each"
);
// A regular category on None makes every cell an aggregate (ADR 002), and
// an aggregate names no record, so there is nothing to claim a version for.
app.model_state.workbook.add_category("Channel").unwrap();
app.model_state
.workbook
.add_item("Channel", "Online")
.unwrap();
app.model_state
.workbook
.active_view_mut()
.set_axis("Channel", Axis::None);
app.rebuild_layout();
assert_eq!(
app.model_state.workbook.active_view().axis_of("Channel"),
Axis::None,
"the fixture only says anything if the new category really lands on None"
);
assert!(
named(&app).is_empty(),
"an aggregate grid names no record, so it publishes no record tier"
);
}
fn apply(app: &mut App, effects: Vec<Box<dyn Effect>>) -> Dirty {
app.apply_effects(effects)
}
fn full_viewport() -> Viewport {
Viewport {
row_offset: 0,
col_offset: 0,
rows: 100,
cols: 100,
}
}
// ── what the producer emits reproduces what the server has ──────────
/// The contract of the whole module: a client that folds what the server sends
/// holds what the server has. Not the fold axiom — the yardstick is independent.
#[test]
fn folding_what_the_server_emits_reproduces_what_the_server_has() {
let mut app = app_with_data();
let mut sessions = Sessions::default();
let (id, snapshot) = sessions.connect(&app, 100, 100);
let mut client = ClientCaches::default();
client.fold(&snapshot.seed);
assert_eq!(client, server_truth(&app, full_viewport()));
let steps: Vec<Vec<Box<dyn Effect>>> = vec![
vec![Box::new(SetCell(
CellKey::new(vec![
("Region".to_string(), "North".to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(999.0),
))],
vec![Box::new(AddFormula {
raw: "Profit = Revenue - Cost".to_string(),
target_category: "_Measure".to_string(),
})],
vec![Box::new(SetAxis {
view: ViewTarget::Active,
category: "Region".to_string(),
axis: Axis::Column,
})],
];
for effects in steps {
let dirty = apply(&mut app, effects);
let outbound = sessions.project_all(&app, dirty);
for (target, projections) in outbound {
assert_eq!(target, id);
client.fold(&projections);
}
assert_eq!(
client,
server_truth(&app, full_viewport()),
"the client drifted from the server"
);
}
}
/// Removing a category drops every formula targeting it, and `LayoutGeometry`
/// reported `formulas: false` — a client panel wrong forever (gotcha 35).
#[test]
fn removing_a_category_empties_the_clients_formula_panel() {
let mut app = app_with_data();
let mut sessions = Sessions::default();
let (_, snapshot) = sessions.connect(&app, 100, 100);
let mut client = ClientCaches::default();
client.fold(&snapshot.seed);
let dirty = apply(
&mut app,
vec![Box::new(AddFormula {
raw: "Total = Revenue".to_string(),
target_category: "Region".to_string(),
})],
);
for (_, projections) in sessions.project_all(&app, dirty) {
client.fold(&projections);
}
assert_eq!(
client
.formulas
.formulas
.iter()
.map(|f| f.raw.as_str())
.collect::<Vec<_>>(),
vec!["Total = Revenue"],
"the client must be holding the formula before it can lose it"
);
let dirty = apply(&mut app, vec![Box::new(RemoveCategory("Region".into()))]);
for (_, projections) in sessions.project_all(&app, dirty) {
client.fold(&projections);
}
assert!(
app.model_state.workbook.model.formulas().is_empty(),
"the document dropped the formula with its target category"
);
assert_eq!(
client.formulas.formulas,
Vec::<FormulaEntry>::new(),
"the client is still showing a formula the document no longer has"
);
}
/// The item-level twin of the test above. Evidence:
/// `context/test-evidence/command.md`.
#[test]
fn removing_a_formulas_target_item_empties_the_clients_formula_panel() {
let mut app = app_with_data();
apply(
&mut app,
vec![Box::new(AddFormula {
raw: "Total = Revenue".to_string(),
target_category: "_Measure".to_string(),
})],
);
// The formula predates the session, so the client holds it from the
// BOOTSTRAP SEED and not from a CellData projection (improvise-en4s).
let mut sessions = Sessions::default();
let (_, snapshot) = sessions.connect(&app, 100, 100);
let mut client = ClientCaches::default();
client.fold(&snapshot.seed);
assert_eq!(
client
.formulas
.formulas
.iter()
.map(|f| f.raw.as_str())
.collect::<Vec<_>>(),
vec!["Total = Revenue"],
"the client must be holding the formula before it can lose it"
);
let effect = RemoveItem {
category: "_Measure".to_string(),
item: "Total".to_string(),
};
assert_eq!(
effect.kind(),
EffectKind::Model(ModelChange::CellData),
"this test is about the CellData arm; reclassify it and it stops being that"
);
let dirty = apply(&mut app, vec![Box::new(effect)]);
for (_, projections) in sessions.project_all(&app, dirty) {
client.fold(&projections);
}
assert!(
app.model_state.workbook.model.formulas().is_empty(),
"the document dropped the formula with its target item"
);
assert_eq!(
client.formulas.formulas,
Vec::<FormulaEntry>::new(),
"the client is still showing a formula the document no longer has"
);
}
/// Bootstrap is a compaction, not a replay: a late client folding only the seed
/// lands where one that followed the whole stream is.
#[test]
fn a_late_client_converges_with_one_that_followed_from_the_start() {
let mut app = app_with_data();
let mut sessions = Sessions::default();
let (_, early_snapshot) = sessions.connect(&app, 100, 100);
let mut early = ClientCaches::default();
early.fold(&early_snapshot.seed);
let dirty = apply(
&mut app,
vec![Box::new(SetCell(
CellKey::new(vec![
("Region".to_string(), "South".to_string()),
("_Measure".to_string(), "Cost".to_string()),
]),
CellValue::Number(42.0),
))],
);
for (_, projections) in sessions.project_all(&app, dirty) {
early.fold(&projections);
}
let (_, late_snapshot) = sessions.connect(&app, 100, 100);
let mut late = ClientCaches::default();
late.fold(&late_snapshot.seed);
assert_eq!(early, late, "seeding late must land where following did");
assert_eq!(
late_snapshot.seed.len(),
12,
"the seed is a compaction — four document widgets, the title bar, the grid's extent and its frame (labels, widths, totals), its cells, and the basis that lets this client compose a write at all — not the history. No page-coords update: this fixture pages nothing, and the diff against a fresh mirror omits what already matches"
);
}
/// A cell edit outside a client's window costs that client nothing.
#[test]
fn a_cell_outside_the_viewport_produces_no_grid_traffic() {
let mut app = app_with_data();
let mut sessions = Sessions::default();
// One row, one column: only ("North", "Revenue") is visible.
let (_, _) = sessions.connect(&app, 1, 1);
let dirty = apply(
&mut app,
vec![Box::new(SetCell(
CellKey::new(vec![
("Region".to_string(), "East".to_string()),
("_Measure".to_string(), "Cost".to_string()),
]),
CellValue::Number(7.0),
))],
);
let outbound = sessions.project_all(&app, dirty);
let grid_updates: Vec<_> = outbound
.iter()
.flat_map(|(_, p)| p.iter())
.filter(|p| matches!(p, Projection::Grid(_)))
.collect();
assert!(
grid_updates.is_empty(),
"an off-screen edit reached the grid: {grid_updates:?}"
);
}
/// The extent is the **whole** grid, not the window (improvise-bpq2). The
/// fixture is not square and the window smaller in both directions.
#[test]
fn the_projected_extent_is_the_whole_grid_not_the_window() {
let app = app_with_data();
let cache = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows: 1,
cols: 1,
},
);
assert_eq!(cache.row_frame.len(), 1, "precondition: a real window");
assert_eq!(cache.col_frame.len(), 1);
assert_eq!(cache.extent, GridExtent { rows: 3, cols: 2 });
}
/// ...and one inside it costs exactly the cell.
#[test]
fn a_cell_inside_the_viewport_produces_exactly_that_cell() {
let mut app = app_with_data();
let mut sessions = Sessions::default();
sessions.connect(&app, 2, 2);
let dirty = apply(
&mut app,
vec![Box::new(SetCell(
CellKey::new(vec![
("Region".to_string(), "South".to_string()),
("_Measure".to_string(), "Cost".to_string()),
]),
CellValue::Number(7.0),
))],
);
let cells: Vec<_> = sessions
.project_all(&app, dirty)
.into_iter()
.flat_map(|(_, p)| p)
.filter_map(|p| match p {
Projection::Grid(GridUpdate::Cells(changes)) => Some(changes),
_ => None,
})
.flatten()
.collect();
assert_eq!(cells.len(), 1, "expected one changed cell, got {cells:?}");
assert_eq!(cells[0].0, (1, 1));
assert_eq!(cells[0].1.text, "7.00");
}
/// Scrolling is `ViewOnly` — no other client hears it — but the client
/// that scrolled must be sent the region it can now see.
#[test]
fn scrolling_sends_the_newly_visible_region_to_that_client_alone() {
let mut app = app_with_data();
let mut sessions = Sessions::default();
let (scroller, _) = sessions.connect(&app, 1, 2);
let (bystander, _) = sessions.connect(&app, 1, 2);
sessions.apply(&mut app, scroller, &Command::SetRowOffset(2));
let outbound = sessions.project_all(&app, Dirty::NOTHING);
assert_eq!(outbound.len(), 1, "only the scroller hears a scroll");
assert_eq!(outbound[0].0, scroller);
assert!(
!sessions.ids().is_empty() && outbound[0].0 != bystander,
"the bystander must be silent"
);
let labels: Vec<_> = outbound[0]
.1
.iter()
.filter_map(|p| match p {
Projection::Grid(GridUpdate::RowFrame(f)) => Some(f.leaf_labels()),
_ => None,
})
.collect();
assert_eq!(
labels,
vec![vec!["East".to_string()]],
"the third row's label must frame the cells that follow it"
);
}
/// The fold is not commutative: labels must precede the cells they
/// frame, or the consumer drops them.
#[test]
fn labels_are_emitted_before_the_cells_they_frame() {
let mirror = GridCache::default();
let desired = GridCache {
row_frame: AxisFrame::flat(["r0"]),
col_frame: AxisFrame::flat(["c0"]),
col_widths: vec![8],
cells: vec![vec![CellDisplay::number("1")]],
..GridCache::default()
};
let updates = grid_updates(&mirror, &desired);
let first_cells = updates
.iter()
.position(|u| matches!(u, GridUpdate::Cells(_)))
.expect("a cell update");
for (i, update) in updates.iter().enumerate() {
if matches!(update, GridUpdate::RowFrame(_) | GridUpdate::ColFrame(_)) {
assert!(
i < first_cells,
"labels must come before cells: {updates:?}"
);
}
}
assert_eq!(GridCache::folded(&updates), desired);
}
/// Reshaping is the case the ordering exists for. Growing bites: cells at
/// positions the client lacks are dropped, so labels must go first.
#[test]
fn a_reshaped_grid_folds_to_the_desired_state() {
let small = GridCache {
row_frame: AxisFrame::flat(["r0"]),
col_frame: AxisFrame::flat(["c0"]),
col_widths: vec![8],
cells: vec![vec![CellDisplay::number("1")]],
..GridCache::default()
};
let large = GridCache {
row_frame: AxisFrame::flat(["r0", "r1"]),
col_frame: AxisFrame::flat(["c0", "c1"]),
col_widths: vec![8, 8],
cells: vec![
vec![CellDisplay::number("1"), CellDisplay::number("2")],
vec![CellDisplay::number("3"), CellDisplay::number("4")],
],
..GridCache::default()
};
for (from, to) in [(&small, &large), (&large, &small)] {
let mut client = from.clone();
for update in &grid_updates(from, to) {
client.apply(update);
}
assert_eq!(&client, to, "reshaping {from:?} into {to:?} lost cells");
}
}
/// improvise-hkt6 (Copilot, PR 20): a column **renamed** while its total stayed
/// the same number left the client's total blank forever. See the evidence.
#[test]
fn a_renamed_column_whose_total_did_not_change_still_gets_its_total_back() {
let totals = || {
vec![
CellDisplay::number("10"),
CellDisplay::number("20"),
CellDisplay::number("30"),
]
};
let mirror = GridCache {
row_frame: AxisFrame::flat(["r0"]),
col_frame: AxisFrame::flat(["A", "B", "C"]),
col_widths: vec![8, 8, 8],
cells: vec![vec![
CellDisplay::number("1"),
CellDisplay::number("2"),
CellDisplay::number("3"),
]],
totals: totals(),
..GridCache::default()
};
let desired = GridCache {
col_frame: AxisFrame::flat(["A", "Bee", "C"]),
..mirror.clone()
};
let mut client = mirror.clone();
for update in &grid_updates(&mirror, &desired) {
client.apply(update);
}
assert_eq!(
client, desired,
"the updates must carry the mirror to desired, totals included"
);
}
/// The producer half of the category-rename rule: an axis swap leaves the labels
/// identical and the `CellId`s pointing at the other record.
#[test]
fn renaming_an_axis_category_resends_every_cell() {
let cell = |n: &str| CellDisplay::number(n);
let mirror = GridCache {
row_frame: AxisFrame {
first: 0,
cats: vec!["Region".into()],
entries: vec![FrameEntry::Item(vec!["North".into()])],
},
col_frame: AxisFrame::flat(["A", "B"]),
col_widths: vec![8, 8],
cells: vec![vec![cell("1"), cell("2")]],
totals: vec![cell("10"), cell("20")],
..GridCache::default()
};
let desired = GridCache {
row_frame: AxisFrame {
first: 0,
cats: vec!["Department".into()],
entries: mirror.row_frame.entries.clone(),
},
..mirror.clone()
};
assert_eq!(
desired.row_frame.items().collect::<Vec<_>>(),
mirror.row_frame.items().collect::<Vec<_>>(),
"precondition: only `cats` differs, so a producer comparing \
labels sees nothing"
);
let updates = grid_updates(&mirror, &desired);
let resent: Vec<(usize, usize)> = updates
.iter()
.filter_map(|u| match u {
GridUpdate::Cells(changes) => Some(changes),
_ => None,
})
.flatten()
.map(|((r, c), _)| (*r, *c))
.collect();
assert_eq!(
resent,
vec![(0, 0), (0, 1)],
"every cell answers a coordinate that has been re-identified"
);
let mut client = mirror.clone();
for update in &updates {
client.apply(update);
}
assert_eq!(
client, desired,
"and the client ends holding exactly what the server has"
);
}
/// A second view whose axes are the first's transposed, so the two grids cannot
/// be confused: "Default" rows are regions, "Second" rows are measures.
fn app_with_two_views() -> App {
let mut app = app_with_data();
let second = app.model_state.workbook.create_view("Second");
second.set_axis("_Measure", Axis::Row);
second.set_axis("Region", Axis::Column);
app
}
/// A client's grid is built from *its own* view, never the document's default
/// (improvise-jytm). See `context/test-evidence/command.md`.
#[test]
fn a_session_that_switches_views_is_projected_the_view_it_switched_to() {
let mut app = app_with_two_views();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let applied = sessions.apply(&mut app, id, &Command::SwitchView("Second".into()));
let (dirty, _) = diff(applied);
assert_eq!(
sessions.get_mut(id).unwrap().view().active_view,
"Second",
"the switch did not reach the asker's mirror"
);
assert_eq!(
app.model_state.workbook.active_view, "Default",
"one client's switch moved the document's default"
);
let labels: Vec<_> = sessions
.project_all(&app, dirty)
.into_iter()
.flat_map(|(_, p)| p)
.filter_map(|p| match p {
Projection::Grid(GridUpdate::RowFrame(f)) => Some(f.leaf_labels()),
_ => None,
})
.collect();
assert_eq!(
labels,
vec![vec!["Revenue".to_string(), "Cost".to_string()]],
"the client was sent the rows of the view it switched away from"
);
}
/// The model is shared and the UI is not (improvise-jytm): a bystander keeps
/// its view, its scroll and its grid when somebody else switches.
#[test]
fn a_view_switch_moves_only_the_switchers_mirror() {
let mut app = app_with_two_views();
let mut sessions = Sessions::default();
let (switcher, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
// The bystander is scrolled, so a mirror reset shows up as a moved
// offset rather than coincidentally matching the origin.
sessions.apply(&mut app, bystander, &Command::SetRowOffset(1));
// Drain what the scroll itself owes, so the fan-out below names only
// what the switch caused.
sessions.project_all(&app, Dirty::NOTHING);
let applied = sessions.apply(&mut app, switcher, &Command::SwitchView("Second".into()));
assert_eq!(
sessions.get_mut(bystander).unwrap().view().active_view,
"Default",
"somebody else's switch moved this client's view"
);
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
1,
"somebody else's switch moved this client's scroll"
);
assert_eq!(
diff(applied).1,
Vec::new(),
"an accepted switch is the asker's own business and relays nothing"
);
let heard: Vec<SessionId> = sessions
.project_all(&app, Dirty::NOTHING)
.into_iter()
.map(|(id, _)| id)
.collect();
assert_eq!(
heard,
vec![switcher],
"a bystander was repainted for a view it is not on"
);
}
/// A refused switch is a correction owed to the **asker alone** — the one
/// client that folded it (improvise-6c6q). Driven through the asker's slice.
#[test]
fn a_refused_switch_corrects_the_asker_and_nobody_else() {
let mut app = app_with_two_views();
let mut sessions = Sessions::default();
let (asker, snapshot) = sessions.connect(&app, 100, 100);
let (bystander, bystander_snapshot) = sessions.connect(&app, 100, 100);
let mut client = snapshot.view.clone();
// The bystander sits on the OTHER view, so a relay addressed to everyone
// moves it; on the default it would fold to a no-op and prove nothing.
let mut other = bystander_snapshot.view.clone();
let elsewhere = Command::SwitchView("Second".into());
reduce_view(&mut other, &elsewhere);
sessions.apply(&mut app, bystander, &elsewhere);
let asked = Command::SwitchView("Nonexistent".into());
reduce_view(&mut client, &asked);
assert_eq!(client.active_view, "Nonexistent", "the asker folded it");
let applied = sessions.apply(&mut app, asker, &asked);
assert_eq!(
diff(applied.clone()).1,
vec![
// The inverse of the fold, not a jump to the default: the
// history has to come back too (Codex, PR #115).
Broadcast::To(asker, Command::ViewBack),
Broadcast::To(asker, Command::ForgetView("Nonexistent".into())),
],
"the correction is addressed to somebody other than the asker"
);
host_dispatch(&mut client, &applied, asker);
host_dispatch(&mut other, &applied, bystander);
assert_eq!(
client.active_view, "Default",
"the asker was left naming a view the document does not have"
);
assert_eq!(
client.active_view,
sessions.get_mut(asker).unwrap().view().active_view,
"the asker and the server's mirror of it have drifted"
);
assert_eq!(
other.active_view, "Second",
"a bystander was told about somebody else's refused switch"
);
}
/// A refused switch must leave no trace, and `active_view` is not the whole
/// trace (ChatGPT Codex, PR #115). See `context/test-evidence/command.md`.
#[test]
fn a_refused_switch_puts_no_phantom_on_the_history() {
let mut app = app_with_two_views();
app.model_state.workbook.create_view("Third");
let mut sessions = Sessions::default();
let (asker, snapshot) = sessions.connect(&app, 100, 100);
// Somewhere to go forward to, so the forward stack is not vacuously
// equal: a refusal must not silently eat it either.
let mut client = snapshot.view.clone();
for step in [
Command::SwitchView("Second".into()),
Command::SwitchView("Third".into()),
Command::ViewBack,
] {
reduce_view(&mut client, &step);
sessions.apply(&mut app, asker, &step);
}
let before = client.clone();
assert!(
!before.view_back_stack.is_empty() && !before.view_forward_stack.is_empty(),
"the fixture must load BOTH stacks, or this cannot see a lost one"
);
let asked = Command::SwitchView("Nonexistent".into());
reduce_view(&mut client, &asked);
let applied = sessions.apply(&mut app, asker, &asked);
host_dispatch(&mut client, &applied, asker);
assert!(
!client
.view_back_stack
.iter()
.chain(client.view_forward_stack.iter())
.any(|f| f.view_name == "Nonexistent"),
"a view the document never had is on the history, and `<` will land on it"
);
assert_eq!(
(&client.active_view, &client.view_back_stack),
(&before.active_view, &before.view_back_stack),
"a refused switch moved the asker or changed what it can go back to"
);
// The one cost, shared with an ACCEPTED switch: the optimistic fold
// clears the redo stack and no correction can put it back.
assert!(
client.view_forward_stack.is_empty(),
"the forward stack is cleared by the fold; a correction must not refill it"
);
let mirror = sessions.get_mut(asker).unwrap().view().clone();
assert_eq!(
(
&client.active_view,
&client.view_back_stack,
&client.view_forward_stack
),
(
&mirror.active_view,
&mirror.view_back_stack,
&mirror.view_forward_stack
),
"the asker and the server's mirror of it hold different histories"
);
}
/// The category panel and the tile bar carry a category's **axis**, which is a
/// fact about the view — so they follow the client too (improvise-jytm).
#[test]
fn a_session_on_another_view_is_projected_that_views_axes() {
let mut app = app_with_two_views();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let applied = sessions.apply(&mut app, id, &Command::SwitchView("Second".into()));
let (dirty, _) = diff(applied);
let projections: Vec<Projection> = sessions
.project_all(&app, dirty)
.into_iter()
.flat_map(|(_, p)| p)
.collect();
let axes = |name: &str| {
projections.iter().find_map(|p| match p {
Projection::CategoryTree(CategoryTreeUpdate::Categories(rows)) => Some(
rows.iter()
.find(|row| row.name == name)
.unwrap_or_else(|| panic!("{name} is not in the category tree"))
.axis,
),
_ => None,
})
};
let tile = |name: &str| {
projections.iter().find_map(|p| match p {
Projection::TileBar(tiles) => Some(
tiles
.iter()
.find(|t| t.category == name)
.unwrap_or_else(|| panic!("{name} is not on the tile bar"))
.axis,
),
_ => None,
})
};
// "Second" is "Default" transposed, so reading the host's view instead of
// the session's swaps both of these rather than merely blurring them.
assert_eq!(
(axes("Region"), axes("_Measure")),
(Some(Axis::Column), Some(Axis::Row)),
"the category panel was built from the view this client is not on"
);
assert_eq!(
(tile("Region"), tile("_Measure")),
(Some(Axis::Column), Some(Axis::Row)),
"the tile bar was built from the view this client is not on"
);
}
/// Every client's history is pruned, not just the deleter's — the bystander
/// here is the one that holds the frame. See `context/test-evidence/command.md`.
#[test]
fn a_deleted_view_leaves_the_history_of_a_client_that_did_not_delete_it() {
use improvise_protocol::ModelCommand;
let mut app = app_with_two_views();
app.model_state.workbook.create_view("Third");
let mut sessions = Sessions::default();
let (deleter, _) = sessions.connect(&app, 100, 100);
let (visitor, visitor_snapshot) = sessions.connect(&app, 100, 100);
// The visitor has BEEN to Second and left, so Second is on its stack
// and it is not sitting on the view — `adopt_deleted_views` cannot see it.
let mut held = visitor_snapshot.view.clone();
for name in ["Second", "Third"] {
let switch = Command::SwitchView(name.into());
reduce_view(&mut held, &switch);
sessions.apply(&mut app, visitor, &switch);
}
assert!(
held.view_back_stack.iter().any(|f| f.view_name == "Second"),
"the fixture must put the doomed view on a BYSTANDER's stack"
);
let applied =
sessions.apply_caught_up(&mut app, deleter, ModelCommand::DeleteView("Second".into()));
assert!(
diff(applied.clone())
.1
.contains(&Broadcast::ToAll(Command::ForgetView("Second".into()))),
"the forgetting must reach every client, not only the one that asked"
);
host_dispatch(&mut held, &applied, visitor);
assert!(
!held.view_back_stack.iter().any(|f| f.view_name == "Second"),
"a bystander is still offered a step back into a deleted view"
);
assert_eq!(
held.active_view, "Third",
"the forgetting moved a client that was not on the deleted view"
);
}
/// The property the forgetting buys, stated where it is felt: a step back
/// cannot reach a deleted view. See `context/test-evidence/command.md`.
#[test]
fn a_step_back_cannot_land_on_a_view_the_document_has_lost() {
use improvise_protocol::ModelCommand;
let mut app = app_with_two_views();
app.model_state.workbook.create_view("Third");
let mut sessions = Sessions::default();
let (stepper, stepper_snapshot) = sessions.connect(&app, 100, 100);
// Third, then Second, then back to Default: the stack is
// [Default, Third, Second] and only the top frame is doomed.
let mut stepped = stepper_snapshot.view.clone();
for name in ["Third", "Second", "Default"] {
let switch = Command::SwitchView(name.into());
reduce_view(&mut stepped, &switch);
sessions.apply(&mut app, stepper, &switch);
}
let applied =
sessions.apply_caught_up(&mut app, stepper, ModelCommand::DeleteView("Second".into()));
assert!(
!app.model_state.workbook.views.contains_key("Second"),
"the fixture must actually delete the view, or this proves nothing"
);
host_dispatch(&mut stepped, &applied, stepper);
reduce_view(&mut stepped, &Command::ViewBack);
sessions.apply(&mut app, stepper, &Command::ViewBack);
// Third, not Default: a test landing on the default could not tell
// "skipped the dead frame" from "gave up and went home".
assert_eq!(
stepped.active_view, "Third",
"the step landed on the deleted view, or on nothing at all"
);
assert_eq!(
sessions.get_mut(stepper).unwrap().view().active_view,
stepped.active_view,
"the mirror and the client disagree about where the step landed"
);
}
/// A per-client view can be deleted out from under the client that is on it.
/// The mirror is moved and the client told — see `context/test-evidence/command.md`.
#[test]
fn deleting_a_view_moves_the_client_that_was_on_it_and_tells_it() {
use improvise_protocol::ModelCommand;
let mut app = app_with_two_views();
app.model_state.workbook.create_view("Third");
let mut sessions = Sessions::default();
let (switcher, switcher_snapshot) = sessions.connect(&app, 100, 100);
let (bystander, bystander_snapshot) = sessions.connect(&app, 100, 100);
let mut moved = switcher_snapshot.view.clone();
let switch = Command::SwitchView("Second".into());
reduce_view(&mut moved, &switch);
sessions.apply(&mut app, switcher, &switch);
// The bystander sits on a view the delete does NOT remove, and not on the
// default: a `ToAll` relay would move it, where being on the default hides that.
let mut stayed = bystander_snapshot.view.clone();
let elsewhere = Command::SwitchView("Third".into());
reduce_view(&mut stayed, &elsewhere);
sessions.apply(&mut app, bystander, &elsewhere);
let applied = sessions.apply_caught_up(
&mut app,
bystander,
ModelCommand::DeleteView("Second".into()),
);
assert_eq!(
diff(applied.clone()).1,
vec![
Broadcast::To(switcher, Command::SwitchView("Default".into())),
// Everyone's history loses the view; only its occupant is moved.
Broadcast::ToAll(Command::ForgetView("Second".into())),
],
"the move is addressed to somebody other than the client that lost its view"
);
host_dispatch(&mut moved, &applied, switcher);
host_dispatch(&mut stayed, &applied, bystander);
assert!(
!app.model_state.workbook.views.contains_key("Second"),
"the fixture must actually delete the view, or this proves nothing"
);
assert_eq!(
sessions.get_mut(switcher).unwrap().view().active_view,
"Default",
"a mirror still names a view the document has not got"
);
assert_eq!(
moved.active_view, "Default",
"the client was projected the default view while its own slice named a deleted one"
);
assert_eq!(
stayed.active_view, "Third",
"a client on a surviving view was yanked off it by somebody else's delete"
);
assert_eq!(
sessions.get_mut(bystander).unwrap().view().active_view,
"Third",
"the bystander's mirror was moved off a view the document still has"
);
}
/// A client connecting after somebody else has switched opens the document's
/// default, not that client's view (improvise-jytm).
#[test]
fn a_fresh_session_opens_the_documents_default_view() {
let mut app = app_with_two_views();
let mut sessions = Sessions::default();
let (first, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, first, &Command::SwitchView("Second".into()));
let (_, snapshot) = sessions.connect(&app, 100, 100);
assert_eq!(
snapshot.view.active_view, app.model_state.workbook.active_view,
"a fresh client did not open the document's default view"
);
assert_eq!(snapshot.view.active_view, "Default");
}
/// `reset_cursor` reached only the authoritative `App`'s slice, so every client
/// was left indexing a reshaped grid — a blank one (improvise-gvnb).
#[test]
fn a_geometry_command_invalidates_every_clients_cursor_including_the_askers() {
use improvise_protocol::ModelCommand;
let mut app = app_with_data();
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
// Both are positioned inside the grid that is about to be renumbered,
// so a stale offset is visible rather than coincidentally right.
sessions.apply(&mut app, asker, &Command::SetRowOffset(1));
sessions.apply(&mut app, bystander, &Command::SetRowOffset(2));
let view = app.view_name().to_string();
let applied =
sessions.apply_caught_up(&mut app, asker, ModelCommand::TransposeAxes { view });
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
0,
"a bystander indexes a grid that has been renumbered under it"
);
assert_eq!(
sessions.get_mut(asker).unwrap().view().cursor().row_offset,
0,
"the asker indexes a grid that has been renumbered under it"
);
assert_eq!(
diff(applied).1,
vec![
Broadcast::To(asker, Command::ResetCursor),
Broadcast::To(bystander, Command::ResetCursor),
],
"the mirrors were reset and the clients were never told, so the \
diff now believes each of them already holds origin-positioned \
content and will send nothing — stale forever rather than until \
the next scroll"
);
}
/// Two categories on Row, one grouped, one item hidden and one item with no
/// data — so every command below has something real to reshape (improvise-9mo9).
fn app_with_a_reshapeable_grid() -> App {
let mut wb = Workbook::new("Reshape");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").unwrap();
wb.add_category("Product").unwrap();
wb.add_item_in_group("Product", "Widget", "Hardware")
.unwrap();
wb.add_item_in_group("Product", "Gadget", "Hardware")
.unwrap();
wb.add_item("Product", "Service").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.add_category("Channel").unwrap();
wb.add_item("Channel", "Direct").unwrap();
// `Profit` is a _Measure item only through the formula (gotcha 7), and
// `Channel` is parked off the grid: both are cases a narrower fixture hides.
wb.model.add_formula(
improvise_formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
{
let v = wb.active_view_mut();
v.set_axis("Region", Axis::Row);
v.set_axis("Product", Axis::Row);
v.set_axis("_Measure", Axis::Column);
v.set_axis("Channel", Axis::None);
v.hide_item("Product", "Gadget");
// A hidden name the model never had: `hide_item` records any string,
// so showing it again is a no-op only a `drawn` check can see.
v.hide_item("Region", "Ghost");
}
// `Service` is left empty on purpose: it is what `TogglePruneEmpty` prunes.
for region in ["North", "South"] {
for product in ["Widget"] {
for measure in ["Revenue", "Cost"] {
let key = CellKey::new(vec![
("Region".to_string(), region.to_string()),
("Product".to_string(), product.to_string()),
("_Measure".to_string(), measure.to_string()),
]);
wb.model.set_cell(key, CellValue::Number(1.0));
}
}
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// Which cell each grid position names. A command that does not change this has
/// not renumbered anything, so a reset assertion over it would prove nothing.
fn grid_shape(app: &App) -> String {
format!("{:?}|{:?}", app.layout.row_items, app.layout.col_items)
}
/// Two views, both pruning, so a cell write can prune a row out of the view
/// the host is NOT on. Evidence: `context/test-evidence/command.md`.
fn app_with_two_pruning_views() -> App {
let mut wb = Workbook::new("Pruned");
wb.add_category("Region").unwrap();
for region in ["North", "Service", "South"] {
wb.add_item("Region", region).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
for region in ["North", "Service", "South"] {
wb.model.set_cell(
CellKey::new(vec![
("Region".to_string(), region.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(1.0),
);
}
for name in ["Default", "Second"] {
if name != "Default" {
wb.create_view(name.to_string());
}
let v = &mut wb.views[name];
v.set_axis("Region", Axis::Row);
v.set_axis("_Measure", Axis::Column);
v.prune_empty = true;
}
wb.switch_view("Default").unwrap();
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// improvise-mcq4's sting: a `CellData` write prunes a row out of ANOTHER
/// client's view. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_write_that_prunes_a_row_out_of_another_clients_view_invalidates_it() {
use improvise_protocol::ModelCommand;
let mut app = app_with_two_pruning_views();
let full = full_viewport();
let before = format!("{:?}", grid_in(&app, "Second", full).row_frame);
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, bystander, &Command::SwitchView("Second".into()));
sessions.apply(&mut app, bystander, &Command::SetRowOffset(1));
// Clearing Service's only cell prunes its row out of BOTH views; the host
// is on Default, so only the bystander's view is the one nobody checks.
let applied = sessions.apply_caught_up(
&mut app,
asker,
ModelCommand::ClearCell(RecordCell {
record: RecordHash::of(&CellKey::new(vec![(
"Region".to_string(),
"Service".to_string(),
)])),
measure: Some("Revenue".to_string()),
}),
);
assert_ne!(
before,
format!("{:?}", grid_in(&app, "Second", full).row_frame),
"the bystander's view must really lose a row, or this proves nothing"
);
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
0,
"a row vanished under the bystander and its mirror was left positioned \
on the old grid — gotcha 24's permanently-stale case"
);
assert!(
diff(applied)
.1
.contains(&Broadcast::To(bystander, Command::ResetCursor)),
"and it was never told"
);
}
/// Three differing views, all pruning, over a model with one **actually empty**
/// row. Evidence: `context/test-evidence/command.md`.
fn app_with_three_views_and_an_empty_row() -> App {
let mut wb = Workbook::new("Independent");
wb.add_category("Region").unwrap();
wb.add_category("Product").unwrap();
for region in ["North", "Service", "South"] {
wb.add_item("Region", region).unwrap();
}
for product in ["Widget", "Gadget"] {
wb.add_item("Product", product).unwrap();
}
for measure in ["Revenue", "Cost"] {
wb.add_item("_Measure", measure).unwrap();
}
// Service gets no cell at all. With every region populated both code paths
// agree and the property below is vacuous — the trap recorded on t6db.
for region in ["North", "South"] {
for product in ["Widget", "Gadget"] {
for measure in ["Revenue", "Cost"] {
wb.model.set_cell(
CellKey::new(vec![
("Region".to_string(), region.to_string()),
("Product".to_string(), product.to_string()),
("_Measure".to_string(), measure.to_string()),
]),
CellValue::Number(1.0),
);
}
}
}
// An aggregate formula, so the views also differ in `none_cats` — the one
// input `grid_in` cannot rebuild for a view the host is not on.
wb.model.add_formula(
improvise_formula::parse_formula("Total = SUM(Revenue)", "_Measure").unwrap(),
);
for name in ["Default", "Second", "Third"] {
if name != "Default" {
wb.create_view(name.to_string());
}
let v = &mut wb.views[name];
v.set_axis("Region", Axis::Row);
match name {
"Second" => {
v.set_axis("Product", Axis::Column);
v.set_axis("_Measure", Axis::Column);
v.number_format = ",.2f".to_string();
}
"Third" => {
v.set_axis("Product", Axis::Page);
v.set_axis("_Measure", Axis::Column);
v.hide_item("_Measure", "Cost");
}
// Explicitly None, which `add_category` does NOT default to: this
// is the only view whose `none_cats` differ, and the point of it.
_ => {
v.set_axis("Product", Axis::None);
v.set_axis("_Measure", Axis::Column);
}
}
// All three prune. Turning it OFF in one view is plenty of differing
// config and proves nothing — t6db's first, passing, formulation.
v.prune_empty = true;
}
wb.switch_view("Default").unwrap();
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// improvise-jmjz: one walk now answers three questions, so a window must not
/// reach the other two. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths() {
use crate::ui::viewport::{compute_col_widths, grid_body};
let mut wb = Workbook::new("Totals");
wb.add_category("Region").unwrap();
// A ONE-CHARACTER measure name and a total that CANCELS to 1: neither the
// header nor the total can supply the width — the reviewer of PR #92.
wb.add_item("_Measure", "R").unwrap();
for (region, value) in [
("North", 888888888.0),
("South", -888888888.0),
("East", 1.0),
] {
wb.add_item("Region", region).unwrap();
wb.model.set_cell(
CellKey::new(vec![
("Region".to_string(), region.to_string()),
("_Measure".to_string(), "R".to_string()),
]),
CellValue::Number(value),
);
}
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
let mut app = App::new(wb, None);
app.rebuild_layout();
let model = &app.model_state.workbook.model;
assert_eq!(app.layout.row_count(), 3, "precondition: three rows");
let plan = improvise_core::format::FormatPlan::uniform("");
let one_row = grid_body(model, &app.layout, &plan, 0..1, 0..1);
assert_eq!(
one_row.window.len(),
1,
"precondition: the window must really be narrower than the grid"
);
assert_eq!(
one_row.totals,
vec![Some(CellValue::Number(1.0))],
"a column total sums every row, including the ones scrolled past"
);
assert_eq!(
one_row.col_widths,
vec![11],
"the width is `-888888888` plus a gap — a row OUTSIDE the window, and \
a character wider than the one inside it, so the sign is the witness"
);
assert_eq!(
one_row.col_widths,
compute_col_widths(model, &app.layout, &plan),
"the keypress path and the projection must size a column the same"
);
}
/// improvise-t6db's general property: which view the **host** happens to be on
/// is not an input to any client's grid. Evidence: `context/code/command.md`.
#[test]
fn a_clients_grid_does_not_depend_on_which_view_its_host_is_on() {
let mut app = app_with_three_views_and_an_empty_row();
assert!(
app.drill_state.is_none(),
"a frozen drill snapshot is the one thing grid_in cannot reproduce \
for a view the host is not on; the property is stated without it"
);
let full = full_viewport();
let names: Vec<String> = app.model_state.workbook.views.keys().cloned().collect();
// What each view says about itself, with the host standing on it.
let authoritative: Vec<GridCache> = names
.iter()
.map(|name| {
app.switch_to_view(name);
app.rebuild_layout();
grid_in(&app, name, full)
})
.collect();
assert!(
authoritative.iter().all(|g| g.row_frame.len() == 2),
"every view must really prune Service away, or every comparison \
below is between two answers that had nothing to disagree about"
);
// The `none_cats` carve-out is GONE with improvise-ac2z: every pair is
// compared, including the ones that aggregate over different categories.
let mut compared = 0usize;
for host in &names {
app.switch_to_view(host);
app.rebuild_layout();
for (target, expected) in names.iter().zip(&authoritative) {
compared += (host != target) as usize;
assert_eq!(
&grid_in(&app, target, full),
expected,
"host on {host}, projecting {target}"
);
}
}
assert!(
compared > 0,
"every pair was skipped, so this asserted nothing at all"
);
}
/// improvise-ac2z: a formula is evaluated for the view being PROJECTED, not for
/// whichever one the host stands on. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_formulas_columns_survive_a_host_that_aggregates_differently() {
let mut app = app_with_three_views_and_an_empty_row();
let full = full_viewport();
let totals = |grid: &GridCache| {
grid.col_frame
.items()
.filter(|levels| levels.contains(&"Total".to_string()))
.count()
};
app.switch_to_view("Second");
app.rebuild_layout();
let authoritative = grid_in(&app, "Second", full);
assert!(
totals(&authoritative) > 0,
"precondition: Second really does draw the formula's columns, or \
this compares two empties"
);
app.switch_to_view("Default");
app.rebuild_layout();
assert_ne!(
app.view().none_cats(),
app.model_state.workbook.views["Second"].none_cats(),
"precondition: the two views must aggregate over different \
categories, which is the whole input to the hazard"
);
assert_eq!(
grid_in(&app, "Second", full),
authoritative,
"the host's formula_cache decided what another view's grid draws"
);
}
/// The commands that renumber the grid. A **shared** table: the cursor audience
/// and the two shape counters must answer for the same cases (improvise-pdvm).
fn reshaping_commands(view: &str) -> Vec<improvise_protocol::ModelCommand> {
use improvise_protocol::ModelCommand;
vec![
ModelCommand::SetAxis {
view: view.into(),
category: "Region".into(),
axis: Axis::Column,
},
ModelCommand::HideItem {
view: view.into(),
category: "Region".into(),
item: "North".into(),
},
ModelCommand::ShowItem {
view: view.into(),
category: "Product".into(),
item: "Gadget".into(),
},
ModelCommand::ToggleGroup {
view: view.into(),
category: "Product".into(),
group: "Hardware".into(),
},
ModelCommand::TogglePruneEmpty { view: view.into() },
ModelCommand::AddCategory("Zone".into()),
ModelCommand::RemoveCategory("Region".into()),
ModelCommand::AddItem {
category: "Product".into(),
item: "Gizmo".into(),
},
ModelCommand::AddItemInGroup {
category: "Product".into(),
item: "Gizmo".into(),
group: "Hardware".into(),
},
ModelCommand::RemoveItem {
category: "Product".into(),
item: "Widget".into(),
},
// The class improvise-ni6k added, none of which any effect declares:
// a formula target is a `_Measure` item, so it is a column (gotcha 7).
ModelCommand::AddFormula {
raw: "Margin = Revenue - Cost".into(),
target_category: "_Measure".into(),
},
ModelCommand::RemoveFormula {
target: "Profit".into(),
target_category: "_Measure".into(),
},
]
}
/// Every model command that renumbers the grid must invalidate every cursor, and
/// the case is only evidence if the grid really moved (improvise-9mo9).
#[test]
fn every_reshaping_model_command_invalidates_every_cursor() {
let named = app_with_a_reshapeable_grid();
for cmd in reshaping_commands(named.view_name()) {
let label = format!("{cmd:?}");
let mut app = app_with_a_reshapeable_grid();
let before = grid_shape(&app);
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, bystander, &Command::SetRowOffset(1));
let applied = sessions.apply_caught_up(&mut app, asker, cmd);
assert_ne!(
before,
grid_shape(&app),
"{label} reshaped nothing, so this case is not evidence of anything"
);
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
0,
"{label} left a bystander indexing a grid that renumbered under it"
);
let broadcast = diff(applied).1;
for (who, id) in [("asker", asker), ("bystander", bystander)] {
assert!(
broadcast.contains(&Broadcast::To(id, Command::ResetCursor)),
"{label} reset the {who}'s mirror and told it nothing, which is \
worse than doing neither: the diff now believes it holds \
origin-positioned content and will send nothing"
);
}
}
}
/// The commands the document ACCEPTS while changing nothing. Shared with the
/// counter test for the reason `reshaping_commands` is (improvise-pdvm).
fn no_op_commands(view: &str) -> Vec<improvise_protocol::ModelCommand> {
use improvise_protocol::ModelCommand;
vec![
ModelCommand::SetAxis {
view: view.into(),
category: "Region".into(),
axis: Axis::Row,
},
ModelCommand::HideItem {
view: view.into(),
category: "Product".into(),
item: "Gadget".into(),
},
ModelCommand::ShowItem {
view: view.into(),
category: "Product".into(),
item: "Widget".into(),
},
ModelCommand::AddItem {
category: "Region".into(),
item: "North".into(),
},
ModelCommand::AddItemInGroup {
category: "Product".into(),
item: "Widget".into(),
group: "Hardware".into(),
},
ModelCommand::RemoveItem {
category: "Region".into(),
item: "NoSuchItem".into(),
},
ModelCommand::RemoveCategory("NoSuchCategory".into()),
// Review's cases: a name the grid never drew, and a `_Measure` item
// that exists only as a formula target (gotcha 7).
ModelCommand::SetAxis {
view: view.into(),
category: "NoSuchCategory".into(),
axis: Axis::Row,
},
ModelCommand::HideItem {
view: view.into(),
category: "Region".into(),
item: "NoSuchItem".into(),
},
ModelCommand::ShowItem {
view: view.into(),
category: "Region".into(),
item: "Ghost".into(),
},
ModelCommand::ToggleGroup {
view: view.into(),
category: "Product".into(),
group: "NoSuchGroup".into(),
},
ModelCommand::AddItem {
category: "_Measure".into(),
item: "Profit".into(),
},
]
}
/// The third negative arm: a command the document ACCEPTS but which changes
/// nothing reshaped nothing either (found in review of improvise-9mo9).
#[test]
fn an_accepted_no_op_invalidates_no_cursor() {
let named = app_with_a_reshapeable_grid();
for cmd in no_op_commands(named.view_name()) {
let label = format!("{cmd:?}");
let mut app = app_with_a_reshapeable_grid();
let before = grid_shape(&app);
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, bystander, &Command::SetRowOffset(1));
let applied = sessions.apply_caught_up(&mut app, asker, cmd);
assert_eq!(
before,
grid_shape(&app),
"{label} reshaped the grid, so it is not a no-op and proves nothing here"
);
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
1,
"{label} changed nothing and still threw a bystander's cursor away"
);
assert_eq!(diff(applied).1, vec![], "{label} invalidated the wire");
}
}
/// A grid whose rows depend on the page: `Service` has data on Q1 only, so
/// pruning drops it on Q2 and every row below it renumbers (improvise-7kvw).
fn app_with_a_page_dependent_grid() -> App {
let mut wb = Workbook::new("Paged");
wb.add_category("Region").unwrap();
wb.add_category("Quarter").unwrap();
for region in ["North", "Service", "South"] {
wb.add_item("Region", region).unwrap();
}
for quarter in ["Q1", "Q2"] {
wb.add_item("Quarter", quarter).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
{
let v = wb.active_view_mut();
v.set_axis("Region", Axis::Row);
v.set_axis("_Measure", Axis::Column);
v.set_axis("Quarter", Axis::Page);
v.set_page_selection("Quarter", "Q1");
v.prune_empty = true;
}
for (region, quarter) in [
("North", "Q1"),
("North", "Q2"),
("Service", "Q1"),
("South", "Q1"),
("South", "Q2"),
] {
let key = CellKey::new(vec![
("Region".to_string(), region.to_string()),
("Quarter".to_string(), quarter.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]);
wb.model.set_cell(key, CellValue::Number(1.0));
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// improvise-7kvw, closed as a CONSEQUENCE of improvise-ni6k rather than by its
/// own arm: `SetPageSelection` declares nothing and is observed like the rest.
#[test]
fn paging_across_a_prune_that_drops_a_row_invalidates_the_cursor() {
use improvise_protocol::ModelCommand;
let mut app = app_with_a_page_dependent_grid();
let before = grid_shape(&app);
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, bystander, &Command::SetRowOffset(1));
let view = app.view_name().to_string();
let applied = sessions.apply_caught_up(
&mut app,
asker,
ModelCommand::SetPageSelection {
view: view.clone(),
category: "Quarter".into(),
item: "Q2".into(),
},
);
assert_ne!(
before,
grid_shape(&app),
"the fixture only says anything if pruning really drops a row on Q2"
);
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
0,
"a page flip renumbered the grid and left a bystander indexing the old one"
);
let broadcast = diff(applied).1;
for (who, id) in [("asker", asker), ("bystander", bystander)] {
assert!(
broadcast.contains(&Broadcast::To(id, Command::ResetCursor)),
"the {who}'s mirror was reset and told nothing about it"
);
}
}
/// The other half of improvise-7kvw: with pruning OFF a page flip renumbers
/// nothing. Evidence: `context/test-evidence/command.md`.
#[test]
fn paging_with_nothing_pruned_invalidates_no_cursor() {
use improvise_protocol::ModelCommand;
let mut app = app_with_a_page_dependent_grid();
app.model_state.workbook.active_view_mut().prune_empty = false;
app.rebuild_layout();
let before = grid_shape(&app);
let epoch = app.cursor_epoch();
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, bystander, &Command::SetRowOffset(1));
let view = app.view_name().to_string();
let applied = sessions.apply_caught_up(
&mut app,
asker,
ModelCommand::SetPageSelection {
view: view.clone(),
category: "Quarter".into(),
item: "Q2".into(),
},
);
assert_eq!(
before,
grid_shape(&app),
"with pruning off the page flip must renumber nothing, or the \
fixture is testing the pruning case again"
);
assert_eq!(
app.cursor_epoch(),
epoch,
"PR #76 ruled that homing the cursor on every [/] with pruning off \
is worse than the hole improvise-7kvw names"
);
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
1,
"and a bystander who had scrolled keeps their place"
);
assert!(
!diff(applied)
.1
.contains(&Broadcast::To(bystander, Command::ResetCursor)),
"nothing moved, so nobody is owed an invalidation"
);
}
/// Records mode numbers rows by position, so `row_items` cannot see a
/// reorder. What each position NAMES is the question (gotcha 1).
fn record_order(app: &App) -> Vec<String> {
app.layout
.records
.iter()
.flat_map(|r| r.iter())
.map(|row| row.coords.0[0].1.clone())
.collect()
}
/// A records-mode document whose store is out of key order. The axes are set
/// DIRECTLY — see `context/test-evidence/command.md` for why `R` cannot.
fn records_app_out_of_order() -> App {
let mut wb = Workbook::new("Records");
wb.add_category("Region").unwrap();
for item in ["West", "North", "East"] {
wb.add_item("Region", item).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
for region in ["West", "North", "East"] {
wb.model.set_cell(
CellKey::new(vec![
("Region".to_string(), region.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(1.0),
);
}
{
let v = wb.active_view_mut();
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
v.set_axis("Region", Axis::None);
v.set_axis("_Measure", Axis::None);
}
let mut app = App::new(wb, None);
app.rebuild_layout();
assert!(
app.layout.is_records_mode(),
"the fixture must be in records mode"
);
app
}
/// improvise-ni6k's sharpest case: `SortData` changes neither the row count
/// nor any label. Evidence: `context/test-evidence/command.md`.
#[test]
fn sorting_the_store_in_records_mode_invalidates_the_cursor() {
use improvise_protocol::ModelCommand;
let mut app = records_app_out_of_order();
let labels_before = grid_shape(&app);
let rows_before = app.layout.row_count();
let order_before = record_order(&app);
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, bystander, &Command::SetRowOffset(1));
let applied = sessions.apply_caught_up(&mut app, asker, ModelCommand::SortData);
assert_eq!(
order_before,
vec!["West", "North", "East"],
"the fixture only says anything if the store starts out of key order"
);
assert_eq!(
record_order(&app),
vec!["East", "North", "West"],
"and only if sorting really reorders it"
);
// The two facts that make this the sharp case: a guard written as "did
// the shape change" would see nothing here.
assert_eq!(
rows_before,
app.layout.row_count(),
"the row count is equal"
);
assert_eq!(labels_before, grid_shape(&app), "and so are the labels");
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
0,
"row 0 was West and is now East, and a bystander was not told"
);
assert!(
diff(applied)
.1
.contains(&Broadcast::To(bystander, Command::ResetCursor)),
"the bystander's mirror was reset and told nothing about it"
);
}
/// The second negative arm: a command the document REFUSES reshaped nothing, so
/// it owes nobody an invalidation. Every refusable effect, not one of them.
#[test]
fn a_refused_model_command_invalidates_no_cursor() {
use improvise_protocol::ModelCommand;
let refusals = vec![
ModelCommand::AddItem {
category: "NoSuchCategory".into(),
item: "Gizmo".into(),
},
ModelCommand::AddItemInGroup {
category: "NoSuchCategory".into(),
item: "Gizmo".into(),
group: "Hardware".into(),
},
// The 12-category limit; the fixture already holds two.
ModelCommand::AddCategory("Overflow".into()),
];
for cmd in refusals {
let label = format!("{cmd:?}");
let mut app = app_with_a_reshapeable_grid();
if matches!(cmd, ModelCommand::AddCategory(_)) {
// Fill to MAX_CATEGORIES rather than counting: the fixture's own
// category count is not this test's subject and has changed once.
let filled = (0..64).take_while(|i| {
app.model_state
.workbook
.add_category(format!("Filler{i}"))
.is_ok()
});
assert!(filled.count() > 0, "the fixture is already at the limit");
app.rebuild_layout();
}
let before = grid_shape(&app);
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, bystander, &Command::SetRowOffset(1));
let applied = sessions.apply_caught_up(&mut app, asker, cmd);
assert_eq!(
before,
grid_shape(&app),
"{label} was supposed to be refused, so this case proves nothing"
);
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
1,
"{label} was refused and still threw a bystander's cursor away"
);
assert_eq!(diff(applied).1, vec![], "{label} invalidated the wire");
}
}
/// `Default` and `Second` are two differently shaped views of one document, so
/// a reshape of one is observably not a reshape of the other (improvise-nynk).
fn app_with_two_reshapeable_views() -> App {
let mut app = app_with_a_reshapeable_grid();
{
let second = app.model_state.workbook.create_view("Second");
second.set_axis("Channel", Axis::Row);
second.set_axis("_Measure", Axis::Column);
}
app.rebuild_layout();
app
}
/// improvise-e5pf: the diff carries what kind of grid this is, or a client
/// that never watched it change never learns. Evidence: command.md.
#[test]
fn the_diff_tells_a_client_the_grid_became_a_records_grid() {
let mut app = app_with_a_reshapeable_grid();
let full = full_viewport();
let ordinary = grid_of(&app, full);
assert!(
!ordinary.records,
"precondition: the fixture starts on an ordinary grid"
);
app.dispatch_key(crate::command::key::KeyEvent {
code: crate::command::key::KeyCode::Char('R'),
modifiers: crate::command::key::KeyModifiers::NONE,
});
let records = grid_of(&app, full);
assert!(records.records, "precondition: `R` reaches records mode");
assert!(
grid_updates(&ordinary, &records).contains(&GridUpdate::Records(true)),
"the client is never told, so it answers every records question wrong"
);
assert!(
!grid_updates(&records, &records).contains(&GridUpdate::Records(true)),
"an unchanged flag is resent, so the diff is not a diff"
);
}
/// improvise-lltt: the command names its view, so it lands on the asker's view
/// and not on the host's. See `context/test-evidence/command.md`.
#[test]
fn a_view_named_command_lands_on_the_view_it_names() {
use improvise_protocol::ModelCommand;
let mut app = app_with_two_reshapeable_views();
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, asker, &Command::SwitchView("Second".into()));
assert_eq!(
app.view_name(),
"Default",
"the host stays on its own view: that split is what makes this ambiguous"
);
sessions.apply_caught_up(
&mut app,
asker,
ModelCommand::SetAxis {
view: "Second".into(),
category: "Channel".into(),
axis: Axis::Page,
},
);
let views = &app.model_state.workbook.views;
assert_eq!(
views["Second"].axis_of("Channel"),
Axis::Page,
"the asker's own view never moved"
);
assert_eq!(
views["Default"].axis_of("Channel"),
Axis::None,
"the axis moved on the view the asker is NOT on"
);
}
/// improvise-lltt: naming a view the document has not got writes nothing —
/// `resolve_view`'s fallback would move the default instead.
#[test]
fn a_command_naming_a_view_the_document_has_not_got_writes_nothing() {
use improvise_protocol::ModelCommand;
let mut app = app_with_two_reshapeable_views();
let before = format!("{:?}", app.model_state.workbook.views);
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
sessions.apply_caught_up(
&mut app,
asker,
ModelCommand::SetAxis {
view: "NoSuchView".into(),
category: "Channel".into(),
axis: Axis::Page,
},
);
assert_eq!(
format!("{:?}", app.model_state.workbook.views),
before,
"a name the document has not got fell back onto a view that has one"
);
}
/// improvise-lltt: `cursor_epoch` is the host's own cursor, so it cannot
/// witness a reframe elsewhere. See `context/test-evidence/command.md`.
#[test]
fn a_reframe_of_a_view_the_host_is_not_on_invalidates_its_clients() {
use improvise_protocol::ModelCommand;
let mut app = app_with_two_reshapeable_views();
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
let (at_home, _) = sessions.connect(&app, 100, 100);
for who in [asker, bystander] {
sessions.apply(&mut app, who, &Command::SwitchView("Second".into()));
}
sessions.apply(&mut app, bystander, &Command::SetRowOffset(1));
sessions.apply(&mut app, at_home, &Command::SetRowOffset(2));
let applied = sessions.apply_caught_up(
&mut app,
asker,
ModelCommand::HideItem {
view: "Second".into(),
category: "Channel".into(),
item: "Direct".into(),
},
);
let broadcast = diff(applied).1;
assert!(
broadcast.contains(&Broadcast::To(bystander, Command::ResetCursor)),
"a client on the reframed view was never told: {broadcast:?}"
);
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
0,
"the mirror kept an offset into a grid that no longer has those rows"
);
// The negative arm, or "invalidate everyone" passes this test.
assert!(
!broadcast.contains(&Broadcast::To(at_home, Command::ResetCursor)),
"a client on `Default` was invalidated by a reframe of `Second`"
);
assert_eq!(
sessions
.get_mut(at_home)
.unwrap()
.view()
.cursor()
.row_offset,
2,
"a client on `Default` lost its place to a reframe of `Second`"
);
}
/// improvise-nynk: which view a client is on is that client's, so a reshape of
/// one view is not addressed to a client sitting on another.
#[test]
fn a_reshape_of_one_view_leaves_a_client_on_another_view_alone() {
use improvise_protocol::ModelCommand;
let mut app = app_with_two_reshapeable_views();
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, bystander, &Command::SwitchView("Second".into()));
sessions.apply(&mut app, bystander, &Command::SetRowOffset(1));
sessions.apply(&mut app, asker, &Command::SetRowOffset(1));
assert_eq!(
sessions.get_mut(bystander).unwrap().view().active_view,
"Second",
"the bystander never left the view the reshape is aimed at"
);
// Aimed at `Default` BY NAME since improvise-lltt, so `Second`'s shape is
// untouched — the operand, not an ambient one.
let applied = sessions.apply_caught_up(
&mut app,
asker,
ModelCommand::HideItem {
view: "Default".into(),
category: "Region".into(),
item: "North".into(),
},
);
let broadcast = diff(applied).1;
// The positive arm stays, or "never invalidate anyone" passes this test.
assert_eq!(
sessions.get_mut(asker).unwrap().view().cursor().row_offset,
0,
"the asker is on the reshaped view and still indexes the old grid"
);
assert!(
broadcast.contains(&Broadcast::To(asker, Command::ResetCursor)),
"the asker's mirror was reset and the asker was never told"
);
// And the negative arm, which is the ticket.
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
1,
"a reshape of `Default` threw away the cursor of a client on `Second`"
);
assert!(
!broadcast
.iter()
.any(|b| matches!(b, Broadcast::To(id, _) if *id == bystander)),
"a client on a view nothing reshaped was told its cursor is invalid"
);
assert!(
!broadcast.contains(&Broadcast::ToAll(Command::ResetCursor)),
"a cursor invalidation addressed to everybody is addressed to nobody \
in particular, which is the whole of improvise-nynk"
);
}
/// The empty set is "not known", never "nobody": a reader must fail safe, or the
/// narrowing turns into the half-fix that is worse than no fix (gotcha 24).
#[test]
fn an_unknown_reshape_set_invalidates_everybody() {
let none = std::collections::BTreeSet::new();
assert!(cursor_invalidated(&none, "Default"));
assert!(cursor_invalidated(&none, "Second"));
let default_only: std::collections::BTreeSet<String> =
["Default".to_string()].into_iter().collect();
assert!(cursor_invalidated(&default_only, "Default"));
assert!(!cursor_invalidated(&default_only, "Second"));
}
/// improvise-pdvm: `layout_version` and `cursor_epoch` answer the same question
/// -- "has the grid's shape moved under you" -- so they answer over one table.
#[test]
fn both_shape_counters_move_for_every_reshaping_command() {
let named = app_with_a_reshapeable_grid();
for cmd in reshaping_commands(named.view_name()) {
let label = format!("{cmd:?}");
let mut app = app_with_a_reshapeable_grid();
app.model_state.dirty = false;
let shape = grid_shape(&app);
let (layout, epoch) = (app.layout_version(), app.cursor_epoch());
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
sessions.apply_caught_up(&mut app, asker, cmd);
assert_ne!(
shape,
grid_shape(&app),
"{label} reshaped nothing, so this case is not evidence of anything"
);
assert!(
app.model_state.dirty,
"{label} reshaped the grid and left the document reading clean, so \
the browser worker never writes it back to OPFS"
);
assert_ne!(
app.cursor_epoch(),
epoch,
"{label} reshaped the grid and no cursor was invalidated"
);
assert_ne!(
app.layout_version(),
layout,
"{label} reshaped the grid and told every client's basis it had not, \
so a batch composed against the old shape is still accepted"
);
}
}
/// The third case the split creates: a change the file carries but the grid does
/// not draw. `dirty` must rise where `layout_version` must not (improvise-75ld).
#[test]
fn a_configuration_change_that_draws_nothing_is_still_a_document_change() {
use improvise_protocol::ModelCommand;
// `hide_item` records a name whether or not the model has one, and the
// number format is written to the file while changing no row or column.
let config_only = vec![
ModelCommand::HideItem {
view: "Default".into(),
category: "Region".into(),
item: "NoSuchItem".into(),
},
ModelCommand::SetNumberFormat {
view: "Default".into(),
format: ",.4f".into(),
},
];
for cmd in config_only {
let label = format!("{cmd:?}");
let mut app = app_with_a_reshapeable_grid();
app.model_state.dirty = false;
let shape = grid_shape(&app);
let layout = app.layout_version();
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
sessions.apply_caught_up(&mut app, asker, cmd);
assert_eq!(
shape,
grid_shape(&app),
"{label} reshaped the grid, so it is not a configuration-only change"
);
assert!(
app.model_state.dirty,
"{label} is written to the file and left the document reading clean"
);
assert_eq!(
app.layout_version(),
layout,
"{label} draws nothing new and still refused every other client's \
next batch"
);
}
}
/// The other direction, over the other table: a command that changes nothing
/// moves neither counter and does not raise `dirty` (improvise-75ld).
#[test]
fn neither_shape_counter_moves_for_an_accepted_no_op() {
let named = app_with_a_reshapeable_grid();
for cmd in no_op_commands(named.view_name()) {
let label = format!("{cmd:?}");
let mut app = app_with_a_reshapeable_grid();
app.model_state.dirty = false;
let shape = grid_shape(&app);
let (layout, epoch) = (app.layout_version(), app.cursor_epoch());
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
sessions.apply_caught_up(&mut app, asker, cmd);
assert_eq!(
shape,
grid_shape(&app),
"{label} reshaped the grid, so it is not a no-op and proves nothing here"
);
assert_eq!(app.cursor_epoch(), epoch, "{label} invalidated a cursor");
assert_eq!(
app.layout_version(),
layout,
"{label} changed nothing and still refused every other client's \
next batch with Rejection::LayoutMoved"
);
}
}
/// The other arm: a command that leaves the shape alone must not throw anyone's
/// cursor away, or "reset on every model command" passes the test above.
#[test]
fn a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone() {
use improvise_protocol::ModelCommand;
let mut app = app_with_data();
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, bystander, &Command::SetRowOffset(2));
let view = app.view_name().to_string();
let applied = sessions.apply_caught_up(
&mut app,
asker,
ModelCommand::SetNumberFormat {
view,
format: ",.2f".into(),
},
);
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
2,
"a format change moved a bystander's cursor"
);
assert_eq!(diff(applied).1, vec![]);
}
/// And the bystander really is shown the new grid from the origin. The half the
/// mirror reset cannot give: the client's slice follows what the host relays.
#[test]
fn a_bystanders_own_slice_follows_the_invalidation() {
use improvise_protocol::ModelCommand;
let mut app = app_with_data();
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, snapshot) = sessions.connect(&app, 100, 100);
let mut client = snapshot.view.clone();
let scroll = Command::SetRowOffset(2);
let applied = sessions.apply(&mut app, bystander, &scroll);
reduce_view(&mut client, &scroll);
host_dispatch(&mut client, &applied, bystander);
assert_eq!(client.cursor().row_offset, 2);
let view = app.view_name().to_string();
let applied =
sessions.apply_caught_up(&mut app, asker, ModelCommand::TransposeAxes { view });
host_dispatch(&mut client, &applied, bystander);
assert_eq!(
client.cursor().row_offset,
0,
"the client's own slice still positions it in a grid that has \
been renumbered under it"
);
assert_eq!(
client.cursor().row_offset,
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
"the client and the server's mirror of it have drifted"
);
}
/// A model command that leaves the active view alone owes no `SwitchView`
/// correction: no client folded one. It may still owe a cursor invalidation.
#[test]
fn a_model_command_that_leaves_the_active_view_alone_broadcasts_no_switch() {
use improvise_protocol::ModelCommand;
let mut app = app_with_two_views();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let applied =
sessions.apply_caught_up(&mut app, id, ModelCommand::AddCategory("Product".into()));
assert!(
app.model_state.workbook.model.category("Product").is_some(),
"the command did not reach the document"
);
let (dirty, broadcast) = diff(applied);
assert_eq!(
broadcast,
vec![Broadcast::To(id, Command::ResetCursor)],
"a new category lands on an axis in every view, so every cursor is \
invalidated — but no client folded an active view, so no SwitchView \
correction is owed"
);
assert!(
dirty.category_tree,
"a new category must reach every client's category panel"
);
}
/// The other half of the door: a client's scroll must not move the authoritative
/// `App`'s own view slice — the model is shared, the UI is not.
#[test]
fn a_clients_view_only_command_moves_only_that_clients_mirror() {
let mut app = app_with_two_views();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let (bystander, _) = sessions.connect(&app, 100, 100);
sessions.apply(&mut app, id, &Command::SetRowOffset(2));
assert_eq!(sessions.get_mut(id).unwrap().view().cursor().row_offset, 2);
assert_eq!(
sessions
.get_mut(bystander)
.unwrap()
.view()
.cursor()
.row_offset,
0,
"one client's scroll reached another"
);
assert_eq!(
app.cursor().row_offset,
0,
"one client's scroll reached the authoritative app's own slice"
);
}
/// A different document with a differently-named active view, so a mirror still
/// keyed to the outgoing one is visible rather than coincidentally right.
fn other_document() -> Workbook {
let mut wb = Workbook::new("Other");
wb.add_category("Product").unwrap();
wb.add_item("Product", "Shirts").unwrap();
wb.add_item("_Measure", "Units").unwrap();
wb.create_view("Sheet");
wb.switch_view("Sheet").unwrap();
wb.active_view_mut().set_axis("Product", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb
}
/// Replacing the document must clear every *mirror's* cursor map, not just the
/// `App`'s: a client was projected a window past the end of a new document.
#[test]
fn a_document_swap_reseeds_every_session() {
let mut app = app_with_two_views();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 2, 2);
sessions.apply(&mut app, id, &Command::SwitchView("Second".into()));
sessions.apply(&mut app, id, &Command::SetRowOffset(50));
sessions.project_all(&app, Dirty::NOTHING);
app.install_workbook(other_document());
app.rebuild_layout();
let outbound = sessions.project_all(&app, Dirty::NOTHING);
let session = sessions.get_mut(id).unwrap();
assert_eq!(
session.view().active_view,
app.model_state.workbook.active_view,
"the mirror is still keyed to the document that was replaced"
);
assert_eq!(
session.view().cursor().row_offset,
0,
"the mirror kept the outgoing document's scroll offset"
);
let labels: Vec<_> = outbound
.into_iter()
.flat_map(|(_, p)| p)
.filter_map(|p| match p {
Projection::Grid(GridUpdate::RowFrame(f)) => Some(f.leaf_labels()),
_ => None,
})
.collect();
assert_eq!(
labels,
vec![vec!["Shirts".to_string()]],
"the client was left holding an empty grid of the old document"
);
}
/// The reseed is reached from the projection path, not a hook a host must
/// remember. `rebase` is the other half: only a `Snapshot` resets the client.
#[test]
fn rebase_hands_every_session_a_snapshot_of_the_new_document() {
let mut app = app_with_two_views();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 2, 2);
sessions.apply(&mut app, id, &Command::SetRowOffset(50));
app.install_workbook(other_document());
app.rebuild_layout();
let snapshots = sessions.rebase(&app);
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].0, id);
assert_eq!(snapshots[0].1.view.active_view, "Sheet");
assert_eq!(snapshots[0].1.view.cursor().row_offset, 0);
let mut client = ClientCaches::default();
client.fold(&snapshots[0].1.seed);
assert_eq!(
client,
server_truth(&app, sessions.get_mut(id).unwrap().viewport()),
"a rebased client must hold the new document"
);
}
/// A document whose active view has the **same name** and a different shape.
/// [`other_document`]'s rename is what hides the bug — see gotcha 39.
fn a_document_shaped_differently_under_the_same_view_name() -> Workbook {
let mut wb = Workbook::new("Other");
wb.add_category("Product").unwrap();
wb.add_item("Product", "Shirts").unwrap();
wb.add_item("_Measure", "Units").unwrap();
wb.active_view_mut().set_axis("Product", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb
}
/// An `App` whose storage already holds that document, so a wire `LoadModel`
/// really replaces it rather than setting an error status.
fn app_over_a_loadable_document() -> App {
let storage = crate::storage::MemoryFs::new();
crate::persistence::save_to(
&storage,
&a_document_shaped_differently_under_the_same_view_name(),
Path::new("other.improv"),
)
.expect("the fixture document must save");
let mut app = app_with_two_views();
app.storage = Box::new(storage);
app
}
/// The two halves of an [`Applied::Diff`], for the tests whose subject is not
/// the rebase. It panics rather than defaulting, so a rebase is loud.
fn diff(applied: Applied) -> (Dirty, Vec<Broadcast>) {
match applied {
Applied::Diff { dirty, broadcast } => (dirty, broadcast),
other => panic!("expected a diff, got {other:?}"),
}
}
/// A stand-in for `Host::command`'s relay. `to` is the client being driven; the
/// audience is read off the [`Broadcast`] and nowhere else.
fn host_dispatch(client: &mut ViewState, applied: &Applied, to: SessionId) {
match applied {
Applied::Diff { broadcast, .. } => {
for item in broadcast {
match item {
Broadcast::ToAll(cmd) => reduce_view(client, cmd),
Broadcast::To(id, cmd) if *id == to => reduce_view(client, cmd),
Broadcast::To(..) => {}
}
}
}
Applied::Rebased { snapshots } => {
if let Some((_, snapshot)) = snapshots.iter().find(|(id, _)| *id == to) {
*client = snapshot.view.clone();
}
}
// Nothing happened, so there is nothing for a client to fold.
Applied::Rejected { .. } => {}
}
}
/// A document swap asked for by one client must reach every *other* client's own
/// view slice, and the host must not be able to miss it (improvise-kmgs).
#[test]
fn a_document_swap_over_the_wire_rebases_every_client() {
use improvise_protocol::ModelCommand;
let mut app = app_over_a_loadable_document();
let mut sessions = Sessions::default();
let (asker, _) = sessions.connect(&app, 100, 100);
let (bystander, snapshot) = sessions.connect(&app, 100, 100);
// The bystander scrolls inside the outgoing document. Its own slice
// and the server's mirror of it move together, as they must.
let mut client = snapshot.view.clone();
let scroll = Command::SetRowOffset(3);
let applied = sessions.apply(&mut app, bystander, &scroll);
reduce_view(&mut client, &scroll);
host_dispatch(&mut client, &applied, bystander);
assert_eq!(client.cursor().row_offset, 3);
assert_eq!(client.active_view, "Default");
// Somebody else replaces the document.
let applied = sessions.apply_caught_up(
&mut app,
asker,
ModelCommand::LoadModel(PathBuf::from("other.improv")),
);
assert_eq!(
app.model_state.workbook.model.name, "Other",
"the load did not reach the document"
);
host_dispatch(&mut client, &applied, bystander);
// The server's mirror self-heals off the generation bump; this is the other
// half, the client's slice, which only a `Snapshot` resets.
assert_eq!(
client.cursor().row_offset,
0,
"the bystander kept the outgoing document's scroll offset into a document \
one row tall (gotcha 24)"
);
assert!(
client.cursors.is_empty(),
"the outgoing document's cursor map survived the swap: {:?}",
client.cursors
);
}
/// The other arm, so the check is not one that always says "rebase". Both an
/// in-place model command and a view-only one are diffs.
#[test]
fn a_command_that_leaves_the_document_in_place_is_not_a_rebase() {
use improvise_protocol::ModelCommand;
let mut app = app_over_a_loadable_document();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let applied =
sessions.apply_caught_up(&mut app, id, ModelCommand::AddCategory("Product".into()));
assert!(
matches!(applied, Applied::Diff { .. }),
"a new category is not a new document: {applied:?}"
);
let applied = sessions.apply(&mut app, id, &Command::SetRowOffset(1));
assert!(matches!(applied, Applied::Diff { .. }), "{applied:?}");
}
/// A `LoadModel` the storage cannot answer leaves the document alone, so it is a
/// diff: reseeding on the variant would cost a client its cursors for nothing.
#[test]
fn a_load_that_fails_is_not_a_rebase() {
use improvise_protocol::ModelCommand;
let mut app = app_over_a_loadable_document();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let applied = sessions.apply_caught_up(
&mut app,
id,
ModelCommand::LoadModel(PathBuf::from("absent.improv")),
);
assert!(
matches!(applied, Applied::Diff { .. }),
"a failed load reseeded every client: {applied:?}"
);
assert!(
app.view_state.status_msg.starts_with("Load error"),
"the fixture did not actually fail the load: {:?}",
app.view_state.status_msg
);
}
/// `Dirty` is the gate that makes a `ViewOnly` batch free.
#[test]
fn view_only_effects_dirty_nothing() {
let mut app = app_with_data();
let dirty = apply(
&mut app,
vec![Box::new(crate::ui::effect::SetStatus("hi".into()))],
);
assert_eq!(dirty, Dirty::NOTHING);
let mut sessions = Sessions::default();
sessions.connect(&app, 100, 100);
assert!(sessions.project_all(&app, dirty).is_empty());
}
/// An aborted batch must not claim the effects it never applied.
#[test]
fn an_aborted_batch_reports_only_what_ran() {
let mut app = app_with_data();
let dirty = apply(
&mut app,
vec![
Box::new(crate::ui::effect::AbortChain),
Box::new(SetAxis {
view: ViewTarget::Active,
category: "Region".to_string(),
axis: Axis::Column,
}),
],
);
assert_eq!(
dirty,
Dirty::NOTHING,
"the axis change was skipped, so nothing is dirty"
);
}
}
/// Emitting and resolving the surrogate a client names a cell by (ADR 002). Both
/// halves together: an id resolved the way it was emitted agrees with nothing else.
#[cfg(test)]
mod cell_identity {
use super::*;
use crate::model::cell::{CellKey, CellValue};
use crate::ui::reduce::{apply_command, caught_up};
use crate::workbook::Workbook;
use improvise_core::view::Axis;
use improvise_protocol::ModelCommand;
/// Every command here is built by `caught_up`, so the check cannot
/// refuse one; staleness is `basis_check`'s subject, not this module's.
fn apply_checked(app: &mut App, cmd: &Command) -> Dirty {
let cleared = Checked::against(app, cmd).expect("composed against the current state");
apply_command(app, cleared)
}
const WHOLE: Viewport = Viewport {
row_offset: 0,
col_offset: 0,
rows: 20,
cols: 20,
};
/// Region on Row, `_Measure` on Column, nothing regular on None. Three rows and
/// two measures, so "the record" is not "the row" or "the cell".
fn raw_app() -> App {
let mut app = App::new(raw_workbook(), None);
app.rebuild_layout();
app
}
fn raw_workbook() -> Workbook {
let mut wb = Workbook::new("Raw");
wb.add_category("Region").unwrap();
for item in ["North", "South", "East"] {
wb.add_item("Region", item).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
for item in ["North", "South", "East"] {
for (n, measure) in ["Revenue", "Cost"].iter().enumerate() {
wb.model.set_cell(
CellKey::new(vec![
("Region".to_string(), item.to_string()),
("_Measure".to_string(), (*measure).to_string()),
]),
CellValue::Number(n as f64 + 1.0),
);
}
}
wb
}
/// The same with a second *regular* category left on None, so every visible cell
/// aggregates. That is the fork `EditOrDrill` makes and the wire projects.
fn aggregated_app() -> App {
let mut wb = Workbook::new("Aggregated");
wb.add_category("Region").unwrap();
wb.add_category("Product").unwrap();
for item in ["North", "South"] {
wb.add_item("Region", item).unwrap();
}
for item in ["Shirts", "Hats"] {
wb.add_item("Product", item).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);
wb.active_view_mut().set_axis("Product", Axis::None);
for region in ["North", "South"] {
for product in ["Shirts", "Hats"] {
wb.model.set_cell(
CellKey::new(vec![
("Region".to_string(), region.to_string()),
("Product".to_string(), product.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(10.0),
);
}
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// `raw_app` plus `Profit = Revenue - Cost`, so column 2 is formula-owned and
/// columns 0 and 1 are the raw cells that feed it.
fn formula_app() -> App {
let mut wb = raw_workbook();
wb.model.add_formula(
improvise_formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// The same document with a second regular category left on None, so the
/// formula column aggregates as well as being derived.
fn aggregating_formula_app() -> App {
let mut wb = raw_workbook();
wb.add_category("Product").unwrap();
wb.add_item("Product", "Shirts").unwrap();
wb.active_view_mut().set_axis("Product", Axis::None);
wb.model.add_formula(
improvise_formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
fn id_at(app: &App, row: usize, col: usize) -> CellId {
grid_of(app, WHOLE)
.cell(row, col)
.unwrap_or_else(|| panic!("no cell at ({row}, {col})"))
.id
.clone()
.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,
other => panic!("expected a stored cell, got {other:?}"),
}
}
/// The kind of identity *is* the permission, so the kind has to follow
/// what the cell actually is.
#[test]
fn a_stored_cell_is_projected_as_a_record() {
assert!(matches!(id_at(&raw_app(), 0, 0), CellId::Record(_)));
}
/// A formula target is a real coordinate, so the record branch claimed it and
/// the browser offered an editor. `context/test-evidence/command.md`.
#[test]
fn a_formula_owned_cell_is_projected_as_derived_and_its_siblings_are_not() {
let app = formula_app();
assert_eq!(
id_at(&app, 0, 2),
CellId::Derived {
target: "Profit".to_string()
}
);
assert!(matches!(id_at(&app, 0, 0), CellId::Record(_)));
assert!(matches!(id_at(&app, 0, 1), CellId::Record(_)));
}
/// Gotcha 10: a formula cell that *aggregates* is still drillable, so the
/// derived answer must not displace the aggregate one.
#[test]
fn an_aggregating_formula_cell_is_still_projected_as_an_aggregate() {
assert!(matches!(
id_at(&aggregating_formula_app(), 0, 2),
CellId::Aggregate(_)
));
}
#[test]
fn a_cell_that_sums_over_a_none_axis_category_is_projected_as_an_aggregate() {
assert!(matches!(
id_at(&aggregated_app(), 0, 0),
CellId::Aggregate(_)
));
}
/// Gotcha 1's unit, projected: two measure columns of one row are one record and
/// two cells. Both halves matter.
#[test]
fn the_two_measures_of_a_row_share_a_record_and_differ_by_measure() {
let app = raw_app();
let revenue = record_of_id(id_at(&app, 0, 0));
let cost = record_of_id(id_at(&app, 0, 1));
assert_eq!(revenue.record, cost.record);
assert_ne!(revenue.measure, cost.measure);
// …and a different row is a different record.
assert_ne!(revenue.record, record_of_id(id_at(&app, 1, 0)).record);
}
/// The round trip that makes a surrogate usable. Every row is checked: one row
/// cannot tell "resolves correctly" from "resolves to the first record".
#[test]
fn a_projected_record_id_resolves_to_the_cell_it_was_projected_for() {
let app = raw_app();
for row in 0..3 {
for col in 0..2 {
let id = record_of_id(id_at(&app, row, col));
assert_eq!(
app.resolve_cell(&id),
app.layout.cell_key(row, col),
"({row}, {col})"
);
}
}
}
/// A `RecordHash` is document-scoped; an `AggregateHash` cannot be, and so
/// resolves only where the host stands. Evidence: `context/test-evidence/command.md`.
#[test]
fn an_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view() {
let mut app = aggregated_app();
app.model_state.workbook.create_view("Other".to_string());
{
let v = app.model_state.workbook.views.get_mut("Other").unwrap();
v.set_axis("Product", Axis::Row);
v.set_axis("_Measure", Axis::Column);
v.set_axis("Region", Axis::None);
}
app.rebuild_layout();
let mut sessions = Sessions::default();
let (id, _snap) = sessions.connect(&app, 20, 20);
let switch = Command::SwitchView("Other".to_string());
let checked = Checked::against(&app, &switch).unwrap();
sessions.apply_checked(&mut app, id, checked);
// What the host itself is still on.
assert_eq!(app.view_name(), "Default", "the host must not have moved");
// Read the view OFF the session, so deleting the switch cannot leave
// this test green (found by review: the name was hard-coded).
let session = sessions.get(id).expect("the session we just connected");
let seen = session.view().active_view.clone();
assert_eq!(
seen, "Other",
"the switch did not move this session's mirror"
);
assert_ne!(
seen,
app.view_name(),
"then the two views are not different"
);
let grid = grid_in(&app, &seen, session.viewport());
let hash = grid
.cells
.iter()
.flatten()
.find_map(|c| match c.id.clone()? {
CellId::Aggregate(h) => Some(h),
_ => None,
})
.expect("the Other view aggregates over Region, so it has aggregates");
assert_eq!(
app.resolve_aggregate(hash),
None,
"if this resolves, the host's layout can see the session's aggregate \
and the scoping hole is not real"
);
// The control: the SAME surrogate, once the host stands where the
// session does. Without it, a blanket `None` would pass above.
app.switch_to_view("Other");
app.rebuild_layout();
assert!(
app.resolve_aggregate(hash).is_some(),
"the surrogate is resolvable in principle; only the host's VIEW made it not"
);
}
/// An aggregate names a coordinate *constraint* and must come back as the same
/// one, page and filter coordinates included (gotcha 27).
#[test]
fn a_projected_aggregate_id_resolves_to_the_same_constraint() {
let app = aggregated_app();
for row in 0..2 {
let CellId::Aggregate(hash) = id_at(&app, row, 0) else {
panic!("row {row} is not an aggregate");
};
assert_eq!(app.resolve_aggregate(hash), app.layout.cell_key(row, 0));
}
}
/// The first arm of staleness, with the identifiers held constant. The second
/// assertion is what stops a blanket `None` from passing.
#[test]
fn a_surrogate_for_a_removed_row_fails_to_resolve_and_the_others_do_not() {
let mut app = raw_app();
let east = record_of_id(id_at(&app, 2, 0));
let north = record_of_id(id_at(&app, 0, 0));
let north_key = app.resolve_cell(&north);
assert!(
north_key.is_some(),
"the fixture must resolve to begin with"
);
app.model_state.workbook.remove_item("Region", "East");
app.rebuild_layout();
assert_eq!(
app.resolve_cell(&east),
None,
"a surrogate for a row that is gone must not resolve to anything"
);
assert_eq!(
app.resolve_cell(&north),
north_key,
"and the rows that survived must be untouched"
);
}
/// The cell a user types into is usually **empty**, so it is named by the grid
/// and by nothing in the store: the layout fallback is load-bearing.
#[test]
fn an_empty_cell_can_be_written_through_its_projected_id() {
let mut app = raw_app();
app.model_state
.workbook
.add_item("Region", "West")
.expect("the fixture must take a new item");
app.rebuild_layout();
let row = 3;
assert_eq!(
grid_of(&app, WHOLE).cell(row, 0).map(|c| c.text.clone()),
Some(String::new()),
"the fixture's new row must start empty"
);
let target = record_of_id(id_at(&app, row, 0));
let wire = caught_up(
&app,
ModelCommand::SetCell {
cell: target,
value: CellValue::Number(42.0),
},
);
apply_checked(&mut app, &wire);
assert_eq!(
grid_of(&app, WHOLE).cell(row, 0).map(|c| c.text.clone()),
Some("42.00".to_string())
);
}
/// A write by surrogate lands on that cell and no other. Two rows and two measure
/// columns, so "the first cell", "the row" or "the column" would show.
#[test]
fn a_write_by_surrogate_lands_on_exactly_that_cell() {
let mut app = raw_app();
let before: Vec<String> = (0..3)
.flat_map(|r| (0..2).map(move |c| (r, c)))
.map(|(r, c)| grid_of(&app, WHOLE).cell(r, c).unwrap().text.clone())
.collect();
let target = record_of_id(id_at(&app, 1, 1));
let wire = caught_up(
&app,
ModelCommand::SetCell {
cell: target,
value: CellValue::Number(999.0),
},
);
apply_checked(&mut app, &wire);
let after: Vec<String> = (0..3)
.flat_map(|r| (0..2).map(move |c| (r, c)))
.map(|(r, c)| grid_of(&app, WHOLE).cell(r, c).unwrap().text.clone())
.collect();
let moved: Vec<usize> = (0..after.len())
.filter(|i| before[*i] != after[*i])
.collect();
assert_eq!(
moved,
vec![3],
"exactly (1, 1) may move: {before:?} {after:?}"
);
assert_eq!(after[3], "999.00");
}
/// A surrogate that does not resolve applies *nothing*. The floor underneath the
/// session door, which refuses this first and typed — see the evidence.
#[test]
fn a_write_through_a_dead_surrogate_changes_nothing() {
let mut app = raw_app();
let east = record_of_id(id_at(&app, 2, 0));
app.model_state.workbook.remove_item("Region", "East");
app.rebuild_layout();
let before: Vec<(CellKey, CellValue)> = app
.model_state
.workbook
.model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect();
let effects = crate::ui::reduce::model_effects(
&app,
&ModelCommand::SetCell {
cell: east,
value: CellValue::Number(13.0),
},
);
app.apply_effects(effects);
let after: Vec<(CellKey, CellValue)> = app
.model_state
.workbook
.model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect();
assert_eq!(before, after, "a dead surrogate wrote somewhere");
}
// ── records mode (gotcha 1) ─────────────────────────────────────────
/// Wide format, two records and two measures, so neither "the row" nor "the
/// column" can stand in for the record by accident.
fn records_app() -> App {
let mut wb = Workbook::new("Records");
wb.add_category("Region").unwrap();
for item in ["East", "North"] {
wb.add_item("Region", item).unwrap();
}
for item in ["Revenue", "Cost"] {
wb.add_item("_Measure", item).unwrap();
}
for region in ["East", "North"] {
for measure in ["Revenue", "Cost"] {
wb.model.set_cell(
CellKey::new(vec![
("Region".to_string(), region.to_string()),
("_Measure".to_string(), measure.to_string()),
]),
CellValue::Number(1.0),
);
}
}
let mut app = App::new(wb, None);
app.handle_key(crate::command::key::KeyEvent::new(
crate::command::key::KeyCode::Char('R'),
crate::command::key::KeyModifiers::NONE,
))
.expect("R enters records mode");
assert!(
app.layout.is_records_mode(),
"the fixture must be in records mode"
);
app
}
/// improvise-e0ex: a formula's own format reaches the drawn grid — cells and
/// the totals strip — and stops there. `context/test-evidence/command.md`.
#[test]
fn a_formulas_format_reaches_its_own_column_and_no_other() {
let mut wb = raw_workbook();
wb.model.add_formula(
improvise_formula::parse_formula("Margin = Revenue / Cost FORMAT .4", "_Measure")
.unwrap(),
);
let app = App::new(wb, None);
let grid = grid_of(&app, WHOLE);
let margin = column_named(&app, "Margin");
let revenue = column_named(&app, "Revenue");
assert_eq!(
grid.cell(0, margin).map(|c| c.text.clone()),
Some("0.5000".to_string()),
"the formula's own four decimals, not the view's two"
);
assert_eq!(
grid.cell(0, revenue).map(|c| c.text.clone()),
Some("1.00".to_string()),
"a raw measure keeps the view's format"
);
// The totals strip is formatted on its own path (`col_formats`), so it
// can disagree with the cells above it without any cell being wrong.
assert_eq!(
grid.totals[margin].text, "0.5000",
"the formula over the pooled rows (3 / 6), under the column's own format"
);
assert_eq!(grid.totals[revenue].text, "3.00");
}
/// A formula owning one ROW, so its format is row-local and the totals have
/// aggregated that row away. `context/test-evidence/command.md`.
fn row_targeted_app(region: &str) -> App {
let mut wb = raw_workbook();
wb.model.add_formula(
improvise_formula::parse_formula(&format!("{region} = 12.5 FORMAT .4"), "Region")
.unwrap(),
);
App::new(wb, None)
}
/// improvise-e0ex, found by review: reading a total's format off row 0 made
/// it depend on row ORDER. `context/test-evidence/command.md`.
#[test]
fn a_column_total_does_not_inherit_a_row_local_format() {
let first = row_targeted_app("North");
let last = row_targeted_app("East");
let (fg, lg) = (grid_of(&first, WHOLE), grid_of(&last, WHOLE));
// The row the formula owns really is drawn with the formula's format —
// without this the test would pass against a plan that does nothing.
let owned = fg.cell(0, 0).map(|c| c.text.clone());
assert_eq!(
owned,
Some("12.5000".to_string()),
"fixture: North is owned"
);
// North is row 0 and East is the last row, so a total read off row 0
// answers differently in the two grids. It must not.
let totals = |g: &improvise_protocol::render::GridCache| -> Vec<String> {
g.totals.iter().map(|t| t.text.clone()).collect()
};
assert_eq!(
totals(&fg),
totals(&lg),
"which row a formula owns changed the totals' spelling"
);
for t in totals(&fg) {
assert_eq!(
t.split('.').nth(1).map(str::len),
Some(2),
"a total must keep the VIEW's two decimals, got {t:?}"
);
}
}
fn column_named(app: &App, label: &str) -> usize {
(0..app.layout.col_count())
.find(|&c| app.layout.col_label(c) == label)
.unwrap_or_else(|| panic!("no column labelled {label}"))
}
/// The trap gotcha 1 sets: `cell_key` returns a synthetic positional key here,
/// so the id must come from `RecordRow::coords` (ADR 002).
#[test]
fn a_records_mode_cell_is_named_by_its_coordinates_not_by_its_position() {
let app = records_app();
let revenue = column_named(&app, "Revenue");
let id = record_of_id(id_at(&app, 0, revenue));
assert_eq!(
app.resolve_cell(&id),
Some(CellKey::new(vec![
("Region".to_string(), "East".to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]))
);
assert_ne!(
app.resolve_cell(&id),
app.layout.cell_key(0, revenue),
"the synthetic (_Index, _Dim) key must not be what a client holds"
);
}
/// Two measure columns of one row are two cells of one record, checked here
/// because records mode reaches that shape by a different path.
#[test]
fn the_measure_columns_of_a_record_row_share_its_record() {
let app = records_app();
let revenue = record_of_id(id_at(&app, 0, column_named(&app, "Revenue")));
let cost = record_of_id(id_at(&app, 0, column_named(&app, "Cost")));
assert_eq!(revenue.record, cost.record);
assert_ne!(revenue.measure, cost.measure);
assert_ne!(
revenue.record,
record_of_id(id_at(&app, 1, column_named(&app, "Revenue"))).record
);
}
/// A document where `Profit` is BOTH an explicit `_Measure` item and a
/// formula target — which the `+` column lets a user build.
fn shadowed_measure_records_app() -> App {
let mut wb = raw_workbook();
wb.model.add_formula(
improvise_formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
// Exactly what typing `Profit` into the records `+` column does.
wb.add_item("_Measure", "Profit").unwrap();
let mut app = App::new(wb, None);
app.handle_key(crate::command::key::KeyEvent::new(
crate::command::key::KeyCode::Char('R'),
crate::command::key::KeyModifiers::NONE,
))
.expect("R enters records mode");
app
}
/// improvise-ir7l was NOT closed in records mode: a measure can be both an
/// explicit item and a formula target. `context/test-evidence/command.md`.
#[test]
fn a_records_column_a_formula_owns_is_derived_not_a_record() {
let app = shadowed_measure_records_app();
let grid = grid_of(&app, WHOLE);
assert!(
grid.col_frame.leaf_labels().iter().any(|l| l == "Profit"),
"the fixture must really produce the column: {:?}",
grid.col_frame.leaf_labels()
);
let profit = column_named(&app, "Profit");
assert_eq!(
grid.cell(0, profit).and_then(|c| c.id.clone()),
Some(CellId::Derived {
target: "Profit".to_string()
}),
"a write here is stored and then shadowed forever by formula_cache"
);
assert!(
matches!(
grid.cell(0, column_named(&app, "Revenue"))
.and_then(|c| c.id.clone()),
Some(CellId::Record(_))
),
"and the raw measure beside it is still writable, or this is vacuous"
);
}
/// The reviewer's repro for improvise-ir7l, through `handle_key` and no test
/// API at all. `context/test-evidence/command.md`.
#[test]
fn typing_into_a_records_formula_column_is_refused_on_the_key_path() {
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
let ch = |c: char| KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let mut wb = raw_workbook();
wb.model.add_formula(
improvise_formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
let mut app = App::new(wb, None);
app.handle_key(ch('R')).expect("records mode");
// Walk to the trailing `+` column and create the measure by typing its
// name — exactly what a user who cannot see `Profit` would do.
let plus = column_named(&app, crate::view::layout::NEW_MEASURE_COL);
app.cursor_mut().selected = (0, plus);
app.handle_key(ch('i')).expect("edit the + column");
for c in "Profit".chars() {
app.handle_key(ch(c)).expect("type the name");
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.expect("commit the name");
// The commit re-enters editing on the cell it advanced to; leave it.
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.expect("back to records normal");
app.rebuild_layout();
let profit = column_named(&app, "Profit");
assert_eq!(
grid_of(&app, WHOLE)
.cell(0, profit)
.and_then(|c| c.id.clone()),
Some(CellId::Derived {
target: "Profit".to_string()
}),
"the column the user just made is owned by a formula"
);
// And the TUI must refuse rather than open an editor over it.
app.cursor_mut().selected = (0, profit);
let before = app.view_state.mode.clone();
app.handle_key(ch('i')).expect("i on the Profit column");
assert_eq!(
app.view_state.mode, before,
"`i` must not enter an editing mode over a formula-owned cell"
);
assert!(
app.view_state.status_msg.contains("computed by a formula"),
"and it must say why, got {:?}",
app.view_state.status_msg
);
}
/// `cell_id`'s records branch never asks `formula_for`, so improvise-ir7l
/// holds there only because of this. `context/test-evidence/command.md`.
#[test]
fn a_records_grid_has_no_column_for_a_formula_target() {
let mut wb = raw_workbook();
wb.model.add_formula(
improvise_formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
let mut app = App::new(wb, None);
app.handle_key(crate::command::key::KeyEvent::new(
crate::command::key::KeyCode::Char('R'),
crate::command::key::KeyModifiers::NONE,
))
.expect("R enters records mode");
assert!(app.layout.is_records_mode());
assert!(
app.model_state
.workbook
.model
.measure_item_names()
.iter()
.any(|m| m == "Profit"),
"the fixture must really have a formula target among the measures"
);
let labels: Vec<String> = grid_of(&app, WHOLE).col_frame.leaf_labels();
assert!(
!labels.iter().any(|l| l == "Profit"),
"a formula target is not a records column — nothing to type into it \
(gotcha 1). If this fails, cell_id's records branch must learn about \
formula targets too, or it will project one as a writable Record: {labels:?}"
);
}
/// improvise-p1ym, the producer half: the client test builds its own
/// `PageAxis`, so only this can say what is emitted. Evidence file.
#[test]
fn a_page_axis_carries_its_items_and_a_filter_axis_does_not() {
let mut wb = Workbook::new("Paged");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_category("Quarter").unwrap();
for q in ["Q1", "Q2"] {
wb.add_item("Quarter", q).unwrap();
}
wb.add_category("Year").unwrap();
wb.add_item("Year", "2024").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);
wb.active_view_mut().set_axis("Quarter", Axis::Page);
// A drill fixes its coordinates on Filter, which constrains without
// being navigable (gotcha 27).
wb.active_view_mut().set_axis("Year", Axis::Filter);
let mut app = App::new(wb, None);
app.rebuild_layout();
let projected: Vec<(String, Option<Vec<String>>)> = grid_of(&app, WHOLE)
.page_coords
.into_iter()
.map(|axis| (axis.cat, axis.items))
.collect();
assert_eq!(
projected,
vec![
(
"Quarter".to_string(),
Some(vec!["Q1".to_string(), "Q2".to_string()])
),
("Year".to_string(), None),
],
"a Page axis carries what `[`/`]` cycle; a Filter axis carries nothing to cycle"
);
}
/// A records grid whose coordinate items are spelled like numbers, which
/// is the shape a `parse::<f64>()` guess gets wrong.
fn numeric_looking_records_app() -> App {
let mut wb = Workbook::new("Years");
wb.add_category("Year").unwrap();
for item in ["2024", "2025"] {
wb.add_item("Year", item).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
for year in ["2024", "2025"] {
wb.model.set_cell(
CellKey::new(vec![
("Year".to_string(), year.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(7.0),
);
}
let mut app = App::new(wb, None);
app.handle_key(crate::command::key::KeyEvent::new(
crate::command::key::KeyCode::Char('R'),
crate::command::key::KeyModifiers::NONE,
))
.expect("R enters records mode");
app
}
/// improvise-tom1: the producer guessed numeric-ness off the text, so a
/// coordinate `2024` was a number. `context/test-evidence/command.md`.
#[test]
fn a_records_coordinate_is_text_however_it_is_spelled() {
let app = numeric_looking_records_app();
let grid = grid_of(&app, WHOLE);
let year = column_named(&app, "Year");
let revenue = column_named(&app, "Revenue");
let coordinate = grid.cell(0, year).expect("the Year column");
assert_eq!(coordinate.text, "2024");
assert!(
!coordinate.numeric,
"a coordinate is an item name, not a number"
);
assert!(
grid.cell(0, revenue).expect("the Revenue column").numeric,
"and the measure beside it really is one, or the check is vacuous"
);
}
/// The user's ruling (improvise-tom1): staged drill edits are uncommitted
/// writes belonging to whoever staged them. Evidence file.
#[test]
fn a_staged_drill_edit_reaches_its_own_session_and_no_projection() {
let mut app = records_app();
let revenue = column_named(&app, "Revenue");
let col = app
.layout
.record_col(revenue)
.cloned()
.expect("the Revenue column");
let key = app.layout.cell_key(0, revenue).expect("a synthetic key");
// The real frozen snapshot, so the layout the producer reads is the
// one the fixture built rather than an empty drill.
app.drill_state = Some(crate::ui::app::DrillState {
records: app.layout.records.clone().expect("records mode"),
pending_edits: std::collections::HashMap::new(),
});
app.apply_effects(vec![Box::new(crate::ui::effect::SetDrillPendingEdit {
record_idx: 0,
col,
new_value: "999".to_string(),
})]);
assert_eq!(
app.display_value_of(&key),
"999",
"the session that staged it must see its own typing"
);
assert_eq!(
grid_of(&app, WHOLE)
.cell(0, revenue)
.expect("the projected cell")
.text,
"1",
"and no other client may: the producer must not read drill_state"
);
}
/// improvise-poia: `None` means "no projected cell", so a creating column
/// read as an empty slot. `context/test-evidence/command.md`.
#[test]
fn a_creating_column_is_named_rather_than_left_as_no_cell() {
let app = records_app();
let grid = grid_of(&app, WHOLE);
let ids: Vec<Option<CellId>> = (0..app.layout.col_count())
.map(|c| grid.cell(0, c).and_then(|cell| cell.id.clone()))
.collect();
assert!(
ids.iter().all(Option::is_some),
"no records column is nameless since improvise-exdg: {ids:?}"
);
assert_eq!(
ids.iter()
.filter(|id| matches!(id, Some(CellId::Create(_))))
.count(),
2,
"both `+cat` and `+` create something: {ids:?}"
);
assert_eq!(
grid.cell(0, column_named(&app, crate::view::layout::NEW_MEASURE_COL))
.and_then(|c| c.id.clone()),
Some(CellId::Create(CreateKind::Measure))
);
assert_eq!(
grid.cell(0, column_named(&app, crate::view::layout::NEW_CATEGORY_COL))
.and_then(|c| c.id.clone()),
Some(CellId::Create(CreateKind::Category))
);
}
/// improvise-fdk1: a record named only by its coordinate cell is still
/// published, or its rename is RecordUnstated. `context/test-evidence/command.md`.
#[test]
fn a_record_named_only_by_its_coordinate_is_in_the_published_basis() {
let mut wb = Workbook::new("Anchors");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
let north = CellKey::new(vec![("Region".to_string(), "North".to_string())]);
wb.model.set_cell(north.clone(), CellValue::Number(1.0));
let mut app = App::new(wb, None);
app.handle_key(crate::command::key::KeyEvent::new(
crate::command::key::KeyCode::Char('R'),
crate::command::key::KeyModifiers::NONE,
))
.expect("R enters records mode");
let grid = grid_of(&app, WHOLE);
let ids: Vec<CellId> = grid
.cells
.iter()
.flatten()
.filter_map(|c| c.id.clone())
.collect();
assert!(
!ids.iter().any(|id| matches!(id, CellId::Record(_))),
"setup: no measure column, so only the coordinate names the record: {ids:?}"
);
assert!(
ids.iter().any(|id| matches!(id, CellId::Coordinate { .. })),
"setup: the coordinate is projected: {ids:?}"
);
let basis = published_basis(&app, &grid);
assert!(
basis.record(RecordHash::of(&north)).is_some(),
"the coordinate's record must be published: {basis:?}"
);
}
/// improvise-exdg: a coordinate cell is drawn and holds text, so it is not
/// "no cell". `context/test-evidence/command.md`.
#[test]
fn a_coordinate_cell_is_named_rather_than_left_as_no_cell() {
let app = records_app();
let grid = grid_of(&app, WHOLE);
let region = column_named(&app, "Region");
let north = grid.cell(1, region).expect("the second row's coordinate");
assert_eq!(north.text, "North", "setup: a visible coordinate cell");
let record = app.layout.record_row(1).expect("row 1").coords.clone();
assert_eq!(
north.id,
Some(CellId::Coordinate {
record: RecordHash::of(&record),
category: "Region".into(),
}),
"a drawn coordinate is named, with its own record and category (fdk1)"
);
// Each cell's kind against its column's, not merely "some kind" (improvise-w1bu).
let misnamed: Vec<(usize, usize, Option<CellId>)> = (0..app.layout.row_count())
.flat_map(|r| (0..app.layout.col_count()).map(move |c| (r, c)))
.map(|(r, c)| (r, c, grid.cell(r, c).and_then(|cell| cell.id.clone())))
.filter(|(_, c, id)| !names_its_column(app.layout.record_col(*c), id.as_ref()))
.collect();
assert!(
misnamed.is_empty(),
"a records cell of the wrong kind: {misnamed:?}"
);
}
/// Whether `id` is the kind `col` projects in a document with **no formula**,
/// so `Derived` is never right here (improvise-hoxy).
fn names_its_column(col: Option<&RecordCol>, id: Option<&CellId>) -> bool {
match (col, id) {
(Some(RecordCol::Category(c)), Some(CellId::Coordinate { category, .. })) => {
category == c
}
(Some(RecordCol::Measure(m)), Some(CellId::Record(cell))) => {
cell.measure.as_deref() == Some(m.as_str())
}
(Some(RecordCol::NewMeasure), Some(CellId::Create(CreateKind::Measure))) => true,
(Some(RecordCol::NewCategory), Some(CellId::Create(CreateKind::Category))) => true,
_ => false,
}
}
/// The version counter is keyed by the surrogate the client holds, so one
/// identity answers ADR 002's question and ADR 001's.
#[test]
fn a_write_moves_the_version_under_the_clients_own_surrogate() {
let mut app = raw_app();
let target = record_of_id(id_at(&app, 1, 1));
let neighbour = record_of_id(id_at(&app, 0, 0));
let before = app.record_version_of(target.record);
let wire = caught_up(
&app,
ModelCommand::SetCell {
cell: target.clone(),
value: CellValue::Number(999.0),
},
);
apply_checked(&mut app, &wire);
assert_eq!(app.record_version_of(target.record), before + 1);
assert_eq!(
app.record_version_of(neighbour.record),
0,
"another record's basis was invalidated for nothing"
);
}
/// improvise-eccl/f2oo at the projection boundary: the grid a client is sent and
/// the CSV an export writes agree on a fold row — see the evidence file.
#[test]
fn the_grid_and_a_csv_export_agree_on_a_fold_row() {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
for month in ["Jan", "Feb", "Mar"] {
wb.add_item_in_group("Month", month, "Q1").unwrap();
}
for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0), ("Mar", 50.0, 50.0)]
{
for (measure, n) in [("Revenue", revenue), ("Cost", cost)] {
wb.add_item("_Measure", measure).unwrap();
let key = CellKey::new(vec![
("Month".into(), month.into()),
("_Measure".into(), measure.into()),
]);
wb.model.set_cell(key, CellValue::Number(n));
}
}
wb.model.add_formula(
crate::formula::parse_formula("Margin = (Revenue - Cost) / Revenue", "_Measure")
.unwrap(),
);
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().hide_item("Month", "Mar");
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
let app = App::new(wb, None);
let grid = grid_of(&app, WHOLE);
let q1 = grid.row_frame.leaf_labels().iter().position(|l| l == "Q1");
let q1 = q1.expect("a Q1 fold row");
let drawn: Vec<String> = (0..grid.col_frame.leaf_labels().len())
.map(|c| grid.cell(q1, c).map(|d| d.text.clone()).unwrap_or_default())
.collect();
let storage = crate::storage::MemoryFs::new();
let path = std::path::Path::new("out.csv");
crate::persistence::export_csv_to(&storage, &app.model_state.workbook, "Default", path)
.expect("the export writes");
let bytes = crate::storage::Storage::read(&storage, path).unwrap();
let csv = String::from_utf8(bytes).unwrap();
let exported: Vec<String> = csv
.lines()
.find_map(|l| l.strip_prefix("Q1,"))
.expect("a Q1 line")
.split(',')
.map(String::from)
.collect();
assert_eq!(
drawn,
["40.00", "8.00", "0.80"],
"March is hidden, so not folded"
);
assert_eq!(exported, drawn, "the export and the grid disagree:\n{csv}");
}
}
/// ADR 001's basis check, both arms per tier (improvise-xwi6/fkfg). The fixtures
/// hold identifiers constant and vary only the staleness — see the evidence.
#[cfg(test)]
mod basis_check {
use super::*;
use crate::model::cell::{CellKey, CellValue};
use crate::ui::effect::{RemoveItem, SetAxis, SetCell, ViewTarget};
use crate::workbook::Workbook;
use improvise_core::view::Axis;
use improvise_protocol::{Basis, ModelCommand};
const WHOLE: Viewport = Viewport {
row_offset: 0,
col_offset: 0,
rows: 100,
cols: 100,
};
/// Three rows of stored cells, so a batch can name one record while
/// another moves.
fn app() -> App {
let mut wb = Workbook::new("Basis");
wb.add_category("Region").unwrap();
for item in ["North", "South", "East"] {
wb.add_item("Region", item).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 item in ["North", "South", "East"] {
wb.model.set_cell(
CellKey::new(vec![
("Region".to_string(), item.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(1.0),
);
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// The cell a client would address on `row`, named the way a client
/// names it: off the projection, never rebuilt from coordinates.
fn addressed(app: &App, row: usize) -> RecordCell {
match grid_of(app, WHOLE)
.cell(row, 0)
.expect("the fixture projects three rows")
.id
.clone()
.expect("a stored cell is projected with an identity")
{
CellId::Record(cell) => cell,
other => panic!("the fixture must project stored cells, got {other:?}"),
}
}
/// The basis a caught-up client holds for one record. Built by hand, not through
/// `caught_up`, so each test can move exactly one tier.
fn basis_for(app: &App, cell: &RecordCell) -> Basis {
Basis {
document_generation: app.document_generation(),
layout_version: app.layout_version(),
records: vec![(cell.record, app.record_version_of(cell.record))],
}
}
fn write(cell: RecordCell, basis: Basis) -> Command {
Command::Model {
basis,
cmd: ModelCommand::SetCell {
cell,
value: CellValue::Number(99.0),
},
}
}
fn rejection(applied: Applied) -> Rejection {
match applied {
Applied::Rejected { reason } => reason,
other => panic!("expected a rejection, got {other:?}"),
}
}
fn value_at(app: &App, row: usize) -> String {
grid_of(app, WHOLE)
.cell(row, 0)
.expect("the fixture projects three rows")
.text
.clone()
}
// ── tier 2: layout, which is the reason for the tiering ─────────────
/// **The class-2 case.** A `CellData` batch is stale when the *layout* moved,
/// even though no record it names was written: corruption, not a lost keystroke.
#[test]
fn a_cell_edit_is_rejected_when_the_layout_moved_under_it() {
let mut app = app();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
// B composes against the picture it can see.
let target = addressed(&app, 1);
let basis = basis_for(&app, &target);
// A reshapes the grid. No record B named is written by this.
app.apply_effects(vec![Box::new(SetAxis {
view: ViewTarget::Active,
category: "Region".to_string(),
axis: Axis::Column,
})]);
assert_eq!(
app.record_version_of(target.record),
basis.record(target.record).expect("the claim names it"),
"the fixture must move the layout and NOT the record, or this \
test cannot tell the two tiers apart"
);
let applied = sessions.apply(&mut app, id, &write(target, basis));
assert_eq!(rejection(applied), Rejection::LayoutMoved);
}
/// The other arm: a batch composed after the reshape is applied, so the rule is
/// "the layout moved since you looked", not "has ever moved".
#[test]
fn a_cell_edit_composed_after_the_reshape_is_applied() {
let mut app = app();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
app.apply_effects(vec![Box::new(SetAxis {
view: ViewTarget::Active,
category: "Region".to_string(),
axis: Axis::Column,
})]);
let target = addressed(&app, 0);
let basis = basis_for(&app, &target);
let applied = sessions.apply(&mut app, id, &write(target, basis));
assert!(
matches!(applied, Applied::Diff { .. }),
"a current basis was refused: {applied:?}"
);
assert!(
grid_of(&app, WHOLE)
.cells
.iter()
.flatten()
.any(|c| c.text == "99.00"),
"the accepted write never reached the document"
);
}
// ── tier 3: records, and the two answers it has ─────────────────────
/// Class 1: somebody wrote the record since this client read it. **Data**
/// staleness — it still resolves, so force is coherent and the reason says so.
#[test]
fn a_write_is_rejected_when_the_record_it_names_moved() {
let mut app = app();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let target = addressed(&app, 1);
let basis = basis_for(&app, &target);
// Somebody else writes that record. The identifiers do not move — only the
// value — so a rejection cannot be a renamed key in disguise.
app.apply_effects(vec![Box::new(SetCell(
CellKey::new(vec![
("Region".to_string(), "South".to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(7.0),
))]);
assert_eq!(
app.layout_version(),
basis.layout_version,
"the fixture must move the record and NOT the layout"
);
let applied = sessions.apply(&mut app, id, &write(target.clone(), basis));
assert_eq!(rejection(applied), Rejection::RecordMoved(target.record));
assert_eq!(
value_at(&app, 1),
"7.00",
"a rejected batch must leave the document exactly as it was"
);
}
/// The arm that keeps the tier from being "reject whenever anything was written",
/// which is why the tier is per-record rather than one counter.
#[test]
fn a_write_survives_another_record_moving() {
let mut app = app();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let target = addressed(&app, 1);
let basis = basis_for(&app, &target);
app.apply_effects(vec![Box::new(SetCell(
CellKey::new(vec![
("Region".to_string(), "East".to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(7.0),
))]);
let applied = sessions.apply(&mut app, id, &write(target, basis));
assert!(
matches!(applied, Applied::Diff { .. }),
"another row's edit invalidated this one: {applied:?}"
);
assert_eq!(value_at(&app, 1), "99.00");
}
/// Class 2 by the other route: "gone" and "changed" must not collapse into one
/// answer. There is nothing to write to, so nothing to force.
#[test]
fn a_write_to_a_record_that_no_longer_exists_says_so() {
let mut app = app();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let target = addressed(&app, 1);
let mut basis = basis_for(&app, &target);
app.apply_effects(vec![Box::new(RemoveItem {
category: "Region".to_string(),
item: "South".to_string(),
})]);
// Bring the layout claim up to date so only the record tier is left to
// answer, whatever kind `RemoveItem` wears — see the evidence.
basis.layout_version = app.layout_version();
let applied = sessions.apply(&mut app, id, &write(target.clone(), basis));
assert_eq!(rejection(applied), Rejection::RecordGone(target.record));
}
/// The force-by-omission hole, closed: which records are checked is the
/// **server's** decision, so omitting one is told, not waved through.
#[test]
fn a_claim_that_omits_the_record_it_writes_is_not_a_free_pass() {
let mut app = app();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let target = addressed(&app, 1);
let mut basis = basis_for(&app, &target);
basis.records.clear();
let applied = sessions.apply(&mut app, id, &write(target.clone(), basis));
assert_eq!(rejection(applied), Rejection::RecordUnstated(target.record));
assert_eq!(
value_at(&app, 1),
"1.00",
"the omitted claim let a write land"
);
}
// ── tier 1: the document ────────────────────────────────────────────
/// The top tier invalidates everything and is checked first, so the answer names
/// the largest thing that moved rather than an arbitrary one.
#[test]
fn a_document_swap_invalidates_every_claim() {
let mut app = app();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let target = addressed(&app, 1);
let mut basis = basis_for(&app, &target);
basis.document_generation += 1;
let applied = sessions.apply(&mut app, id, &write(target, basis));
assert_eq!(rejection(applied), Rejection::DocumentReplaced);
}
// ── what a rejection is not ─────────────────────────────────────────
/// Rejection is a *normal outcome*: nothing applied, nobody told, no basis
/// moved. A rejection that dirtied something would trigger redraws for nothing.
#[test]
fn a_rejected_batch_changes_nothing_anywhere() {
let mut app = app();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let (_bystander, _) = sessions.connect(&app, 100, 100);
let target = addressed(&app, 1);
let mut basis = basis_for(&app, &target);
basis.layout_version += 1;
let before = format!("{:?}", app.model_state.workbook);
let versions = (app.layout_version(), app.record_version_of(target.record));
sessions.apply(&mut app, id, &write(target.clone(), basis));
assert_eq!(before, format!("{:?}", app.model_state.workbook));
assert_eq!(
versions,
(app.layout_version(), app.record_version_of(target.record)),
"a refused batch moved a counter"
);
assert!(
sessions.project_all(&app, Dirty::NOTHING).is_empty(),
"a refused batch produced traffic for somebody"
);
}
/// The order `ClientSession::basis_update` claims is load-bearing, pinned: a
/// basis that overtook its grid updates would let a client claim unseen values.
#[test]
fn a_published_basis_follows_the_grid_updates_it_describes() {
let mut app = app();
let mut sessions = Sessions::default();
let (id, _) = sessions.connect(&app, 100, 100);
let dirty = app.apply_effects(vec![Box::new(SetCell(
CellKey::new(vec![
("Region".to_string(), "South".to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(7.0),
))]);
let projections = sessions
.project_all(&app, dirty)
.into_iter()
.find(|(who, _)| *who == id)
.expect("the writing client is told about its own document")
.1;
let last_grid = projections
.iter()
.rposition(|p| matches!(p, Projection::Grid(_)))
.expect("a cell edit reaches the grid");
let basis = projections
.iter()
.position(|p| matches!(p, Projection::Basis(_)))
.expect("a cell edit moves a record counter, so the basis is republished");
assert!(
basis > last_grid,
"the basis overtook the grid updates whose counters it states: {projections:?}"
);
}
// ── the publication the check depends on ────────────────────────────
/// The check is satisfiable only because the server *publishes* the basis, which
/// ADR 001 does not mention. End to end, with no test-only constructor.
#[test]
fn a_client_that_folds_the_stream_composes_an_accepted_batch() {
let mut app = app();
let mut sessions = Sessions::default();
let (id, snapshot) = sessions.connect(&app, 100, 100);
let mut grid = GridCache::default();
let mut basis = BasisCache::default();
for projection in &snapshot.seed {
match projection {
Projection::Grid(u) => grid.apply(u),
Projection::Basis(u) => basis.apply(u),
_ => {}
}
}
let CellId::Record(target) = grid
.cell(1, 0)
.expect("seeded")
.id
.clone()
.expect("a stored cell carries an identity")
else {
panic!("the fixture must project stored cells");
};
let claimed = basis
.claim([target.record])
.expect("the seed must publish a basis");
let applied = sessions.apply(&mut app, id, &write(target, claimed));
assert!(
matches!(applied, Applied::Diff { .. }),
"a basis composed only from the published stream was refused: {applied:?}"
);
assert_eq!(value_at(&app, 1), "99.00");
}
/// The other side of publication: after somebody else writes, the stream carries
/// a new basis, so re-composing is accepted. That is ADR 001's force.
#[test]
fn re_composing_against_the_republished_basis_is_accepted() {
let mut app = app();
let mut sessions = Sessions::default();
let (id, snapshot) = sessions.connect(&app, 100, 100);
let mut grid = GridCache::default();
let mut basis = BasisCache::default();
for projection in &snapshot.seed {
match projection {
Projection::Grid(u) => grid.apply(u),
Projection::Basis(u) => basis.apply(u),
_ => {}
}
}
let CellId::Record(target) = grid.cell(1, 0).unwrap().id.clone().unwrap() else {
panic!("the fixture must project stored cells");
};
let stale = basis.claim([target.record]).expect("seeded");
// Somebody else writes the record this client was about to.
let dirty = app.apply_effects(vec![Box::new(SetCell(
CellKey::new(vec![
("Region".to_string(), "South".to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(7.0),
))]);
let refused = sessions.apply(&mut app, id, &write(target.clone(), stale));
assert_eq!(
rejection(refused),
Rejection::RecordMoved(target.record),
"the arms of this test would prove nothing if the first write landed"
);
// The client folds what it is sent, which now includes the moved
// counter…
for (_, projections) in sessions.project_all(&app, dirty) {
for projection in &projections {
if let Projection::Basis(u) = projection {
basis.apply(u);
}
}
}
// …and re-composes the same intent.
let fresh = basis.claim([target.record]).expect("republished");
let applied = sessions.apply(&mut app, id, &write(target, fresh));
assert!(
matches!(applied, Applied::Diff { .. }),
"a re-composed batch was refused: {applied:?}"
);
assert_eq!(value_at(&app, 1), "99.00");
}
}
/// The totals strip: a column's total is its cell as if every drawn row were one
/// fold (improvise-wm20). The rule and the rulings: `context/code/command.md`.
#[cfg(test)]
mod column_totals {
use super::*;
use crate::model::cell::{CellKey, CellValue};
use crate::workbook::Workbook;
use improvise_core::view::Axis;
const WHOLE: Viewport = Viewport {
row_offset: 0,
col_offset: 0,
rows: 20,
cols: 20,
};
fn key(coords: &[(&str, &str)]) -> CellKey {
CellKey::new(
coords
.iter()
.map(|(c, i)| (c.to_string(), i.to_string()))
.collect(),
)
}
/// Region on Row, `_Measure` on Column, one record per cell.
fn regions(cells: &[(&str, &str, CellValue)]) -> Workbook {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
for (region, measure, value) in cells {
wb.add_item("Region", *region).unwrap();
wb.add_item("_Measure", *measure).unwrap();
let at = key(&[("Region", region), ("_Measure", measure)]);
wb.model.set_cell(at, value.clone());
}
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb
}
fn total(wb: Workbook, measure: &str) -> CellDisplay {
let mut app = App::new(wb, None);
app.rebuild_layout();
let col = (0..app.layout.col_count())
.find(|&c| app.layout.col_label(c) == measure)
.unwrap_or_else(|| panic!("no column {measure}"));
grid_of(&app, WHOLE).totals[col].clone()
}
fn money(rows: &[(&str, f64, f64)]) -> Workbook {
let cells: Vec<_> = rows
.iter()
.flat_map(|(r, rev, cost)| {
[
(*r, "Revenue", CellValue::Number(*rev)),
(*r, "Cost", CellValue::Number(*cost)),
]
})
.collect();
regions(&cells)
}
/// improvise-wm20 — see context/test-evidence/command.md.
#[test]
fn a_ratio_columns_total_is_the_ratio_of_the_totalled_inputs() {
let mut wb = money(&[("North", 10.0, 5.0), ("South", 30.0, 3.0)]);
let margin =
crate::formula::parse_formula("Margin = (Revenue - Cost) / Revenue", "_Measure");
wb.model.add_formula(margin.unwrap());
// Rows are 0.50 and 0.90: summed 1.40, averaged 0.70, pooled 32/40.
assert_eq!(total(wb, "Margin").text, "0.80");
}
/// improvise-wm20: the fold is of the DRAWN rows, so a hidden row's values stay
/// out of every total, a formula's included. context/test-evidence/command.md.
#[test]
fn a_hidden_rows_values_stay_out_of_the_total() {
let mut wb = money(&[
("North", 10.0, 5.0),
("South", 30.0, 3.0),
("East", 100.0, 100.0),
]);
let margin =
crate::formula::parse_formula("Margin = (Revenue - Cost) / Revenue", "_Measure");
wb.model.add_formula(margin.unwrap());
wb.active_view_mut().hide_item("Region", "East");
assert_eq!(total(wb.clone(), "Revenue").text, "40.00");
assert_eq!(
total(wb, "Margin").text,
"0.80",
"East's 0 margin pooled in"
);
}
/// improvise-wm20: an Avg measure totals as the mean over its records, not
/// the sum or mean of the rows' means. context/test-evidence/command.md.
#[test]
fn an_average_measures_total_is_the_mean_over_its_records() {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_category("Product").unwrap();
wb.add_item("_Measure", "Price").unwrap();
for (region, product, price) in [
("North", "A", 10.0),
("North", "B", 20.0),
("South", "A", 30.0),
] {
wb.add_item("Region", region).unwrap();
wb.add_item("Product", product).unwrap();
let at = key(&[
("Region", region),
("Product", product),
("_Measure", "Price"),
]);
wb.model.set_cell(at, CellValue::Number(price));
}
let avg = crate::formula::AggFunc::Avg;
wb.model.measure_agg.insert("Price".into(), avg);
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().set_axis("Product", Axis::None);
// Rows are 15 and 30: summed 45, their mean 22.5, the records' mean 20.
assert_eq!(total(wb, "Price").text, "20.00");
}
/// improvise-wm20, ruling 1: a text column is unanimous or `mixed`.
#[test]
fn a_text_columns_total_is_its_text_when_every_row_agrees() {
let text = |s: &str| CellValue::Text(s.into());
let agreed = regions(&[
("North", "Status", text("ok")),
("South", "Status", text("ok")),
]);
let t = total(agreed, "Status");
assert_eq!((t.text.as_str(), t.error), ("ok", false));
let split = regions(&[
("North", "Status", text("ok")),
("South", "Status", text("no")),
]);
let t = total(split, "Status");
assert!(t.error, "differing text totals as an error: {t:?}");
}
/// improvise-wm20, ruling 2: an error in any row makes the total an error.
#[test]
fn an_error_in_any_row_makes_the_total_an_error() {
let wb = regions(&[
("North", "Revenue", CellValue::Number(10.0)),
("South", "Revenue", CellValue::Error("bad".into())),
]);
let t = total(wb, "Revenue");
assert!(t.error, "a row's error was dropped from the total: {t:?}");
}
/// improvise-wm20, ruling 3: with `_Measure` on the row axis a column adds
/// different measures together, so there is no strip at all.
#[test]
fn measures_on_the_row_axis_leave_no_totals_strip() {
let mut wb = money(&[("North", 10.0, 5.0), ("South", 30.0, 3.0)]);
wb.active_view_mut().set_axis("Region", Axis::Column);
wb.active_view_mut().set_axis("_Measure", Axis::Row);
let mut app = App::new(wb, None);
app.rebuild_layout();
assert!(grid_of(&app, WHOLE).totals.is_empty());
}
/// improvise-wm20 with #203's folds: a Max measure's total does not move when
/// a row group collapses. context/test-evidence/command.md.
#[test]
fn a_max_measures_total_does_not_move_when_a_row_group_collapses() {
let book = |collapsed: bool| {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Region").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
for (month, n, s) in [("Jan", 5.0, 1.0), ("Feb", 2.0, 7.0)] {
wb.add_item_in_group("Month", month, "Q1").unwrap();
for (region, v) in [("North", n), ("South", s)] {
wb.add_item("Region", region).unwrap();
let at = key(&[
("Month", month),
("Region", region),
("_Measure", "Revenue"),
]);
wb.model.set_cell(at, CellValue::Number(v));
}
}
let max = crate::formula::AggFunc::Max;
wb.model.measure_agg.insert("Revenue".into(), max);
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().set_axis("Region", Axis::None);
if collapsed {
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
}
wb
};
// Expanded rows are 5 and 7, which a strip that sums reads as 12.
assert_eq!(total(book(false), "Revenue").text, "7.00");
assert_eq!(total(book(true), "Revenue").text, "7.00");
}
/// The column's grid and its index, for a test that reads rows and total together.
fn column(wb: Workbook, measure: &str) -> (GridCache, usize) {
let mut app = App::new(wb, None);
app.rebuild_layout();
let col = (0..app.layout.col_count())
.find(|&c| app.layout.col_label(c) == measure)
.unwrap_or_else(|| panic!("no column {measure}"));
(grid_of(&app, WHOLE), col)
}
/// North 25/30, South 27/30, East -4/0: the rows draw 0.83, 0.90, div/0.
fn margins_with_a_zero_revenue_row() -> Workbook {
money(&[
("North", 30.0, 5.0),
("South", 30.0, 3.0),
("East", 0.0, 4.0),
])
}
/// improvise-b5dp, user ruling: a formula total is the pooled ratio, so a row's
/// div/0 does not poison it. context/test-evidence/command.md.
#[test]
fn a_formula_rows_error_does_not_poison_the_pooled_total() {
let mut wb = margins_with_a_zero_revenue_row();
for f in ["Profit = Revenue - Cost", "Margin = Profit / Revenue"] {
let formula = crate::formula::parse_formula(f, "_Measure").unwrap();
wb.model.add_formula(formula);
}
let (grid, col) = column(wb, "Margin");
let rows: Vec<bool> = grid.cells.iter().map(|r| r[col].error).collect();
assert_eq!(rows, vec![false, false, true], "East's row must draw div/0");
let t = &grid.totals[col];
assert_eq!(
(t.text.as_str(), t.error),
("0.80", false),
"48 / 60, pooled"
);
}
/// improvise-b5dp: the same ruling for a formula whose own aggregate is zero
/// at one row. context/test-evidence/command.md.
#[test]
fn a_share_columns_total_pools_past_a_rows_div_zero() {
let mut wb = margins_with_a_zero_revenue_row();
let share = crate::formula::parse_formula("Share = Revenue / SUM(Revenue)", "_Measure");
wb.model.add_formula(share.unwrap());
let (grid, col) = column(wb, "Share");
assert!(grid.cells[2][col].error, "East's row must draw div/0");
let t = &grid.totals[col];
assert_eq!(
(t.text.as_str(), t.error),
("1.00", false),
"60 / 60, pooled"
);
}
}