//! What a client needs in order to draw, without the model. **Each widget //! folds its own stream** — the fold's rules: `context/code/protocol.md` §3. use serde::{Deserialize, Serialize}; use unicode_width::UnicodeWidthStr; use improvise_core::model::category::CategoryKind; use improvise_core::model::cell::CellValue; use improvise_core::model::record::{AggregateHash, RecordHash}; use improvise_core::view::Axis; use improvise_core::view::layout::PageAxis; use crate::{FormulaId, ViewState}; use improvise_core::formula::Formula; /// A widget's local copy of what it draws. **Total and deterministic**: an /// update naming something absent is dropped, never a panic. pub trait WidgetCache: Default { /// The mutations that reach this widget — derived from the effects that /// change what it draws, not invented ahead of them. type Update; fn apply(&mut self, update: &Self::Update); /// Fold a whole stream, for tests and for seeding from a [`Snapshot`]. fn folded<'a>(updates: impl IntoIterator) -> Self where Self::Update: 'a, { let mut cache = Self::default(); for update in updates { cache.apply(update); } cache } } /// Which stored cell: the record that owns it, and the measure within it. /// The surrogate names the **record**, not the cell (ADR 002). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct RecordCell { pub record: RecordHash, pub measure: Option, } /// What a projected cell *is*, and therefore what may be done to it: **the /// kind of identity is the permission** (ADR 002). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum CellId { /// A stored cell. `SetCell` takes this and nothing else. Record(RecordCell), /// An aggregate over stored cells. `Drill` takes this and nothing else. Aggregate(AggregateHash), /// A cell a formula owns. It carries no surrogate, so a write for it is /// unconstructible rather than refused — see `context/code/protocol.md` §3. Derived { target: String }, /// A records-mode creating column: typing a **name** into it adds that /// measure or category and writes no cell at all (gotcha 1, improvise-poia). Create(CreateKind), /// A records-mode coordinate: the row's record and the category this column /// names. Renaming it moves the whole record — `protocol.md` §3 (improvise-fdk1). Coordinate { record: RecordHash, category: String, }, /// A stored cell whose `category` coordinate names no item of it, so no write /// can address it: `context/code/command.md` (improvise-gk51). NonItem { category: String, item: String }, } /// Why a cell whose coordinate names no item takes no write. One wording for the /// projection's decline and the host's refusal (improvise-gk51). /// `category` is what a person sees for it; `item` is the stray coordinate as /// stored, which has no display because it names no item. pub fn non_item_refusal(category: &str, item: &str) -> String { format!("{item} is no item of {category}, so there is no one cell to write") } /// What a creating column creates. The name typed into it is the whole /// operand: `ModelCommand::AddItem` / `AddCategory` — `protocol.md` §3. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum CreateKind { Measure, Category, } /// One cell as the client draws it. Drawn from `text`, never from `value` — the /// formatting has already happened, and re-deriving it is how ends drift. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CellDisplay { pub text: String, /// Right-align numerics, left-align text — decided by the server, which /// is the side that knows which it was. pub numeric: bool, /// A formula evaluation error, rendered distinctly. pub error: bool, /// What this cell is, and so what may be composed against it (ADR 002). /// **`None` is not "aggregate"** but "no projected cell" — see §3. pub id: Option, /// What an editor should start with — the value as the user last /// **typed** it, not as it is drawn. The difference corrupts data (§3). pub edit_text: Option, /// The value the host evaluated here, unrounded and untyped-by-text: what /// `yank` copies. `text` cannot be re-parsed into it, nor can text like `007` (§3). pub value: Option, } impl CellDisplay { pub fn text(s: impl Into) -> Self { Self { text: s.into(), ..Self::default() } } pub fn number(s: impl Into) -> Self { let text = s.into(); Self { edit_text: Some(text.clone()), text, numeric: true, error: false, id: None, value: None, } } /// An error cell is not editable back into the model — its text is a /// diagnostic, not a value — so it carries no `edit_text`. pub fn error(s: impl Into) -> Self { Self { text: s.into(), numeric: false, error: true, id: None, edit_text: None, value: None, } } /// The same cell, naming what it is. Separate from the constructors so /// the text-only forms stay usable in tests that are not about identity. pub fn with_id(mut self, id: CellId) -> Self { self.id = Some(id); self } } /// Equal when the client could not tell them apart, so a NaN equals itself and /// the diff quiesces (improvise-4w09) — `context/code/protocol.md` §3. impl PartialEq for CellDisplay { fn eq(&self, other: &Self) -> bool { let CellDisplay { text, numeric, error, id, edit_text, value, } = self; *text == other.text && *numeric == other.numeric && *error == other.error && *id == other.id && *edit_text == other.edit_text && match (value, &other.value) { (Some(a), Some(b)) => same_on_the_wire(a, b), (a, b) => a.is_none() && b.is_none(), } } } impl Eq for CellDisplay {} /// Every NaN is one token on the wire; any other number is its bits, so `0.0` /// and `-0.0` differ. An equivalence relation, which `f64`'s `==` is not. fn same_on_the_wire(a: &CellValue, b: &CellValue) -> bool { match (a, b) { (CellValue::Number(a), CellValue::Number(b)) => { a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()) } (a, b) => a == b, } } /// What a title bar draws: facts about the **document**, shared by every /// client. Why a cache and not a `ViewState` field: `context/code/protocol.md` §3. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct DocumentCache { pub name: String, /// Unsaved changes. Raised by any model effect, cleared by `Save` — which /// is `ViewOnly`, so no `EffectKind` can gate this widget (§3). pub dirty: bool, /// What the host calls this document: a file name natively, an OPFS entry /// in a browser. **Never a path** — `None` means it has no home yet. pub stored_as: Option, } impl WidgetCache for DocumentCache { type Update = DocumentCache; fn apply(&mut self, update: &Self::Update) { *self = update.clone(); } } /// Minimum and maximum width of **one level** of a row header, in display /// cells. One home with the rule that clamps to them (improvise-1125). pub const MIN_ROW_HEADER_W: u16 = 4; pub const MAX_ROW_HEADER_W: u16 = 24; /// A run of positions on one axis that share a group. `collapsed` travels /// because it is a fact about the *view*, which only the server holds. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FrameGroup { /// Which header level opened it — an index into [`AxisFrame::cats`]. Runs /// nest, so membership is per level (improvise-e265). pub level: usize, /// The group's depth in its category's hierarchy, 0 for a root. Runs nest /// by `(level, depth)`: `context/code/protocol.md` §3a (improvise-felo.6). pub depth: usize, /// The key; what `toggle-group` and a run lookup name. pub name: String, /// What the header shows (ADR 007); never an address. pub display: String, pub collapsed: bool, } /// One entry on a grid axis, in visual order — interleaved exactly as /// `GridLayout::row_items` interleaves them. Row/column differ only in paint. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum FrameEntry { Group(FrameGroup), /// The run at `(level, depth)`, and every run inside it, ends here — /// `AxisEntry::GroupEnd` on the wire (improvise-gd83, e265). Nothing is drawn. GroupEnd { level: usize, depth: usize, }, /// One label per header level, outermost first. Data indices — the /// `row`/`col` of every other field on [`GridCache`] — count only these. Item { /// The keys: the coordinate this position names, never replaced by `display`. tuple: Vec, /// Parallel to `tuple`, one name per level: what the header shows (ADR 007). display: Vec, /// The layout's own fold flag: `context/code/protocol.md` (improvise-4k1j). collapsed: bool, }, } impl FrameEntry { /// An ordinary, unfolded item shown under its own keys, as /// `AxisEntry::item` is on the layout side. pub fn item(tuple: Vec) -> Self { FrameEntry::Item { display: tuple.clone(), tuple, collapsed: false, } } } /// One axis of the grid's *frame*: header levels, label tuples and groups /// (improvise-anr9). **One** type, on purpose — `context/code/protocol.md` §3. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(try_from = "UncheckedAxisFrame")] pub struct AxisFrame { /// The categories on this axis, outermost first. Empty is a real layout /// (one nameless row or column), not a gap — hence [`AxisFrame::levels`]. pub cats: Vec, /// Parallel to `cats`: what each level's category is called on screen. pub cat_displays: Vec, /// The **absolute** data index of this frame's first item. Zero is a claim /// and not an absence, so it is NOT `serde(default)` — §3a, improvise-1wl0. pub first: usize, pub entries: Vec, } /// An [`AxisFrame`] as the wire delivers it, before its levels are checked /// against its categories: `context/code/protocol.md` §3b. #[derive(Deserialize)] struct UncheckedAxisFrame { cats: Vec, cat_displays: Vec, first: usize, entries: Vec, } impl TryFrom for AxisFrame { type Error = String; fn try_from(raw: UncheckedAxisFrame) -> Result { if raw.cat_displays.len() != raw.cats.len() { return Err(format!( "{} category displays for {} categories", raw.cat_displays.len(), raw.cats.len() )); } let ragged = raw.entries.iter().find_map(|e| match e { FrameEntry::Item { tuple, display, .. } if tuple.len() != display.len() => { Some((tuple.len(), display.len())) } _ => None, }); if let Some((keys, shown)) = ragged { return Err(format!("{shown} displays for a tuple of {keys} keys")); } let beyond = raw .entries .iter() .filter_map(|e| match e { FrameEntry::Group(g) => Some(g.level), FrameEntry::GroupEnd { level, .. } => Some(*level), FrameEntry::Item { .. } => None, }) .find(|level| *level >= raw.cats.len()); match beyond { Some(level) => Err(format!( "group level {level} names no category: this axis has {}", raw.cats.len() )), None => Ok(Self { cats: raw.cats, cat_displays: raw.cat_displays, first: raw.first, entries: raw.entries, }), } } } impl AxisFrame { /// A single-level axis with no groups. `cats` is left empty, so this is /// not a way to smuggle a multi-level axis in flattened. pub fn flat>(labels: impl IntoIterator) -> Self { Self { cats: Vec::new(), cat_displays: Vec::new(), first: 0, entries: labels .into_iter() .map(|l| FrameEntry::item(vec![l.into()])) .collect(), } } /// The data-item tuples, in order. Group headers are skipped: a data /// index counts items only, exactly as `GridLayout` does. pub fn items(&self) -> impl Iterator> { self.entries.iter().filter_map(|e| match e { FrameEntry::Item { tuple: v, .. } => Some(v), FrameEntry::Group(_) | FrameEntry::GroupEnd { .. } => None, }) } /// The display tuples, parallel to [`AxisFrame::items`]: what is drawn. pub fn displays(&self) -> impl Iterator> { self.entries.iter().filter_map(|e| match e { FrameEntry::Item { display, .. } => Some(display), FrameEntry::Group(_) | FrameEntry::GroupEnd { .. } => None, }) } /// The innermost label of each data item. **Not "the labels"** — a draw /// loop wants [`AxisFrame::header_labels`] and would draw these wrong. pub fn leaf_labels(&self) -> Vec { self.items() .map(|v| v.last().cloned().unwrap_or_default()) .collect() } /// How many data items this frame names — the number of rows or columns /// the cells are indexed by. pub fn len(&self) -> usize { self.items().count() } pub fn is_empty(&self) -> bool { self.len() == 0 } /// One data item's label tuple. **Not for a draw loop**: it scans, so /// per-position use is quadratic — [`AxisFrame::header_labels`] instead. pub fn item(&self, idx: usize) -> Option<&Vec> { self.items().nth(idx) } /// Whether the item at `idx` is a fold or lies under one, at any level: the /// layout's own flag, as `GridLayout::row_folds_a_group` reads it (4k1j). pub fn folds(&self, idx: usize) -> bool { self.entries .iter() .filter_map(|e| match e { FrameEntry::Item { collapsed, .. } => Some(*collapsed), FrameEntry::Group(_) | FrameEntry::GroupEnd { .. } => None, }) .nth(idx) .unwrap_or(false) } /// How many header levels this axis draws. **At least one**: an axis /// with no categories still draws a single, empty header line. pub fn levels(&self) -> usize { self.cats.len().max(1) } /// One label, or `""` where the tuple is shorter. **Not for a draw /// loop**: it goes through [`AxisFrame::item`] and inherits its scan. pub fn label(&self, idx: usize, level: usize) -> &str { self.item(idx) .and_then(|v| v.get(level)) .map(String::as_str) .unwrap_or_default() } /// Draw the header at `(idx, level)`, or blank it as a repeat? **Not for /// a draw loop** — the worst of the four scanning accessors; see §3. pub fn shows_level(&self, idx: usize, first_rendered: usize, level: usize) -> bool { shows( &self.items().collect::>(), idx, first_rendered, level, ) } /// Every header label, suppression applied, indexed `[idx][level]`. /// **One pass, and that is the point** — what a draw loop wants (§3). pub fn header_labels(&self, first_rendered: usize) -> Vec> { let items: Vec<&Vec> = self.items().collect(); let shown: Vec<&Vec> = self.displays().collect(); let levels = self.levels(); (0..items.len()) .map(|idx| { (0..levels) .map(|level| match shows(&items, idx, first_rendered, level) { true => shown[idx].get(level).cloned().unwrap_or_default(), false => String::new(), }) .collect() }) .collect() } /// Whether each item shares `with`'s ancestry per level — the TUI's /// column-header underline. No per-position form, deliberately. pub fn shares_ancestry(&self, with: usize) -> Vec> { let items: Vec<&Vec> = self.items().collect(); let levels = self.levels(); let prefix = |i: usize, level: usize| items.get(i).map(|v| &v[..(level + 1).min(v.len())]); (0..items.len()) .map(|idx| { (0..levels) .map(|level| match (prefix(idx, level), prefix(with, level)) { (Some(a), Some(b)) => a == b, _ => false, }) .collect() }) .collect() } /// The innermost group each data item sits in, in order — one pass. A group /// at `(level, depth)` closes deeper runs; so does a `GroupEnd` there (§3a). pub fn groups(&self) -> Vec> { let mut open: Vec<&FrameGroup> = Vec::new(); let close = |open: &mut Vec<&FrameGroup>, key: (usize, usize)| { open.truncate(open.partition_point(|g| (g.level, g.depth) < key)) }; let mut out = Vec::new(); for entry in &self.entries { match entry { FrameEntry::Group(g) => { close(&mut open, (g.level, g.depth)); open.push(g); } FrameEntry::GroupEnd { level, depth } => close(&mut open, (*level, *depth)), FrameEntry::Item { .. } => out.push(open.last().copied()), } } out } /// The item's innermost group — [`AxisFrame::groups`] at one position. It /// walks the whole frame, so it is not for a draw loop; §3. pub fn group_of(&self, idx: usize) -> Option<&FrameGroup> { self.groups().get(idx).copied().flatten() } /// The runs of adjacent positions sharing a group — one home, both front /// ends (improvise-8qlr). Opened by a HEADER, not a name: §3a, improvise-h0iv. pub fn group_runs(&self) -> Vec<(Option<&FrameGroup>, usize)> { let mut runs: Vec<(Option<&FrameGroup>, usize)> = Vec::new(); for group in self.groups() { match runs.last_mut() { Some((prev, span)) if opened_by(*prev, group) => *span += 1, _ => runs.push((group, 1)), } } runs } /// The group label to paint above each position, `None` where the run /// continues. Derived from [`AxisFrame::group_runs`], never beside it. pub fn group_run_heads(&self) -> Vec> { self.group_runs() .into_iter() .flat_map(|(group, span)| { std::iter::once(group).chain(std::iter::repeat_n(None, span - 1)) }) .collect() } /// Position of the data item `idx` within `entries`, group headers /// included. fn visual_of(&self, idx: usize) -> Option { self.entries .iter() .enumerate() .filter(|(_, e)| matches!(e, FrameEntry::Item { .. })) .map(|(vi, _)| vi) .nth(idx) } /// The entries a renderer walks from data item `idx`. The header above it /// is dropped, EXCEPT at the top of the axis — §3a, improvise-0o6x. pub fn body_from(&self, idx: usize) -> &[FrameEntry] { match self.first + idx == 0 { true => &self.entries[..], false => { let start = self.visual_of(idx).unwrap_or(self.entries.len()); &self.entries[start..] } } } /// The width of each header level, in display cells. Derived rather than /// projected, because the frame is the whole input (§3, improvise-1125). pub fn header_widths(&self) -> Vec { header_level_widths(self.displays(), self.levels()) } } /// Same `FrameEntry::Group`, by identity — two equal headers under different /// outer items are two runs. Both borrow `self.entries`, so this is total. fn opened_by(one: Option<&FrameGroup>, other: Option<&FrameGroup>) -> bool { match (one, other) { (None, None) => true, (Some(one), Some(other)) => std::ptr::eq(one, other), _ => false, } } /// The row-header width rule's **one home** (improvise-1125). It borrows /// rather than taking an [`AxisFrame`], for the keypress path — see §3. pub fn header_level_widths<'a>( items: impl IntoIterator>, levels: usize, ) -> Vec { let mut widest = vec![0u16; levels]; for tuple in items { for (level, w) in widest.iter_mut().enumerate() { if let Some(label) = tuple.get(level) { let measured = UnicodeWidthStr::width(label.as_str()); *w = (*w).max(measured.min(MAX_ROW_HEADER_W as usize) as u16); } } } widest .into_iter() .map(|w| (w + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W)) .collect() } /// The suppression rule's one home. [`AxisFrame::shows_level`] and /// [`AxisFrame::header_labels`] are its lookup and bulk entry points. fn shows(items: &[&Vec], idx: usize, first_rendered: usize, level: usize) -> bool { let prefix = |i: usize| items.get(i).map(|v| &v[..(level + 1).min(v.len())]); idx == first_rendered || idx == 0 || prefix(idx) != prefix(idx - 1) } /// The grid widget's copy of the visible cells (improvise-n10), indexed by /// position within the **viewport** and never by `CellKey`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct GridCache { /// The row axis's frame: levels, label tuples and groups. **Not** a list /// of strings — see [`AxisFrame`]. pub row_frame: AxisFrame, pub col_frame: AxisFrame, pub col_widths: Vec, /// Row-major, `row_frame.len()` rows of `col_frame.len()` cells. pub cells: Vec>, /// The column totals strip, framed by `col_frame`. **Empty means "no /// totals"**, which is not a row of blanks — see §3. pub totals: Vec, /// `(category, selected item)` per fixed category — the page bar. Page /// *and* Filter (`View::filtering_cats`, gotcha 27); framed by nothing. pub page_coords: Vec, /// How big the **whole** grid is, of which the frames are one window /// (improvise-bpq2). Not derivable, and not framed — see §3. pub extent: GridExtent, /// Is this the wide records layout (gotcha 1)? **Projected, never derived /// from the axes or from a mode** — improvise-e5pf, and §3. pub records: bool, } /// The size of the whole grid: what a cursor clamps against. A named pair, /// because two bare counts read as "how many rows are in the frame". #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct GridExtent { pub rows: usize, pub cols: usize, } impl GridCache { pub fn cell(&self, row: usize, col: usize) -> Option<&CellDisplay> { self.cells.get(row)?.get(col) } /// The **compaction** a server sends a late client — not a replay. The /// ordering is what earns it: the frames go first. pub fn seed(&self) -> Vec { let cells = self .cells .iter() .enumerate() .flat_map(|(r, row)| { row.iter() .enumerate() .filter(|(_, cell)| *cell != &CellDisplay::default()) .map(move |(c, cell)| ((r, c), cell.clone())) }) .collect(); vec![ GridUpdate::Extent(self.extent), GridUpdate::Records(self.records), GridUpdate::RowFrame(self.row_frame.clone()), GridUpdate::ColFrame(self.col_frame.clone()), GridUpdate::ColWidths(self.col_widths.clone()), GridUpdate::PageCoords(self.page_coords.clone()), GridUpdate::Totals(self.totals.clone()), GridUpdate::Cells(cells), ] } /// Resize to match the current frames, preserving what still fits. /// **Every arm touching a frame or the widths re-fits** — see §3. fn fit_to_labels(&mut self) { let cols = self.col_frame.len(); self.cells.resize_with(self.row_frame.len(), Vec::new); for row in &mut self.cells { row.resize_with(cols, CellDisplay::default); } self.col_widths.truncate(cols); if self.col_widths.len() < cols { // Materialized once: `AxisFrame::item` scans, so asking it per // column would make a re-fit quadratic in the column count. let items: Vec<&Vec> = self.col_frame.displays().collect(); for levels in &items[self.col_widths.len()..cols] { // Display cells, the unit `header_widths` and the // authoritative `ColWidths` use — `chars().count()` is short. let width = levels .iter() .map(|s| UnicodeWidthStr::width(s.as_str())) .max() .unwrap_or(0); self.col_widths.push(width.min(u16::MAX as usize) as u16); } } // An *empty* strip means "no totals here" rather than "not caught // up yet", so it is padded only once it is non-empty. if !self.totals.is_empty() { self.totals.resize_with(cols, CellDisplay::default); } } /// Blank every position the frame re-identifies. **A `cats` change /// re-identifies all of them at once** — the full argument is in §3. fn invalidate_relabelled(&mut self, old: &AxisFrame, axis: LabelAxis) { let new = match axis { LabelAxis::Row => &self.row_frame, LabelAxis::Col => &self.col_frame, }; // The categories are half of every coordinate on this axis: when // they move, nothing on it survives — all of them, not some. let recategorized = old.cats != new.cats; let old_items: Vec<&Vec> = old.items().collect(); let new_items: Vec<&Vec> = new.items().collect(); let stale: Vec = (0..new_items.len()) .filter(|&i| recategorized || old_items.get(i) != Some(&new_items[i])) .collect(); for i in stale { match axis { LabelAxis::Row => { if let Some(row) = self.cells.get_mut(i) { row.fill(CellDisplay::default()); } } LabelAxis::Col => { for row in &mut self.cells { if let Some(cell) = row.get_mut(i) { *cell = CellDisplay::default(); } } if let Some(total) = self.totals.get_mut(i) { *total = CellDisplay::default(); } } } } // The totals go with a recategorization of **either** axis: a column // total sums the rows, so re-identifying the rows strands it. if recategorized { for total in &mut self.totals { *total = CellDisplay::default(); } } } } /// Which axis a re-fit is reacting to. Private: the row and column cases /// share one invalidation rule rather than two copies that can disagree. #[derive(Debug, Clone, Copy)] enum LabelAxis { Row, Col, } /// What can change about the grid: cell values, the frames that name them, /// the widths, the totals, the page bar and the extent. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum GridUpdate { /// Display values for cells, by viewport position. Cells(Vec<((usize, usize), CellDisplay)>), /// The row axis's whole frame — levels, label tuples and groups /// (improvise-anr9). See [`AxisFrame`]. RowFrame(AxisFrame), ColFrame(AxisFrame), ColWidths(Vec), /// The column totals strip. An empty vector means the grid has no /// totals to draw, which is a fact and not a gap — §3 for the blanking. Totals(Vec), /// The categories the view has fixed, `(category, selected item)`. PageCoords(Vec), /// How big the whole grid is (improvise-bpq2). Alone among these arms /// its batch position is **not** load-bearing — see §3. Extent(GridExtent), /// Whether this is the wide records layout (improvise-e5pf). Frames /// nothing, like [`GridUpdate::Extent`]. Records(bool), } impl WidgetCache for GridCache { type Update = GridUpdate; /// **Not commutative, and deliberately so.** The three rules the frames /// impose on this fold are in `context/code/protocol.md` §3. fn apply(&mut self, update: &GridUpdate) { match update { GridUpdate::Cells(changes) => { for ((row, col), display) in changes { if let Some(slot) = self.cells.get_mut(*row).and_then(|r| r.get_mut(*col)) { *slot = display.clone(); } } } GridUpdate::RowFrame(frame) => { let old = std::mem::replace(&mut self.row_frame, frame.clone()); self.fit_to_labels(); self.invalidate_relabelled(&old, LabelAxis::Row); } GridUpdate::ColFrame(frame) => { let old = std::mem::replace(&mut self.col_frame, frame.clone()); self.fit_to_labels(); self.invalidate_relabelled(&old, LabelAxis::Col); } GridUpdate::ColWidths(widths) => { self.col_widths = widths.clone(); self.fit_to_labels(); } GridUpdate::Totals(totals) => { self.totals = totals.clone(); self.fit_to_labels(); } GridUpdate::PageCoords(coords) => self.page_coords = coords.clone(), // No `fit_to_labels`: the extent is the size of the grid the // frames are a window onto, not something they frame. GridUpdate::Extent(extent) => self.extent = *extent, // Nor this: it is what KIND of grid the frames describe. GridUpdate::Records(records) => self.records = *records, } } } /// One category as the category panel draws it, with every item. Which are /// *expanded* is per-client (`ViewState::expanded_cats`), so not on the wire. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CategoryRow { pub name: String, /// What the panel shows; `name` stays what a command names (ADR 007). pub display: String, pub items: Vec, pub axis: Axis, } /// One item row: the key a command names, and what the panel shows (ADR 007). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CategoryItem { pub name: String, pub display: String, } impl CategoryItem { /// An item shown under its own key. pub fn plain(name: impl Into) -> Self { let name = name.into(); Self { display: name.clone(), name, } } } impl From<&str> for CategoryItem { fn from(name: &str) -> Self { Self::plain(name) } } /// The category panel's categories, items and axes (improvise-pca) — /// **every** category, and `_Measure`'s items are derived. See §3. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct CategoryTreeCache { pub categories: Vec, } impl CategoryTreeCache { pub fn category(&self, name: &str) -> Option<&CategoryRow> { self.categories.iter().find(|c| c.name == name) } /// What a person sees for a category key; the key when the cache lacks it. /// For a message only: never pass the result to a lookup (core.md §21). pub fn category_display<'a>(&'a self, name: &'a str) -> &'a str { self.category(name).map_or(name, |c| c.display.as_str()) } /// What a person sees for an item of `cat`; the key when either is absent. pub fn item_display<'a>(&'a self, cat: &str, item: &'a str) -> &'a str { self.category(cat) .and_then(|c| c.items.iter().find(|i| i.name == item)) .map_or(item, |i| i.display.as_str()) } } /// Three mutations, because the effects are three *shapes*. `Items` and /// `Axes` have no producer yet, deliberately — the argument is in §3. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum CategoryTreeUpdate { /// The set or order of categories changed. Categories(Vec), Items { category: String, items: Vec, }, /// Every category whose axis moved, in one update. Axes(Vec<(String, Axis)>), } impl WidgetCache for CategoryTreeCache { type Update = CategoryTreeUpdate; fn apply(&mut self, update: &CategoryTreeUpdate) { match update { CategoryTreeUpdate::Categories(rows) => self.categories = rows.clone(), CategoryTreeUpdate::Items { category, items } => { if let Some(row) = self.categories.iter_mut().find(|c| &c.name == category) { row.items = items.clone(); } } CategoryTreeUpdate::Axes(moves) => { for (name, axis) in moves { if let Some(row) = self.categories.iter_mut().find(|c| &c.name == name) { row.axis = *axis; } } } } } } /// The formula panel's copy of the formula list (improvise-pca). Every /// formula effect sends it whole. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct FormulaCache { pub formulas: Vec, } /// One formula as a client holds it: its text, and the identity an edit names /// it by, which the text alone cannot give a client (improvise-km71). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FormulaEntry { pub id: FormulaId, pub raw: String, } impl From<&Formula> for FormulaEntry { fn from(f: &Formula) -> Self { Self { id: f.id(), raw: f.raw.clone(), } } } /// A `_Measure` formula's entry, identified exactly as the host identifies it. #[cfg(any(test, feature = "test-support"))] impl From<&str> for FormulaEntry { fn from(raw: &str) -> Self { Self::from(&improvise_core::formula::parse_formula(raw, "_Measure").expect(raw)) } } impl WidgetCache for FormulaCache { type Update = Vec; fn apply(&mut self, update: &Vec) { self.formulas = update.clone(); } } /// One view as the view panel draws it: its name and where each category /// sits in it, which the panel renders as an axis summary. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ViewSummary { pub name: String, /// What the view panel shows; `name` stays what a command names. pub display: String, /// Where each category this view displays sits. pub axes: Vec, } /// One category's place in a view: the key a command names, what the panel /// shows for it, and the axis. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ViewAxis { pub category: String, pub display: String, pub axis: Axis, } impl ViewAxis { /// A category shown under its own key. pub fn plain(category: impl Into, axis: Axis) -> Self { let category = category.into(); Self { display: category.clone(), category, axis, } } } /// The view panel's copy of the view list (improvise-pca). Which view is /// *active* is per-client and lives on `ViewState` (improvise-8uz). #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ViewListCache { pub views: Vec, } impl ViewListCache { /// What a person sees for a view key; the key when the list lacks it. /// For a title or a message only, never a lookup (core.md §21). pub fn display_of<'a>(&'a self, key: &'a str) -> &'a str { self.views .iter() .find(|v| v.name == key) .map_or(key, |v| v.display.as_str()) } } impl WidgetCache for ViewListCache { type Update = Vec; fn apply(&mut self, update: &Vec) { self.views = update.clone(); } } /// One tile in the tile bar. `kind` is the host's answer: a client cannot tell /// `_Index` from a user category without sniffing names (improvise-bqm0). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TileEntry { pub category: String, /// What the tile shows; `category` stays what a command names. pub display: String, pub axis: Axis, pub kind: CategoryKind, } /// Where tile select opens: the best-ranked tile, the leftmost on a tie, so /// `T Enter` never puts `_Index` alone on Row. `context/code/command.md`. pub fn opening_tile(tiles: &[TileEntry]) -> usize { let rank = |kind: CategoryKind| match kind { CategoryKind::Regular => 0, CategoryKind::VirtualMeasure => 1, CategoryKind::Label => 2, CategoryKind::VirtualIndex | CategoryKind::VirtualDim => 3, }; tiles .iter() .enumerate() .min_by_key(|(i, tile)| (rank(tile.kind), *i)) .map_or(0, |(i, _)| i) } /// The tile bar's tileable categories and axes (improvise-pca). Its overlap /// with [`CategoryTreeCache`] is **the point** — see §3. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TileBarCache { pub tiles: Vec, } impl WidgetCache for TileBarCache { type Update = Vec; fn apply(&mut self, update: &Vec) { self.tiles = update.clone(); } } /// Which step the import wizard is on. Spelled again rather than /// re-exported: this crate must not depend on `improvise-io` (gotcha 30). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum WizardStepKind { Preview, SelectArrayPath, ReviewProposals, ConfigureDates, ResolveDuplicates, NameDuplicateCategory, DefineFormulas, NameModel, Done, } /// What kind of thing a proposed field is. The semantic half of a colour /// decision; the terminal mapping lives in `draw.rs` (gotcha 26). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum WizardFieldKind { Category, Measure, TimeCategory, Label, } /// One row of the field-review table. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WizardProposal { pub field: String, pub kind: WizardFieldKind, /// The kind's user-facing label, carried rather than re-derived from /// `kind` so the two ends cannot disagree about what it is called. pub kind_label: String, pub accepted: bool, } /// One time field on the date-components step. Components are /// `(label, enabled)` pairs, so `DateComponent` does not have to cross. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WizardDateField { pub field: String, /// The detected date format, or `"?"` when none was detected — which is /// what is drawn, so the substitution is made once, here. pub format: String, pub components: Vec<(String, bool)>, /// What the chosen components become, as a sentence: one hierarchical /// category, or a split (improvise-8jk6.3). Worded once, in the projection. pub shape: String, } /// Several source records sharing every coordinate (improvise-cgqt). Data, /// so every front end lists the same rows; [`Self::describe`] words it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WizardCollision { pub coords: Vec<(String, String)>, /// 1-based positions of the colliding records in the source. pub records: Vec, } impl WizardCollision { /// One line naming the records and the tuple they share, never /// abbreviated: a front end wraps it rather than cutting it. pub fn describe(&self) -> String { let records: Vec = self.records.iter().map(|r| format!("#{r}")).collect(); let coords: Vec = self .coords .iter() .map(|(c, i)| format!("{c}={i}")) .collect(); format!( "{} records ({}): {}", self.records.len(), records.join(", "), coords.join(", ") ) } } /// One option on the duplicates step; `disabled` says why it cannot be taken. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WizardChoice { pub label: String, pub disabled: Option, } /// The import wizard as the widget draws it (improvise-764). **Deliberately /// not a [`Projection`]** — that absence is the argument; see §3. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WizardCache { pub step: WizardStepKind, /// The cursor within whichever list the current step draws. pub cursor: usize, /// An error or a blocked-step explanation, which takes the bottom line /// from the key hint while it is present. pub message: Option, /// `Preview`: the one-line description of what was parsed. pub preview_summary: String, /// `SelectArrayPath`: candidate paths, already labelled — the root reads /// `"(root)"`, decided here so both ends spell it the same way. pub array_paths: Vec, /// `ReviewProposals`: one row per detected field. pub proposals: Vec, /// `ConfigureDates`: the accepted time fields and their components. pub date_fields: Vec, /// `ResolveDuplicates`: every colliding tuple, in first-seen order. pub collisions: Vec, /// `ResolveDuplicates`: sum, keep separate, go back — in cursor order. pub duplicate_choices: Vec, /// `NameDuplicateCategory`: the name being typed. pub duplicate_category: String, /// The first item drawn of the step's scrolling list. pub scroll: usize, /// `DefineFormulas`: the formulas defined so far. pub formulas: Vec, /// `DefineFormulas`: suggestions derived from the accepted measures. pub sample_formulas: Vec, pub formula_editing: bool, pub formula_buffer: String, /// `NameModel`: the name being typed. pub model_name: String, /// `Done`: a one-line description of the workbook awaiting acceptance, /// absent when there is nothing to confirm. pub built_summary: Option, /// `Done`: warnings raised while building (improvise-907). pub built_warnings: Vec, } /// One server→client update, addressed to one widget. No variant carries /// several widgets' updates — that would be the aggregate cache again. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Projection { Grid(GridUpdate), CategoryTree(CategoryTreeUpdate), /// Every formula: its text and its identity. Formulas(Vec), ViewList(Vec), TileBar(Vec), /// Name, dirty marker and where the host keeps it (improvise-z754). Document(DocumentCache), /// The picture of the document a client's next batch may claim (ADR 001, /// improvise-fkfg). Not a widget, folded like one — see §5. Basis(crate::Basis), } /// What a client receives on connect, and the only thing that is not an /// event. `seed` is a **compaction**, `view` the one `ViewState` on the wire. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Snapshot { pub view: ViewState, pub seed: Vec, } #[cfg(test)] mod tests { use super::*; fn tile(category: &str, axis: Axis, kind: CategoryKind) -> TileEntry { TileEntry { category: category.to_string(), display: category.to_string(), axis, kind, } } /// Records mode puts `_Index` on Row and the first regular elsewhere, so /// "first regular" and "first on Row" part (improvise-52od, protocol.md). #[test] fn tile_select_opens_on_a_regular_category_even_when_a_virtual_holds_row() { let tiles = [ tile("_Index", Axis::Row, CategoryKind::VirtualIndex), tile("_Dim", Axis::Column, CategoryKind::VirtualDim), tile("_Measure", Axis::None, CategoryKind::VirtualMeasure), tile("Region", Axis::Page, CategoryKind::Regular), tile("Product", Axis::Row, CategoryKind::Regular), ]; assert_eq!(opening_tile(&tiles), 3, "Region, the first regular tile"); } /// No regular category: open on `_Measure`, never on `_Index` or `_Dim` /// (improvise-bqm0). Why this ranking: `context/code/command.md`. #[test] fn with_no_regular_category_tile_select_opens_on_measure_not_index() { let tiles = [ tile("_Index", Axis::None, CategoryKind::VirtualIndex), tile("_Dim", Axis::None, CategoryKind::VirtualDim), tile("Note", Axis::None, CategoryKind::Label), tile("_Measure", Axis::Column, CategoryKind::VirtualMeasure), ]; assert_eq!( opening_tile(&tiles), 3, "_Measure outranks a label and the records axes" ); assert_eq!( opening_tile(&tiles[..3]), 2, "a label outranks _Index and _Dim" ); assert_eq!(opening_tile(&[]), 0, "an empty bar opens at 0"); } fn valued(value: CellValue) -> CellDisplay { CellDisplay { value: Some(value), ..CellDisplay::number("x") } } /// Cells are equal when the wire cannot tell them apart: every NaN is one /// token, and `-0.0` is not `0.0`. Evidence file. #[test] fn a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart() { let host_nan = f64::from_bits(f64::NAN.to_bits() | (1 << 63)); assert_ne!( host_nan.to_bits(), f64::NAN.to_bits(), "precondition: two NaNs whose bits differ" ); assert_eq!( valued(CellValue::Number(host_nan)), valued(CellValue::Number(f64::NAN)), "a NaN the host computed and the NaN the client parsed are one value" ); assert_ne!( valued(CellValue::Number(0.0)), valued(CellValue::Number(-0.0)), "the wire spells them differently, so a change between them is sent" ); assert_ne!( valued(CellValue::Number(7.0)), valued(CellValue::Text("7".into())), "a number and number-like text are different values" ); assert_ne!( valued(CellValue::Number(1.0)), CellDisplay::number("x"), "a value and no value differ" ); } fn cat(name: &str, items: &[&str], axis: Axis) -> CategoryRow { CategoryRow { name: name.into(), display: name.into(), items: items.iter().map(|s| CategoryItem::plain(*s)).collect(), axis, } } fn framed_grid() -> GridCache { GridCache::folded(&[ GridUpdate::RowFrame(AxisFrame::flat(["r0", "r1"])), GridUpdate::ColFrame(AxisFrame::flat(["c0", "c1", "c2"])), ]) } /// improvise-44ad: the property 33(c) is really about — a renderer zips /// widths with labels. See `context/test-evidence/protocol.md`. #[test] fn a_client_never_holds_widths_of_a_different_length_from_its_labels() { let mut grid = framed_grid(); grid.apply(&GridUpdate::ColWidths(vec![8, 8, 8])); let stream = [ GridUpdate::ColFrame(AxisFrame::flat(["c0", "c1", "c2", "c3", "c4"])), GridUpdate::Cells(vec![((0, 4), CellDisplay::number("9"))]), GridUpdate::ColWidths(vec![8, 8, 8, 8, 8]), GridUpdate::ColFrame(AxisFrame::flat(["c0", "c1"])), GridUpdate::Totals(vec![CellDisplay::number("1"), CellDisplay::number("2")]), GridUpdate::ColWidths(vec![8, 8]), ]; assert_ne!( grid.col_frame.len(), 5, "precondition: the first update must WIDEN past the widths held, \ or no step of this fold has a mismatch to avoid" ); for (step, update) in stream.iter().enumerate() { grid.apply(update); assert_eq!( grid.col_widths.len(), grid.col_frame.len(), "step {step}: a client held {} widths for {} columns — a \ renderer zipping them indexes off the end", grid.col_widths.len(), grid.col_frame.len() ); } } #[test] fn label_updates_resize_the_cell_grid() { let grid = framed_grid(); assert_eq!(grid.cells.len(), 2); assert!(grid.cells.iter().all(|r| r.len() == 3)); } #[test] fn cell_updates_write_by_viewport_position() { let mut grid = framed_grid(); grid.apply(&GridUpdate::Cells(vec![( (1, 0), CellDisplay::number("42"), )])); assert_eq!(grid.cell(1, 0).unwrap().text, "42"); assert!(grid.cell(1, 0).unwrap().numeric); assert_eq!(grid.cell(0, 0).unwrap().text, ""); } /// Dropped, not a panic: frames and cell updates race on the wire. /// See `context/test-evidence/protocol.md`. #[test] fn cell_updates_outside_the_cache_are_dropped() { let mut grid = framed_grid(); grid.apply(&GridUpdate::Cells(vec![( (99, 99), CellDisplay::text("ghost"), )])); assert_eq!(grid.cell(0, 0).unwrap().text, ""); } /// Relabelling row 0 blanks it and keeps the frame — the job the retired /// `Invalidated` arm described. `context/test-evidence/protocol.md`. #[test] fn a_relabelled_row_is_blanked_and_the_frame_survives() { let mut grid = framed_grid(); grid.apply(&GridUpdate::Cells(vec![ ((0, 0), CellDisplay::number("7")), ((1, 0), CellDisplay::number("8")), ])); grid.apply(&GridUpdate::RowFrame(AxisFrame::flat(["renamed", "r1"]))); assert_eq!(grid.cell(0, 0).unwrap().text, ""); assert_eq!( grid.cell(1, 0).unwrap().text, "8", "a row whose label did not change keeps its contents" ); assert_eq!( grid.row_frame.leaf_labels(), vec!["renamed".to_string(), "r1".to_string()] ); assert_eq!(grid.col_frame.len(), 3); } /// The retired arm must not come back as a serde alias, which would be a /// wire spelling nothing produces. `context/test-evidence/protocol.md`. #[test] fn the_retired_invalidated_spelling_does_not_parse() { let well_formed = r#"{"Grid":{"ColWidths":[8]}}"#; serde_json::from_str::(well_formed).expect("the shape is right"); let retired = r#"{"Grid":"Invalidated"}"#; let err = serde_json::from_str::(retired) .expect_err("`Invalidated` must not be a GridUpdate any more"); assert!( err.to_string().contains("unknown variant"), "it must fail as an unknown variant, not as broken JSON: {err}" ); } #[test] fn shrinking_the_labels_drops_the_cells_that_no_longer_fit() { let mut grid = framed_grid(); grid.apply(&GridUpdate::Cells(vec![((1, 2), CellDisplay::number("9"))])); grid.apply(&GridUpdate::RowFrame(AxisFrame::flat(["r0"]))); assert_eq!(grid.cells.len(), 1); assert!(grid.cell(1, 2).is_none()); } /// The totals are framed by the columns on the cells' own terms. /// See `context/test-evidence/protocol.md`. #[test] fn a_totals_update_that_overtakes_its_columns_is_cut_to_fit() { let mut grid = framed_grid(); grid.apply(&GridUpdate::Totals(vec![ CellDisplay::number("1"), CellDisplay::number("2"), CellDisplay::number("3"), CellDisplay::number("4"), CellDisplay::number("5"), ])); assert_eq!(grid.totals.len(), grid.col_frame.len()); } /// An *empty* strip says "no totals", not "a row of blanks". /// See `context/test-evidence/protocol.md`. #[test] fn an_empty_totals_strip_is_not_padded_into_blanks() { let mut grid = framed_grid(); assert!(grid.totals.is_empty()); grid.apply(&GridUpdate::ColFrame(AxisFrame::flat(["c0", "c1"]))); assert!(grid.totals.is_empty(), "no totals is not a row of blanks"); } /// The totals are contents, not frame. /// See `context/test-evidence/protocol.md`. #[test] fn a_recategorized_axis_blanks_the_totals_too() { let mut grid = framed_grid(); grid.apply(&GridUpdate::Totals(vec![ CellDisplay::number("1"), CellDisplay::number("2"), CellDisplay::number("3"), ])); grid.apply(&GridUpdate::RowFrame(AxisFrame { first: 0, cats: vec!["Zone".into()], cat_displays: vec!["Zone".into()], entries: grid.row_frame.entries.clone(), })); assert!(grid.totals.iter().all(|t| t.text.is_empty())); assert_eq!( grid.totals.len(), 3, "the frame survives; the numbers do not" ); } /// The page bar is framed by nothing: it names categories, not /// positions. See `context/test-evidence/protocol.md`. #[test] fn the_page_bar_outlives_a_reshape() { let mut grid = framed_grid(); let payer = PageAxis::plain("Payer", "Alice", Some(vec!["Alice".into(), "Bob".into()])); grid.apply(&GridUpdate::PageCoords(vec![payer.clone()])); grid.apply(&GridUpdate::RowFrame(AxisFrame::default())); grid.apply(&GridUpdate::ColFrame(AxisFrame::default())); assert_eq!(grid.page_coords, vec![payer]); } // ── AxisFrame: the frame rule both front ends now share ───────────── /// A two-level axis: [A,x], [A,y], [B,z], with `B` in a collapsed group. fn two_level_frame() -> AxisFrame { AxisFrame { first: 0, cats: vec!["Type".into(), "Who".into()], cat_displays: vec!["Type".into(), "Who".into()], entries: vec![ FrameEntry::item(vec!["A".into(), "x".into()]), FrameEntry::item(vec!["A".into(), "y".into()]), FrameEntry::Group(FrameGroup { level: 0, depth: 0, name: "Late".into(), display: "Late".into(), collapsed: true, }), FrameEntry::item(vec!["B".into(), "z".into()]), ], } } /// Two keys per level shown under displays: the outer pair share a display, /// the inner pair share a KEY. Suppression must follow the keys. fn shown_frame() -> AxisFrame { let item = |tuple: [&str; 2], display: [&str; 2]| FrameEntry::Item { tuple: tuple.map(String::from).to_vec(), display: display.map(String::from).to_vec(), collapsed: false, }; AxisFrame { cats: vec!["Region".into(), "Month".into()], cat_displays: vec!["Sales Area".into(), "Period".into()], first: 0, entries: vec![ item(["East", "Jan"], ["Same", "January"]), item(["East", "Feb"], ["Same", "February"]), item(["West", "Jan"], ["Same", "January"]), ], } } #[test] fn a_header_draws_displays_and_blanks_a_repeat_by_key() { let frame = shown_frame(); assert_eq!( frame.header_labels(0), vec![ vec!["Same".to_string(), "January".into()], vec![String::new(), "February".into()], vec!["Same".to_string(), "January".into()], ], "East repeats and blanks; West shares East's DISPLAY and still draws" ); let keys: Vec<&Vec> = frame.items().collect(); assert_eq!(keys[2], &vec!["West".to_string(), "Jan".into()]); assert_eq!(frame.label(0, 0), "East", "the lookup is still the key"); } #[test] fn a_header_is_as_wide_as_what_it_shows() { let frame = AxisFrame { cats: vec!["R".into()], cat_displays: vec!["Region".into()], first: 0, entries: vec![FrameEntry::Item { tuple: vec!["E".into()], display: vec!["Eastern Seaboard".into()], collapsed: false, }], }; assert_eq!( frame.header_widths(), vec!["Eastern Seaboard".len() as u16 + 1] ); } #[test] fn a_frame_with_ragged_displays_is_refused() { let good = serde_json::to_value(shown_frame()).expect("a frame serializes"); assert!(serde_json::from_value::(good.clone()).is_ok()); let mut short_cats = good.clone(); short_cats["cat_displays"] = serde_json::json!(["Only one"]); let err = serde_json::from_value::(short_cats).unwrap_err(); assert!(err.to_string().contains("category displays"), "{err}"); let mut short_tuple = good; short_tuple["entries"][0]["Item"]["display"] = serde_json::json!(["Same"]); let err = serde_json::from_value::(short_tuple).unwrap_err(); assert!(err.to_string().contains("displays for a tuple"), "{err}"); } /// Two runs of one group name under two different outer items, with nothing /// ungrouped between them — improvise-h0iv's reachable shape. fn repeated_group_frame() -> AxisFrame { let q1 = || FrameGroup { level: 1, depth: 0, name: "Q1".into(), display: "Q1".into(), collapsed: false, }; AxisFrame { first: 0, cats: vec!["Region".into(), "Month".into()], cat_displays: vec!["Region".into(), "Month".into()], entries: vec![ FrameEntry::Group(q1()), FrameEntry::item(vec!["North".into(), "Jul".into()]), FrameEntry::item(vec!["North".into(), "Aug".into()]), FrameEntry::Group(q1()), FrameEntry::item(vec!["South".into(), "Jul".into()]), FrameEntry::item(vec!["South".into(), "Aug".into()]), ], } } /// The frame `prune_empty` produces when it drops the only item of the /// SECOND group (improvise-u6gc): Q1's run is re-opened, GroupEnd ends it. fn pruned_middle_group_frame() -> AxisFrame { AxisFrame { first: 0, cats: vec!["Month".into()], cat_displays: vec!["Month".into()], entries: vec![ FrameEntry::Group(FrameGroup { level: 0, depth: 0, name: "Q1".into(), display: "Q1".into(), collapsed: false, }), FrameEntry::item(vec!["Jan".into()]), FrameEntry::item(vec!["Feb".into()]), FrameEntry::GroupEnd { level: 0, depth: 0 }, FrameEntry::item(vec!["Sep".into()]), ], } } /// A level past the frame's categories is refused as it arrives, before /// anything sizes a vector by it. Evidence file. #[test] fn a_frame_whose_group_level_names_no_category_does_not_deserialize() { let group = |level: usize, name: &str| { FrameEntry::Group(FrameGroup { level, depth: 0, name: name.into(), display: name.into(), collapsed: true, }) }; let frame = AxisFrame { cats: vec!["Region".into(), "Month".into()], cat_displays: vec!["Region".into(), "Month".into()], first: 0, entries: vec![ group(0, "Upper"), group(1, "Q1"), FrameEntry::GroupEnd { level: 1, depth: 0 }, ], }; let json = serde_json::to_string(&frame).unwrap(); let back: AxisFrame = serde_json::from_str(&json).expect("the control parses"); assert_eq!(back, frame); for (from, to) in [ ( r#""level":1,"depth":0,"name":"Q1""#, r#""level":4294967295,"depth":0,"name":"Q1""#, ), ( r#"{"GroupEnd":{"level":1,"depth":0}}"#, r#"{"GroupEnd":{"level":2,"depth":0}}"#, ), ] { let bad = json.replacen(from, to, 1); assert_ne!(bad, json, "the fault {to} was injected"); serde_json::from_str::(&bad).expect("still well-formed JSON"); let err = serde_json::from_str::(&bad).unwrap_err(); assert!( err.to_string().contains("names no category"), "refused for its level, not its shape: {err}" ); } } /// improvise-felo.6: runs nest by `(level, depth)` inside one category, so /// the end of a quarter leaves its year open. See the evidence file. #[test] fn a_quarters_end_leaves_its_year_open_on_the_same_level() { let group = |depth: usize, name: &str| { FrameEntry::Group(FrameGroup { level: 0, depth, name: name.into(), display: name.into(), collapsed: false, }) }; let frame = AxisFrame { cats: vec!["Date".into()], cat_displays: vec!["Date".into()], first: 0, entries: vec![ group(0, "2024"), group(1, "Q1"), FrameEntry::item(vec!["Jan".into()]), FrameEntry::GroupEnd { level: 0, depth: 1 }, FrameEntry::item(vec!["Direct".into()]), group(1, "Q2"), FrameEntry::item(vec!["Apr".into()]), FrameEntry::GroupEnd { level: 0, depth: 0 }, FrameEntry::item(vec!["Undated".into()]), ], }; let names: Vec> = frame .groups() .into_iter() .map(|g| g.map(|g| g.name.as_str())) .collect(); assert_eq!( names, vec![Some("Q1"), Some("2024"), Some("Q2"), None], "Direct sits in 2024 after Q1 ends; Undated in nothing" ); } /// improvise-gd83's property, asserted in the crate that owns the type. /// See `context/test-evidence/protocol.md`. #[test] fn a_group_end_stops_the_membership_walk() { let frame = pruned_middle_group_frame(); assert_eq!( frame.group_of(0).map(|g| g.name.as_str()), Some("Q1"), "precondition: Jan is in Q1, so the walk has somewhere wrong to land" ); assert_eq!( frame.group_of(2).map(|g| g.name.as_str()), None, "Sep sits after a GroupEnd — membership is not inherited" ); } /// The collapsed frame (improvise-lhi4): a header with NO item under it /// still travels, carrying the flag a client draws the affordance from. #[test] fn a_collapsed_group_reaches_the_frame_with_no_item_under_it() { let frame = AxisFrame { first: 0, cats: vec!["Month".into()], cat_displays: vec!["Month".into()], entries: vec![ FrameEntry::Group(FrameGroup { level: 0, depth: 0, name: "Q1".into(), display: "Q1".into(), collapsed: true, }), FrameEntry::GroupEnd { level: 0, depth: 0 }, FrameEntry::item(vec!["Sep".into()]), ], }; assert_eq!(frame.len(), 1, "the collapsed group contributes no item"); assert!( matches!( frame.entries.first(), Some(FrameEntry::Group(g)) if g.collapsed ), "the header must carry collapsed: true or a client cannot offer re-expansion" ); assert_eq!( frame.group_of(0).map(|g| g.name.as_str()), None, "Sep must not inherit the collapsed run" ); } /// improvise-h0iv: a run is opened by a header, not by a name. See /// `context/test-evidence/protocol.md`. #[test] fn two_runs_under_two_headers_of_one_name_are_two_runs() { let frame = repeated_group_frame(); let spans: Vec<(Option<&str>, usize)> = frame .group_runs() .into_iter() .map(|(group, span)| (group.map(|g| g.name.as_str()), span)) .collect(); assert_eq!( frame .entries .iter() .filter(|e| matches!(e, FrameEntry::Group(_))) .count(), 2, "precondition: two separate headers, or there is nothing to split" ); assert_eq!( spans, vec![(Some("Q1"), 2), (Some("Q1"), 2)], "one span of 4 draws a single colspan across the North/South boundary" ); } /// The pair `FrameGroup` carries cannot separate them — h0iv's own proposed /// fix. See `context/test-evidence/protocol.md`. #[test] fn the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them() { let frame = repeated_group_frame(); let headers: Vec<&FrameGroup> = frame .entries .iter() .filter_map(|e| match e { FrameEntry::Group(g) => Some(g), _ => None, }) .collect(); assert_eq!( headers[0], headers[1], "both headers are the same group in the same state, so no \ comparison of their fields can split them" ); } /// improvise-1wl0: a dropped `first` must not deserialize as the claim that /// this frame LEADS the axis. See `context/test-evidence/protocol.md`. #[test] fn a_frame_that_lost_its_origin_does_not_claim_to_lead_the_axis() { let scrolled = AxisFrame { first: 3, ..two_level_frame() }; let mut json: serde_json::Value = serde_json::to_value(&scrolled).expect("an AxisFrame serializes"); assert!( json.as_object_mut() .expect("an AxisFrame is a JSON object") .remove("first") .is_some(), "the field must have been there, or this proves nothing" ); assert!( json.get("cats").is_some() && json.get("entries").is_some(), "the mutated value must still be a well-formed AxisFrame minus one \ field, or the refusal below is about broken JSON: {json}" ); let err = serde_json::from_value::(json) .expect_err("a frame with no origin states no claim, so it is refused"); assert!( err.to_string().contains("missing field"), "it must fail as a MISSING FIELD, not as a type error: {err}" ); } /// Bug improvise-jjx: the first RENDERED entry always shows its tuple. /// See `context/test-evidence/protocol.md`. #[test] fn the_first_rendered_entry_always_shows_its_level() { let frame = two_level_frame(); assert!( frame.shows_level(1, 1, 0), "index 1 is the first on screen; its level-0 prefix matches the \ off-screen index 0 and must still be drawn" ); } #[test] fn a_repeated_prefix_below_the_first_rendered_entry_is_suppressed() { let frame = two_level_frame(); assert!(!frame.shows_level(1, 0, 0), "[A,y] repeats [A,x]'s 'A'"); assert!(frame.shows_level(1, 0, 1), "its leaf 'y' differs from 'x'"); assert!(frame.shows_level(2, 0, 0), "'B' differs from 'A'"); assert!(frame.shows_level(2, 1, 0), "scrolled, and still differs"); } /// Group headers consume no data index, and an index still names its /// group. See `context/test-evidence/protocol.md`. #[test] fn a_data_index_skips_group_headers_and_still_names_its_group() { let frame = two_level_frame(); assert_eq!(frame.len(), 3, "three data items, one group header"); assert_eq!(frame.item(2), Some(&vec!["B".to_string(), "z".to_string()])); assert!(frame.group_of(0).is_none(), "[A,x] is in no group"); let group = frame.group_of(2).expect("[B,z] sits under Late"); assert_eq!(group.name, "Late"); assert!(group.collapsed, "the collapse state travels with the group"); } /// The underline rule, checked at **both** levels. /// See `context/test-evidence/protocol.md`. #[test] fn shared_ancestry_widens_as_the_level_rises() { let frame = two_level_frame(); let under_ay = frame.shares_ancestry(1); // [A,y] assert_eq!( under_ay, vec![ vec![true, false], // [A,x] shares "A", not "A/x" vec![true, true], // itself vec![false, false], // [B,z] shares nothing ] ); } /// A window opening mid-group names its group without painting its /// header. See `context/test-evidence/protocol.md`. #[test] fn body_from_starts_at_the_item_not_at_the_header_above_it() { let frame = two_level_frame(); assert!( matches!(frame.body_from(2), [FrameEntry::Item { .. }]), "the trailing group header is above item 2, not inside the body" ); assert_eq!(frame.body_from(0).len(), 4, "everything, headers included"); } /// The one width rule (improvise-1125), in **display cells**. /// See `context/test-evidence/protocol.md`. #[test] fn header_widths_are_per_level_and_measured_in_display_cells() { let frame = AxisFrame { first: 0, cats: vec!["A".into(), "B".into()], cat_displays: vec!["A".into(), "B".into()], entries: vec![FrameEntry::item(vec!["Clothing".into(), "日本".into()])], }; assert_eq!( frame.header_widths(), vec![9, 5], "level 0 is 'Clothing' (8) + 1; level 1 is 4 display cells + 1. \ A `chars().count()` would make level 1 two cells wide and clamp \ it to the floor of {MIN_ROW_HEADER_W}, drawing the header short" ); assert_eq!( AxisFrame::flat(["ab"]).header_widths(), vec![MIN_ROW_HEADER_W], "and the floor is real: 'ab' + 1 is under it" ); let wide = AxisFrame::flat(["x".repeat(100)]); assert_eq!(wide.header_widths(), vec![MAX_ROW_HEADER_W]); } /// The fallback width is in the same unit as the authoritative one. /// See `context/test-evidence/protocol.md`. #[test] fn a_column_without_its_width_yet_is_measured_in_display_cells() { let mut grid = GridCache::default(); grid.apply(&GridUpdate::ColFrame(AxisFrame::flat(["\u{65e5}\u{672c}"]))); assert_eq!( grid.col_widths, vec![4], "two scalars, four display cells — a scalar count would say 2" ); } /// An axis with no categories still draws one header line, and reading a /// level off an empty tuple answers `""` rather than panicking. #[test] fn an_axis_with_no_categories_still_has_one_level() { let frame = AxisFrame { first: 0, cats: Vec::new(), cat_displays: Vec::new(), entries: vec![FrameEntry::item(Vec::new())], }; assert_eq!(frame.levels(), 1); assert_eq!(frame.label(0, 0), ""); assert!(frame.shows_level(0, 0, 0)); } #[test] fn item_updates_touch_only_the_named_category() { let mut tree = CategoryTreeCache::folded(&[ CategoryTreeUpdate::Categories(vec![ cat("Region", &["North"], Axis::Row), cat("Product", &["Shirts"], Axis::Column), ]), CategoryTreeUpdate::Items { category: "Region".into(), items: vec!["North".into(), "South".into()], }, ]); assert_eq!(tree.category("Region").unwrap().items.len(), 2); assert_eq!( tree.category("Product").unwrap().items, vec![CategoryItem::plain("Shirts")] ); // An update for a category the cache has never heard of is dropped. tree.apply(&CategoryTreeUpdate::Items { category: "Ghost".into(), items: vec!["boo".into()], }); assert_eq!(tree.categories.len(), 2); } /// A transpose moves two axes at once, which is why `Axes` takes a list /// rather than a single pair. #[test] fn an_axes_update_moves_several_categories_at_once() { let tree = CategoryTreeCache::folded(&[ CategoryTreeUpdate::Categories(vec![ cat("Region", &["North"], Axis::Row), cat("Product", &["Shirts"], Axis::Column), ]), CategoryTreeUpdate::Axes(vec![ ("Region".into(), Axis::Column), ("Product".into(), Axis::Row), ]), ]); assert_eq!(tree.category("Region").unwrap().axis, Axis::Column); assert_eq!(tree.category("Product").unwrap().axis, Axis::Row); assert_eq!( tree.category("Region").unwrap().items, vec![CategoryItem::plain("North")], "moving an axis does not disturb membership" ); } #[test] fn a_category_update_replaces_the_list_wholesale() { let tree = CategoryTreeCache::folded(&[ CategoryTreeUpdate::Categories(vec![cat("Region", &["North"], Axis::Row)]), CategoryTreeUpdate::Categories(vec![cat("Product", &["Shirts"], Axis::Row)]), ]); assert_eq!(tree.categories.len(), 1); assert!(tree.category("Region").is_none()); } #[test] fn the_list_caches_hold_the_last_update() { let formulas = FormulaCache::folded(&[ vec!["Profit = Revenue - Cost".into()], vec![ "Profit = Revenue - Cost".into(), "Tax = Revenue * 0.1".into(), ], ]); assert_eq!(formulas.formulas.len(), 2); let views = ViewListCache::folded(&[vec![ViewSummary { name: "Default".into(), display: "Default".into(), axes: vec![ViewAxis::plain("Region", Axis::Row)], }]]); assert_eq!(views.views[0].name, "Default"); let tiles = TileBarCache::folded(&[vec![TileEntry { category: "Region".into(), display: "Region".into(), axis: Axis::Page, kind: crate::CategoryKind::Regular, }]]); assert_eq!(tiles.tiles[0].axis, Axis::Page); } /// A column dropped from the middle used to slide its neighbours left. /// See `context/test-evidence/protocol.md`. #[test] fn a_relabelled_column_does_not_keep_the_old_columns_value() { let mut grid = GridCache::folded(&[ GridUpdate::RowFrame(AxisFrame::flat(["r0"])), GridUpdate::ColFrame(AxisFrame::flat(["A", "B", "C"])), GridUpdate::Cells(vec![ ((0, 0), CellDisplay::number("1")), ((0, 1), CellDisplay::number("2")), ((0, 2), CellDisplay::number("3")), ]), ]); grid.apply(&GridUpdate::ColFrame(AxisFrame::flat(["A", "C"]))); assert_eq!( grid.cell(0, 0).unwrap().text, "1", "column A never moved, so its value stands" ); assert_eq!( grid.cell(0, 1).unwrap().text, "", "position 1 is labelled C now; B's value there would be a tear" ); } /// Editable [`CellId`]s and totals — everything a reframe can leave /// stale. `cats` is set on both axes; `AxisFrame::flat` leaves them empty. fn identified_grid() -> GridCache { // A surrogate as a client receives one: 32 hex characters off the // wire. It cannot be computed here — this crate holds no model. let surrogate: RecordHash = serde_json::from_str("\"fe4c8e5e9cbd578e0d388f4b2b5a7879\"") .expect("a surrogate is hex"); let cell = |m: &str| { CellDisplay::number("1200").with_id(CellId::Record(RecordCell { record: surrogate, measure: Some(m.into()), })) }; let axis = |cat: &str, labels: [&str; 2]| AxisFrame { first: 0, cats: vec![cat.into()], cat_displays: vec![cat.into()], entries: labels .iter() .map(|l| FrameEntry::item(vec![(*l).into()])) .collect(), }; GridCache::folded(&[ GridUpdate::RowFrame(axis("Region", ["North", "South"])), GridUpdate::ColFrame(axis("Measure", ["Revenue", "Cost"])), GridUpdate::Cells(vec![ ((0, 0), cell("Revenue")), ((0, 1), cell("Cost")), ((1, 0), cell("Revenue")), ((1, 1), cell("Cost")), ]), GridUpdate::Totals(vec![CellDisplay::number("10"), CellDisplay::number("20")]), ]) } /// Renaming the *category* re-identifies every cell, byte-identical /// labels included. See `context/test-evidence/protocol.md`. #[test] fn renaming_a_row_category_invalidates_every_cell_under_it() { let mut grid = identified_grid(); let renamed = AxisFrame { first: 0, cats: vec!["Department".into()], cat_displays: vec!["Department".into()], entries: grid.row_frame.entries.clone(), }; assert_eq!( renamed.items().collect::>(), grid.row_frame.items().collect::>(), "precondition: the ITEMS are identical, so only `cats` can \ answer this — without that the test is just the ordinary \ relabelling case" ); grid.apply(&GridUpdate::RowFrame(renamed)); for (r, row) in grid.cells.iter().enumerate() { for (c, cell) in row.iter().enumerate() { assert_eq!( cell, &CellDisplay::default(), "({r},{c}) answers a coordinate the grid no longer has" ); assert!( cell.id.is_none(), "({r},{c}) still offers a write against the record the \ old coordinate named" ); } } assert!( grid.totals.iter().all(|t| t == &CellDisplay::default()), "a column total sums the rows, and every row was re-identified" ); } /// The column half of the same rule, pinning the cells and the ids. /// See `context/test-evidence/protocol.md`. #[test] fn renaming_a_column_category_invalidates_every_cell_under_it() { let mut grid = identified_grid(); let renamed = AxisFrame { first: 0, cats: vec!["Metric".into()], cat_displays: vec!["Metric".into()], entries: grid.col_frame.entries.clone(), }; grid.apply(&GridUpdate::ColFrame(renamed)); assert!( grid.cells .iter() .flatten() .all(|cell| cell == &CellDisplay::default() && cell.id.is_none()), "every column was re-identified: {:?}", grid.cells ); assert!(grid.totals.iter().all(|t| t == &CellDisplay::default())); } /// **The negative arm, and it is load-bearing.** /// See `context/test-evidence/protocol.md`. #[test] fn an_unchanged_category_leaves_the_cells_that_still_fit() { let mut grid = identified_grid(); let same = grid.row_frame.clone(); grid.apply(&GridUpdate::RowFrame(same)); assert_eq!( grid.cell(0, 0).map(|c| c.text.as_str()), Some("1200"), "nothing was re-identified, so nothing is stale" ); assert!( grid.cell(0, 0).unwrap().id.is_some(), "and it is still editable" ); assert_eq!(grid.totals[0].text, "10"); } /// improvise-hkt6: a renamed column kept its **total**. Three columns and /// a rename in the middle — `context/test-evidence/protocol.md`. #[test] fn a_relabelled_column_drops_its_total() { let mut grid = GridCache::folded(&[ GridUpdate::RowFrame(AxisFrame::flat(["r0"])), GridUpdate::ColFrame(AxisFrame::flat(["A", "B", "C"])), GridUpdate::Totals(vec![ CellDisplay::number("10"), CellDisplay::number("20"), CellDisplay::number("30"), ]), ]); grid.apply(&GridUpdate::ColFrame(AxisFrame::flat(["A", "Bee", "C"]))); let texts: Vec<&str> = grid.totals.iter().map(|t| t.text.as_str()).collect(); assert_eq!( texts, vec!["10", "", "30"], "only the renamed column's sum answered a label that is gone" ); } /// The row half is the *negative* arm and has to stay: a total spans /// every row. See `context/test-evidence/protocol.md`. #[test] fn a_relabelled_row_leaves_the_totals_alone() { let mut grid = GridCache::folded(&[ GridUpdate::RowFrame(AxisFrame::flat(["r0", "r1"])), GridUpdate::ColFrame(AxisFrame::flat(["A", "B"])), GridUpdate::Totals(vec![CellDisplay::number("10"), CellDisplay::number("20")]), ]); grid.apply(&GridUpdate::RowFrame(AxisFrame::flat(["r0", "renamed"]))); let texts: Vec<&str> = grid.totals.iter().map(|t| t.text.as_str()).collect(); assert_eq!(texts, vec!["10", "20"]); } /// Only the *relabelled* positions are blanked, so an unchanged row /// keeps what the server already sent for it. #[test] fn a_label_change_blanks_only_the_positions_it_reframes() { let mut grid = GridCache::folded(&[ GridUpdate::RowFrame(AxisFrame::flat(["r0", "r1"])), GridUpdate::ColFrame(AxisFrame::flat(["c0"])), GridUpdate::Cells(vec![ ((0, 0), CellDisplay::number("1")), ((1, 0), CellDisplay::number("2")), ]), ]); grid.apply(&GridUpdate::RowFrame(AxisFrame::flat(["r0", "rX"]))); assert_eq!(grid.cell(0, 0).unwrap().text, "1"); assert_eq!(grid.cell(1, 0).unwrap().text, ""); } /// `col_widths` is framed by the columns, and `ColWidths` may arrive a /// frame behind. See `context/test-evidence/protocol.md`. #[test] fn col_widths_always_span_the_column_labels() { let mut grid = GridCache::folded(&[ GridUpdate::ColFrame(AxisFrame::flat(["A"])), GridUpdate::ColWidths(vec![8]), ]); grid.apply(&GridUpdate::ColFrame(AxisFrame::flat(["A", "Bravo"]))); assert_eq!(grid.col_widths.len(), grid.col_frame.len()); assert_eq!(grid.col_widths[0], 8, "the width already sent is kept"); assert_eq!( grid.col_widths[1], 5, "a column with no width yet is drawn at its label's width" ); grid.apply(&GridUpdate::ColFrame(AxisFrame::flat(["A"]))); assert_eq!(grid.col_widths, vec![8]); } /// The same invariant from the other arm, in **both** directions. /// See `context/test-evidence/protocol.md`. #[test] fn a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by() { let mut grid = GridCache::folded(&[GridUpdate::ColFrame(AxisFrame::flat(["A", "Bravo"]))]); grid.apply(&GridUpdate::ColWidths(vec![8])); assert_eq!( grid.col_widths.len(), grid.col_frame.len(), "a short widths update must not leave a renderer zipping off the end" ); assert_eq!(grid.col_widths[0], 8, "the width that was sent is taken"); assert_eq!( grid.col_widths[1], 5, "the column with no width yet keeps its label's width" ); grid.apply(&GridUpdate::ColWidths(vec![3, 4, 99])); assert_eq!( grid.col_widths, vec![3, 4], "a width for a column that has no label yet is dropped, not stored" ); } /// This fold is **not** commutative, and the `assert_ne!` is the /// load-bearing line. See `context/test-evidence/protocol.md`. #[test] fn a_cell_update_that_overtakes_its_labels_is_lost() { let cells = GridUpdate::Cells(vec![((0, 0), CellDisplay::number("42"))]); let rows = GridUpdate::RowFrame(AxisFrame::flat(["r0"])); let cols = GridUpdate::ColFrame(AxisFrame::flat(["c0"])); let in_order = GridCache::folded(&[rows.clone(), cols.clone(), cells.clone()]); let raced = GridCache::folded(&[cells, rows, cols]); assert_eq!(in_order.cell(0, 0).unwrap().text, "42"); assert_eq!( raced.cell(0, 0).unwrap().text, "", "a cell that arrived before its frame has nowhere to land" ); assert_ne!( in_order, raced, "if these agreed, order would not matter and a client could \ reorder the stream freely" ); } /// The property that matters is **compaction**, not replay. /// See `context/test-evidence/protocol.md`. #[test] fn a_compacted_seed_folds_to_the_same_cache_as_the_whole_history() { let history = [ GridUpdate::Extent(GridExtent { rows: 2, cols: 2 }), // Non-default, or a seed that OMITS this arm compacts to the same // cache and the comparison below cannot see the omission. GridUpdate::Records(true), GridUpdate::RowFrame(AxisFrame::flat(["r0", "r1"])), GridUpdate::ColFrame(AxisFrame::flat(["c0", "c1"])), GridUpdate::ColWidths(vec![8, 12]), GridUpdate::Cells(vec![((0, 0), CellDisplay::number("1"))]), GridUpdate::Cells(vec![ ((0, 0), CellDisplay::number("7")), ((1, 1), CellDisplay::error("#div/0")), ]), GridUpdate::Extent(GridExtent { rows: 3, cols: 2 }), GridUpdate::RowFrame(AxisFrame::flat(["r0", "r1", "r2"])), GridUpdate::Cells(vec![((2, 0), CellDisplay::text("late"))]), ]; let live = GridCache::folded(&history); // The state is worth compacting: overwritten, relabelled, populated, // and grown — the extent moved twice and only the last one survives. assert_eq!(live.cell(0, 0).unwrap().text, "7"); assert!(live.cell(1, 1).unwrap().error); assert_eq!(live.cell(2, 0).unwrap().text, "late"); assert_eq!(live.extent, GridExtent { rows: 3, cols: 2 }); assert!(live.records, "and the records flag is not at its default"); let seed = live.seed(); assert!( seed.len() < history.len(), "a seed that is not smaller than the history is not a compaction" ); assert_eq!( GridCache::folded(&seed), live, "a client seeded from the compaction draws something no client \ that watched the whole stream would" ); } /// The same property across the envelope: a `Snapshot` seed compacts /// every widget's history, and folding it reproduces every cache. #[test] fn a_snapshot_seeds_every_widget_from_its_compacted_state() { let history = [ Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["r0"]))), Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["c0"]))), Projection::Formulas(vec!["Profit = Revenue - Cost".into()]), Projection::Grid(GridUpdate::Cells(vec![((0, 0), CellDisplay::number("5"))])), Projection::Formulas(vec!["Tax = Revenue * 0.1".into()]), ]; let (grid, formulas) = fold_projections(Default::default(), &history); let snapshot = Snapshot { view: ViewState::default(), seed: grid .seed() .into_iter() .map(Projection::Grid) .chain([Projection::Formulas(formulas.formulas.clone())]) .collect(), }; let booted = fold_projections(Default::default(), &snapshot.seed); assert_eq!(booted, (grid, formulas)); assert_eq!(booted.0.cell(0, 0).unwrap().text, "5"); assert_eq!(booted.1.formulas, vec!["Tax = Revenue * 0.1".into()]); } /// Two subscribers, each folding only what is addressed to it — the /// dispatch a client does for real. fn fold_projections( caches: (GridCache, FormulaCache), projections: &[Projection], ) -> (GridCache, FormulaCache) { let (mut grid, mut formulas) = caches; for projection in projections { match projection { Projection::Grid(u) => grid.apply(u), Projection::Formulas(u) => formulas.apply(u), _ => {} } } (grid, formulas) } #[test] fn every_projection_round_trips_through_json() { let projections = [ // One populated value per `CellDisplay` shape: carrying them at // their default proves nothing (doctrine populate_before_roundtrip). Projection::Grid(GridUpdate::Cells(vec![ ((0, 1), CellDisplay::number("1,234.50")), ((2, 3), CellDisplay::error("#div/0")), ((4, 5), CellDisplay::text("pending")), ])), Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["r"]))), Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["c"]))), Projection::Grid(GridUpdate::ColWidths(vec![8, 12])), Projection::CategoryTree(CategoryTreeUpdate::Categories(vec![cat( "Region", &["North"], Axis::Row, )])), Projection::CategoryTree(CategoryTreeUpdate::Items { category: "Region".into(), items: vec!["North".into()], }), Projection::CategoryTree(CategoryTreeUpdate::Axes(vec![( "Region".into(), Axis::Filter, )])), Projection::Formulas(vec!["Profit = Revenue - Cost".into()]), Projection::ViewList(vec![ViewSummary { name: "Default".into(), display: "Default".into(), axes: vec![ViewAxis::plain("Region", Axis::None)], }]), Projection::TileBar(vec![TileEntry { category: "Region".into(), display: "Region".into(), axis: Axis::Page, kind: crate::CategoryKind::Regular, }]), ]; for projection in &projections { let json = serde_json::to_string(projection).expect("serializes"); let back: Projection = serde_json::from_str(&json).expect("deserializes"); assert_eq!(&back, projection, "round trip changed {projection:?}"); } } /// The one message whose round trip nothing else covers, populated on /// **both** halves. See `context/test-evidence/protocol.md`. #[test] fn a_populated_snapshot_round_trips_through_json() { let mut view = ViewState { mode: crate::AppMode::item_add("Region".into()), active_view: "Second".into(), transient_prefix: Some((crate::KeyCode::Char('g'), crate::KeyModifiers::NONE)), ..ViewState::default() }; view.cursor_mut().selected = (3, 4); view.buffers.insert("item".into(), "East".into()); view.expanded_cats.insert("Region".into()); let snapshot = Snapshot { view, seed: vec![ Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["r0"]))), Projection::Grid(GridUpdate::ColWidths(vec![8])), Projection::Grid(GridUpdate::Cells(vec![( (0, 0), CellDisplay::number("1,234.50"), )])), Projection::Formulas(vec!["Profit = Revenue - Cost".into()]), Projection::TileBar(vec![TileEntry { category: "Region".into(), display: "Region".into(), axis: Axis::Page, kind: crate::CategoryKind::Regular, }]), ], }; let json = serde_json::to_string(&snapshot).expect("serializes"); let back: Snapshot = serde_json::from_str(&json).expect("deserializes"); assert_eq!(back, snapshot); } }