f6fca7ec98
rebuild_layout recomputes formulas for the HOST's none_cats, and grid_in's elsewhere path built another view's GridLayout against that cache. The cache is keyed by the coordinates the recompute used, so a Total keyed without a Product coordinate is not found by a view with Product on Column, and prune_empty drops the formula's columns entirely. Absent columns, not wrong numbers. grid_in::rederived_for clones the model and recomputes for the target view's none_cats. Three conditions gate the clone -- another view, different none_cats, at least one formula -- so a client on the host's own view pays nothing and a formula-free document pays nothing ever. This is an interim and the cost is why: a whole-model clone plus a fixed-point recompute per projection on exactly the broken path. The ticket's first candidate, keying formula_cache by none_cats so one Model holds more than one view's derived values, is the durable answer and is an improvise-core change outside this wave's ground. The characterization test is replaced by the positive one, and the none_cats carve-out is dropped from a_clients_grid_does_not_depend_on_which_view_its_host_is_on -- which is the general property this is an instance of, and which fails under the same injection. Evidence: context/test-evidence/command.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
5032 lines
191 KiB
Rust
5032 lines
191 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::{format_f64, format_value, parse_number_format};
|
|
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, 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 (comma, decimals) = parse_number_format(&view.number_format);
|
|
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, comma, decimals, 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, comma, decimals))
|
|
.collect()
|
|
})
|
|
.collect(),
|
|
totals: column_totals(&body, cols, comma, decimals),
|
|
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 {
|
|
cat: cat_name.clone(),
|
|
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>,
|
|
comma: bool,
|
|
decimals: u8,
|
|
) -> Vec<CellDisplay> {
|
|
match body.totals.is_empty() {
|
|
true => Vec::new(),
|
|
false => cols
|
|
.filter_map(|c| body.totals.get(c))
|
|
.map(|total| CellDisplay::number(format_f64(*total, comma, decimals)))
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
comma: bool,
|
|
decimals: u8,
|
|
) -> 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(text.clone()),
|
|
text,
|
|
numeric: matches!(value, Some(CellValue::Number(_))),
|
|
error: matches!(value, Some(CellValue::Error(_))),
|
|
id: cell_id(app, layout, row, col),
|
|
};
|
|
}
|
|
CellDisplay {
|
|
text: format_value(value.as_ref(), comma, decimals),
|
|
numeric: matches!(value, Some(CellValue::Number(_))),
|
|
error: matches!(value, Some(CellValue::Error(_))),
|
|
id: cell_id(app, 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: layout
|
|
.cell_key(row, col)
|
|
.map(|key| app.display_value_in(layout, &key)),
|
|
}
|
|
}
|
|
|
|
/// 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`.
|
|
fn cell_id(app: &App, layout: &GridLayout, row: usize, col: usize) -> Option<CellId> {
|
|
let model = &app.model_state.workbook.model;
|
|
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` and still the host's (gotcha 1).
|
|
RecordCol::Category(_) => return None,
|
|
};
|
|
return Some(CellId::Record(RecordCell {
|
|
record: RecordHash::of(record),
|
|
measure,
|
|
}));
|
|
}
|
|
let key = layout.cell_key(row, col)?;
|
|
let aggregated = 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()
|
|
}
|
|
|
|
/// The raw text of every formula, which is all the formula panel draws.
|
|
pub fn formula_texts(app: &App) -> Vec<String> {
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.formulas()
|
|
.iter()
|
|
.map(|f| f.raw.clone())
|
|
.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> {
|
|
let workbook = &app.model_state.workbook;
|
|
view.tileable_cats(&workbook.model.category_names())
|
|
.into_iter()
|
|
.map(|cat| TileEntry {
|
|
category: cat.to_string(),
|
|
axis: view.try_axis_of(cat).unwrap_or(Axis::None),
|
|
})
|
|
.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: the raw text of every formula.
|
|
pub fn formulas_of(app: &App) -> FormulaCache {
|
|
FormulaCache {
|
|
formulas: formula_texts(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(rc) => Some((rc.record, app.record_version_of(rc.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)]
|
|
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);
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
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)]
|
|
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 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 accepted = match self.sessions.get_mut(&id) {
|
|
None => {
|
|
return Applied::Diff {
|
|
dirty: Dirty::NOTHING,
|
|
broadcast: Vec::new(),
|
|
};
|
|
}
|
|
Some(session) => {
|
|
if app.model_state.workbook.views.contains_key(asked) {
|
|
// The same fold the client runs, never a second copy of the arm
|
|
// (improvise-w3bf) — `context/code/command.md`.
|
|
reduce_view(&mut session.view, &Command::SwitchView(asked.to_string()));
|
|
}
|
|
session.view.active_view.clone()
|
|
}
|
|
};
|
|
// The asker folded optimistically, so it hears only when the document
|
|
// refused; nobody else is affected at all (improvise-6c6q).
|
|
let broadcast = (accepted != asked)
|
|
.then_some(Broadcast::To(id, Command::SwitchView(accepted)))
|
|
.into_iter()
|
|
.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 dirty = apply_command(app, cmd);
|
|
match app.document_generation() == generation {
|
|
true => {
|
|
let mut broadcast = self.adopt_deleted_views(app);
|
|
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 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()
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
let mut view = ViewState::default();
|
|
reduce_view(
|
|
&mut view,
|
|
&Command::SwitchView(app.model_state.workbook.active_view.clone()),
|
|
);
|
|
view
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
use crate::ui::effect::{AddFormula, Effect, RemoveCategory, 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:?}"
|
|
);
|
|
}
|
|
|
|
/// 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 has no items to hang off and is still the
|
|
/// only row saying those rows exist, so the last window carries it.
|
|
#[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(),
|
|
2,
|
|
"East is inside the collapsed group"
|
|
);
|
|
assert_eq!(
|
|
frame_shape(&grid.row_frame),
|
|
vec!["North", "South", "[Late]"],
|
|
"the header is the last entry, and it is what the user expands"
|
|
);
|
|
}
|
|
|
|
/// 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_consecutive_headers();
|
|
let grid = grid_of(
|
|
&app,
|
|
Viewport {
|
|
row_offset: 1,
|
|
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 consecutive group headers between two rows: `Hidden` is collapsed, so it
|
|
/// emits a header and no items, and `Late`'s follows immediately.
|
|
fn app_with_consecutive_headers() -> App {
|
|
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, not just the nearest: the far
|
|
/// one is the only row saying those items exist. Above the window, the opposite.
|
|
#[test]
|
|
fn consecutive_headers_between_two_rows_all_come() {
|
|
let app = app_with_consecutive_headers();
|
|
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]", "[Late]", "East"],
|
|
"both headers sit between the two rows and both are drawn"
|
|
);
|
|
let group = grid.row_frame.group_of(1).expect("East is under Late");
|
|
assert_eq!(group.name, "Late", "the NEAREST header wins membership");
|
|
let hidden = grid
|
|
.row_frame
|
|
.entries
|
|
.iter()
|
|
.find_map(|e| match e {
|
|
FrameEntry::Group(g) if g.name == "Hidden" => Some(g),
|
|
_ => None,
|
|
})
|
|
.expect("kept");
|
|
assert!(
|
|
hidden.collapsed,
|
|
"and it arrives collapsed, which is what makes it the row that \
|
|
says those rows still exist"
|
|
);
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// 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,
|
|
vec!["Total = Revenue".to_string()],
|
|
"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::<String>::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");
|
|
}
|
|
|
|
/// 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![Broadcast::To(asker, Command::SwitchView("Default".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"
|
|
);
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
|
|
/// 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())
|
|
)],
|
|
"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);
|
|
}
|
|
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);
|
|
}
|
|
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 one_row = grid_body(model, &app.layout, false, 0, 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![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, false, 0),
|
|
"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-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"))
|
|
}
|
|
|
|
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})"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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".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");
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
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 coordinate column is not a cell to write into (gotcha 1), so it is projected
|
|
/// with no id and a client cannot compose a write against it.
|
|
#[test]
|
|
fn a_coordinate_column_is_projected_with_no_identity() {
|
|
let app = records_app();
|
|
let region = column_named(&app, "Region");
|
|
assert_eq!(
|
|
grid_of(&app, WHOLE)
|
|
.cell(0, region)
|
|
.and_then(|c| c.id.clone()),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// 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_eq!(
|
|
ids.iter().filter(|id| id.is_none()).count(),
|
|
1,
|
|
"only the one Region coordinate column may be nameless: {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))
|
|
);
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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"),
|
|
"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",
|
|
"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");
|
|
}
|
|
|
|
/// 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", "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");
|
|
}
|
|
|
|
/// 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");
|
|
}
|
|
}
|