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