Files
improvise/crates/improvise-protocol/src/render.rs
T
Edward Langley 463bef149d Merge commit '5932ad749f0df01d4321cc961f57a488ab637dde' into wave/km71-edit-formula
# Conflicts:
#	crates/improvise-protocol/src/lib.rs
2026-09-23 08:51:45 -07:00

1981 lines
75 KiB
Rust

//! 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::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<Item = &'a Self::Update>) -> 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<String>,
}
/// 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. Renaming it moves every measure cell in the
/// row (gotcha 1), which no client composes: `protocol.md` §3 (improvise-exdg).
Coordinate,
}
/// 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<CellId>,
/// 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<String>,
/// 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<CellValue>,
}
impl CellDisplay {
pub fn text(s: impl Into<String>) -> Self {
Self {
text: s.into(),
..Self::default()
}
}
pub fn number(s: impl Into<String>) -> 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<String>) -> 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<String>,
}
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 {
pub name: 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 opened by the last `Group` ends here — `AxisEntry::GroupEnd`
/// carried onto the wire (improvise-gd83). Nothing is drawn for it.
GroupEnd,
/// One label per header level, outermost first. Data indices — the
/// `row`/`col` of every other field on [`GridCache`] — count only these.
Item(Vec<String>),
}
/// 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)]
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<String>,
/// 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<FrameEntry>,
}
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<S: Into<String>>(labels: impl IntoIterator<Item = S>) -> Self {
Self {
cats: 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<Item = &Vec<String>> {
self.entries.iter().filter_map(|e| match e {
FrameEntry::Item(v) => Some(v),
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<String> {
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<String>> {
self.items().nth(idx)
}
/// 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::<Vec<_>>(),
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<Vec<String>> {
let items: Vec<&Vec<String>> = self.items().collect();
let levels = self.levels();
(0..items.len())
.map(|idx| {
(0..levels)
.map(|level| match shows(&items, idx, first_rendered, level) {
true => items[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<Vec<bool>> {
let items: Vec<&Vec<String>> = 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 group each data item sits under, in order — [`AxisFrame::group_of`]
/// for every position, in one pass rather than a scan apiece.
pub fn groups(&self) -> Vec<Option<&FrameGroup>> {
let mut current: Option<&FrameGroup> = None;
let mut out = Vec::new();
for entry in &self.entries {
match entry {
FrameEntry::Group(g) => current = Some(g),
FrameEntry::GroupEnd => current = None,
FrameEntry::Item(_) => out.push(current),
}
}
out
}
/// The item's group **membership** — the nearest preceding header, unless
/// a `GroupEnd` closed it first (improvise-gd83). Not for a draw loop; §3.
pub fn group_of(&self, idx: usize) -> Option<&FrameGroup> {
let visual = self.visual_of(idx)?;
self.entries[..visual]
.iter()
.rev()
.find_map(|e| match e {
FrameEntry::Group(g) => Some(Some(g)),
FrameEntry::GroupEnd => Some(None),
FrameEntry::Item(_) => None,
})
.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<Option<&FrameGroup>> {
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<usize> {
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<u16> {
header_level_widths(self.items(), 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<Item = &'a Vec<String>>,
levels: usize,
) -> Vec<u16> {
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<String>], 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<u16>,
/// Row-major, `row_frame.len()` rows of `col_frame.len()` cells.
pub cells: Vec<Vec<CellDisplay>>,
/// The column totals strip, framed by `col_frame`. **Empty means "no
/// totals"**, which is not a row of blanks — see §3.
pub totals: Vec<CellDisplay>,
/// `(category, selected item)` per fixed category — the page bar. Page
/// *and* Filter (`View::filtering_cats`, gotcha 27); framed by nothing.
pub page_coords: Vec<PageAxis>,
/// 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<GridUpdate> {
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<String>> = self.col_frame.items().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<String>> = old.items().collect();
let new_items: Vec<&Vec<String>> = new.items().collect();
let stale: Vec<usize> = (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<u16>),
/// 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<CellDisplay>),
/// The categories the view has fixed, `(category, selected item)`.
PageCoords(Vec<PageAxis>),
/// 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,
pub items: Vec<String>,
pub axis: Axis,
}
/// 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<CategoryRow>,
}
impl CategoryTreeCache {
pub fn category(&self, name: &str) -> Option<&CategoryRow> {
self.categories.iter().find(|c| c.name == name)
}
}
/// 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<CategoryRow>),
Items {
category: String,
items: Vec<String>,
},
/// 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<FormulaEntry>,
}
/// 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<FormulaEntry>;
fn apply(&mut self, update: &Vec<FormulaEntry>) {
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,
/// `(category, axis)` for the categories this view displays.
pub axes: Vec<(String, 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<ViewSummary>,
}
impl WidgetCache for ViewListCache {
type Update = Vec<ViewSummary>;
fn apply(&mut self, update: &Vec<ViewSummary>) {
self.views = update.clone();
}
}
/// One tile in the tile bar. `regular` is the host's `CategoryKind` answer: a
/// client cannot tell `_Index` from a user category without sniffing names.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileEntry {
pub category: String,
pub axis: Axis,
pub regular: bool,
}
/// Where tile select opens: the first regular category, so `T Enter` cannot
/// put `_Index` alone on Row (improvise-oplp). Virtuals stay reachable.
pub fn opening_tile(tiles: &[TileEntry]) -> usize {
tiles.iter().position(|tile| tile.regular).unwrap_or(0)
}
/// 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<TileEntry>,
}
impl WidgetCache for TileBarCache {
type Update = Vec<TileEntry>;
fn apply(&mut self, update: &Vec<TileEntry>) {
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,
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, 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)>,
}
/// 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<String>,
/// `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<String>,
/// `ReviewProposals`: one row per detected field.
pub proposals: Vec<WizardProposal>,
/// `ConfigureDates`: the accepted time fields and their components.
pub date_fields: Vec<WizardDateField>,
/// `DefineFormulas`: the formulas defined so far.
pub formulas: Vec<String>,
/// `DefineFormulas`: suggestions derived from the accepted measures.
pub sample_formulas: Vec<String>,
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<String>,
/// `Done`: warnings raised while building (improvise-907).
pub built_warnings: Vec<String>,
}
/// 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<FormulaEntry>),
ViewList(Vec<ViewSummary>),
TileBar(Vec<TileEntry>),
/// 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<Projection>,
}
#[cfg(test)]
mod tests {
use super::*;
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(),
items: items.iter().map(|s| (*s).to_string()).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::<Projection>(well_formed).expect("the shape is right");
let retired = r#"{"Grid":"Invalidated"}"#;
let err = serde_json::from_str::<Projection>(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()],
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 {
cat: "Payer".into(),
selected: "Alice".into(),
items: 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()],
entries: vec![
FrameEntry::Item(vec!["A".into(), "x".into()]),
FrameEntry::Item(vec!["A".into(), "y".into()]),
FrameEntry::Group(FrameGroup {
name: "Late".into(),
collapsed: true,
}),
FrameEntry::Item(vec!["B".into(), "z".into()]),
],
}
}
/// 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 {
name: "Q1".into(),
collapsed: false,
};
AxisFrame {
first: 0,
cats: 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()],
entries: vec![
FrameEntry::Group(FrameGroup {
name: "Q1".into(),
collapsed: false,
}),
FrameEntry::Item(vec!["Jan".into()]),
FrameEntry::Item(vec!["Feb".into()]),
FrameEntry::GroupEnd,
FrameEntry::Item(vec!["Sep".into()]),
],
}
}
/// 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()],
entries: vec![
FrameEntry::Group(FrameGroup {
name: "Q1".into(),
collapsed: true,
}),
FrameEntry::GroupEnd,
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::<AxisFrame>(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()],
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(),
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!["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!["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(),
axes: vec![("Region".into(), Axis::Row)],
}]]);
assert_eq!(views.views[0].name, "Default");
let tiles = TileBarCache::folded(&[vec![TileEntry {
category: "Region".into(),
axis: Axis::Page,
regular: true,
}]]);
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()],
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()],
entries: grid.row_frame.entries.clone(),
};
assert_eq!(
renamed.items().collect::<Vec<_>>(),
grid.row_frame.items().collect::<Vec<_>>(),
"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()],
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(),
axes: vec![("Region".into(), Axis::None)],
}]),
Projection::TileBar(vec![TileEntry {
category: "Region".into(),
axis: Axis::Page,
regular: true,
}]),
];
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(),
axis: Axis::Page,
regular: true,
}]),
],
};
let json = serde_json::to_string(&snapshot).expect("serializes");
let back: Snapshot = serde_json::from_str(&json).expect("deserializes");
assert_eq!(back, snapshot);
}
}