692 lines
26 KiB
Rust
692 lines
26 KiB
Rust
//! The wire vocabulary, and the fold that applies it to a client's view.
|
|
//! **Derived, not invented** — `context/code/protocol.md` §2.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use improvise_core::model::cell::CellValue;
|
|
use improvise_core::view::Axis;
|
|
|
|
use crate::basis::Basis;
|
|
use crate::render::{Projection, RecordCell};
|
|
use crate::resolve::OpenedEditor;
|
|
use crate::{AppMode, FormulaId, HELP_PAGE_COUNT, KeyCode, KeyModifiers, Panel, ViewState};
|
|
use improvise_core::model::record::RecordHash;
|
|
|
|
/// One message on the wire, in either direction.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub enum Command {
|
|
// ── client → server: view-slice intent ──────────────────────────
|
|
/// Move the cursor within the active view.
|
|
SetSelected {
|
|
row: usize,
|
|
col: usize,
|
|
},
|
|
SetRowOffset(usize),
|
|
SetColOffset(usize),
|
|
/// The grid this cursor indexes has been renumbered under it. **An
|
|
/// invalidation, never a position** — see §2 (improvise-gvnb).
|
|
ResetCursor,
|
|
ChangeMode(AppMode),
|
|
SetStatus(String),
|
|
SetYanked(Option<CellValue>),
|
|
SetSearchQuery(String),
|
|
SetSearchMode(bool),
|
|
/// Write a named buffer. Clearing one is `value: ""` — there is no
|
|
/// `ClearBuffer`, because there is no such effect (improvise-m0sx, §2).
|
|
SetBuffer {
|
|
name: String,
|
|
value: String,
|
|
},
|
|
SetTileCatIdx(usize),
|
|
/// What a cell editor captured as it opened, or `None` for one opened
|
|
/// with nothing to compare against (improvise-ixxx).
|
|
SetOpenedEditor(Option<OpenedEditor>),
|
|
SetPanelOpen {
|
|
panel: Panel,
|
|
open: bool,
|
|
},
|
|
SetPanelCursor {
|
|
panel: Panel,
|
|
cursor: usize,
|
|
},
|
|
ToggleCatExpand(String),
|
|
HelpPage(HelpPage),
|
|
/// Arm a prefix key, or clear the armed one. The *address* of the
|
|
/// prefix, never the sub-keymap it names (improvise-jc8).
|
|
SetTransientPrefix(Option<(KeyCode, KeyModifiers)>),
|
|
/// The active view changed; the name is the cursor map's key. The one
|
|
/// command with a foot in both halves — see §2 (improvise-kmgs).
|
|
SwitchView(String),
|
|
/// Step this client's own view history. The stack is in [`ViewState`], so
|
|
/// it is the client's — the model is shared and the UI is not (§2).
|
|
ViewBack,
|
|
ViewForward,
|
|
/// This view is gone; drop it from the history. The **document** half of a
|
|
/// client-local stack: only the server can observe a view vanishing (§2).
|
|
ForgetView(String),
|
|
|
|
// ── client → server: document intent ────────────────────────────
|
|
/// One mutation of the document, under the basis it claims (§2).
|
|
Model {
|
|
basis: Basis,
|
|
cmd: ModelCommand,
|
|
},
|
|
|
|
// ── server → client: projections ────────────────────────────────
|
|
/// One widget's update; the payload names the widget (improvise-rkeq).
|
|
Project(Projection),
|
|
}
|
|
|
|
/// A mutation of the shared document: 25 variants, derived not invented (§2). A
|
|
/// view-configuration variant **names its view** (improvise-lltt).
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub enum ModelCommand {
|
|
// ── ModelChange::CellData ───────────────────────────────────────
|
|
/// Write one stored cell: a [`RecordCell`] and **nothing else** (ADR 002).
|
|
SetCell {
|
|
cell: RecordCell,
|
|
value: CellValue,
|
|
},
|
|
ClearCell(RecordCell),
|
|
AddItem {
|
|
category: String,
|
|
item: String,
|
|
},
|
|
AddItemInGroup {
|
|
category: String,
|
|
item: String,
|
|
group: String,
|
|
},
|
|
RemoveItem {
|
|
category: String,
|
|
item: String,
|
|
},
|
|
AddFormula {
|
|
raw: String,
|
|
target_category: String,
|
|
},
|
|
/// Remove the formula `id` names; refused if it is already gone.
|
|
RemoveFormula {
|
|
id: FormulaId,
|
|
},
|
|
/// Edit the formula `id` names in place, if it still reads `was` — an edit
|
|
/// of that formula, not a remove-then-add (improvise-km71).
|
|
ReplaceFormula {
|
|
id: FormulaId,
|
|
was: String,
|
|
raw: String,
|
|
},
|
|
CleanEmptyRecords,
|
|
SortData,
|
|
|
|
// ── ModelChange::LayoutGeometry ─────────────────────────────────
|
|
AddCategory(String),
|
|
RemoveCategory(String),
|
|
SetAxis {
|
|
view: String,
|
|
category: String,
|
|
axis: Axis,
|
|
},
|
|
CycleAxis {
|
|
view: String,
|
|
category: String,
|
|
},
|
|
TransposeAxes {
|
|
view: String,
|
|
},
|
|
SetPageSelection {
|
|
view: String,
|
|
category: String,
|
|
item: String,
|
|
},
|
|
HideItem {
|
|
view: String,
|
|
category: String,
|
|
item: String,
|
|
},
|
|
ShowItem {
|
|
view: String,
|
|
category: String,
|
|
item: String,
|
|
},
|
|
ToggleGroup {
|
|
view: String,
|
|
category: String,
|
|
group: String,
|
|
},
|
|
TogglePruneEmpty {
|
|
view: String,
|
|
},
|
|
SetNumberFormat {
|
|
view: String,
|
|
format: String,
|
|
},
|
|
CreateView(String),
|
|
DeleteView(String),
|
|
|
|
// ── ModelChange::WholeWorkbook ──────────────────────────────────
|
|
/// Paths name documents in the host's `Storage`, never the client's (gotcha 2).
|
|
LoadModel(PathBuf),
|
|
ImportJsonHeadless {
|
|
path: PathBuf,
|
|
model_name: Option<String>,
|
|
array_path: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// Which records a [`ModelCommand`] writes. The **one** home: the server
|
|
/// checks against it and a client claims by it — `context/code/protocol.md` §2.
|
|
pub fn records_named(cmd: &ModelCommand) -> Vec<RecordHash> {
|
|
match cmd {
|
|
ModelCommand::SetCell { cell, .. } | ModelCommand::ClearCell(cell) => vec![cell.record],
|
|
|
|
ModelCommand::AddItem { .. }
|
|
| ModelCommand::AddItemInGroup { .. }
|
|
| ModelCommand::RemoveItem { .. }
|
|
| ModelCommand::AddFormula { .. }
|
|
| ModelCommand::RemoveFormula { .. }
|
|
| ModelCommand::ReplaceFormula { .. }
|
|
| ModelCommand::CleanEmptyRecords
|
|
| ModelCommand::SortData
|
|
| ModelCommand::AddCategory(_)
|
|
| ModelCommand::RemoveCategory(_)
|
|
| ModelCommand::SetAxis { .. }
|
|
| ModelCommand::CycleAxis { .. }
|
|
| ModelCommand::TransposeAxes { .. }
|
|
| ModelCommand::SetPageSelection { .. }
|
|
| ModelCommand::HideItem { .. }
|
|
| ModelCommand::ShowItem { .. }
|
|
| ModelCommand::ToggleGroup { .. }
|
|
| ModelCommand::TogglePruneEmpty { .. }
|
|
| ModelCommand::SetNumberFormat { .. }
|
|
| ModelCommand::CreateView(_)
|
|
| ModelCommand::DeleteView(_)
|
|
| ModelCommand::LoadModel(_)
|
|
| ModelCommand::ImportJsonHeadless { .. } => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Which way a help-page command moves. An enum rather than three variants
|
|
/// so the bounds check has one home.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum HelpPage {
|
|
Next,
|
|
Prev,
|
|
Set(usize),
|
|
}
|
|
|
|
/// Which stack a step pops, and which it pushes onto. An enum rather than a
|
|
/// `bool`, so neither call site can read the other's direction.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Rewind {
|
|
Back,
|
|
Forward,
|
|
}
|
|
|
|
/// The mirror of `effect::SwitchView`: a move records where it came from, and
|
|
/// forgets any forward history — `context/code/protocol.md` §2.
|
|
fn switched(view: &mut ViewState, name: &str) {
|
|
let leaving = view.active_view.clone();
|
|
if leaving != name {
|
|
view.view_back_stack.push(crate::ViewFrame {
|
|
view_name: leaving,
|
|
mode: view.mode.clone(),
|
|
});
|
|
view.view_forward_stack.clear();
|
|
}
|
|
view.active_view = name.to_string();
|
|
}
|
|
|
|
/// The mirror of `effect::ViewBack` / `effect::ViewForward`: pop one stack,
|
|
/// push the view being left onto the other, and restore its mode.
|
|
fn stepped(view: &mut ViewState, rewind: Rewind) {
|
|
let popped = match rewind {
|
|
Rewind::Back => view.view_back_stack.pop(),
|
|
Rewind::Forward => view.view_forward_stack.pop(),
|
|
};
|
|
let Some(frame) = popped else {
|
|
return;
|
|
};
|
|
let leaving = crate::ViewFrame {
|
|
view_name: view.active_view.clone(),
|
|
mode: view.mode.clone(),
|
|
};
|
|
match rewind {
|
|
Rewind::Back => view.view_forward_stack.push(leaving),
|
|
Rewind::Forward => view.view_back_stack.push(leaving),
|
|
}
|
|
view.active_view = frame.view_name;
|
|
view.mode = frame.mode;
|
|
}
|
|
|
|
/// Apply one command to a client's view state. The **same function runs on
|
|
/// both ends**, so the mirror cannot drift (improvise-cqq, improvise-m91).
|
|
pub fn reduce_view(view: &mut ViewState, cmd: &Command) {
|
|
match cmd {
|
|
Command::SetSelected { row, col } => {
|
|
view.cursor_mut().selected = (*row, *col);
|
|
}
|
|
Command::SetRowOffset(n) => view.cursor_mut().row_offset = *n,
|
|
Command::SetColOffset(n) => view.cursor_mut().col_offset = *n,
|
|
Command::ResetCursor => *view.cursor_mut() = crate::ViewCursor::default(),
|
|
Command::ChangeMode(mode) => view.mode = mode.clone(),
|
|
Command::SetStatus(s) => view.status_msg = s.clone(),
|
|
Command::SetYanked(v) => view.yanked = v.clone(),
|
|
Command::SetSearchQuery(q) => view.search_query = q.clone(),
|
|
Command::SetSearchMode(on) => view.search_mode = *on,
|
|
Command::SetBuffer { name, value } => {
|
|
view.buffers.insert(name.clone(), value.clone());
|
|
}
|
|
Command::SetTileCatIdx(i) => view.tile_cat_idx = *i,
|
|
Command::SetOpenedEditor(opened) => view.opened_editor = opened.clone(),
|
|
Command::SetPanelOpen { panel, open } => *view.panel_open_mut(*panel) = *open,
|
|
Command::SetPanelCursor { panel, cursor } => *view.panel_cursor_mut(*panel) = *cursor,
|
|
Command::ToggleCatExpand(cat) => {
|
|
if !view.expanded_cats.remove(cat) {
|
|
view.expanded_cats.insert(cat.clone());
|
|
}
|
|
}
|
|
// Clamped, not wrapped: these mirror `HelpPageNext`/`HelpPagePrev`,
|
|
// which saturate. The two used to disagree — `context/code/protocol.md`.
|
|
Command::HelpPage(step) => {
|
|
view.help_page = match step {
|
|
HelpPage::Next => (view.help_page + 1).min(HELP_PAGE_COUNT - 1),
|
|
HelpPage::Prev => view.help_page.saturating_sub(1),
|
|
HelpPage::Set(n) => (*n).min(HELP_PAGE_COUNT - 1),
|
|
};
|
|
}
|
|
Command::SetTransientPrefix(p) => view.transient_prefix = *p,
|
|
Command::SwitchView(name) => switched(view, name),
|
|
Command::ViewBack => stepped(view, Rewind::Back),
|
|
Command::ViewForward => stepped(view, Rewind::Forward),
|
|
Command::ForgetView(name) => view.forget_view(name),
|
|
|
|
// Neither is the view slice's business: a projection goes to one
|
|
// widget's cache, a model command comes back as projections.
|
|
Command::Model { .. } | Command::Project(_) => {}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::render::{CategoryTreeUpdate, CellDisplay, GridUpdate, TileEntry, ViewSummary};
|
|
use crate::{AxisFrame, Panel, ViewCursor};
|
|
use improvise_core::model::cell::CellKey;
|
|
use improvise_core::model::record::RecordHash;
|
|
use improvise_core::view::Axis;
|
|
|
|
/// A model command under a basis, composed the only way there is: from
|
|
/// what the server published. See `context/test-evidence/protocol.md`.
|
|
fn modelled(cmd: ModelCommand) -> Command {
|
|
let mut cache = crate::BasisCache::default();
|
|
crate::WidgetCache::apply(
|
|
&mut cache,
|
|
&crate::basis::published(0, 0, Default::default()),
|
|
);
|
|
Command::Model {
|
|
basis: cache.claim([]).expect("seeded"),
|
|
cmd,
|
|
}
|
|
}
|
|
|
|
fn fold(cmds: &[Command]) -> ViewState {
|
|
let mut view = ViewState::default();
|
|
for cmd in cmds {
|
|
reduce_view(&mut view, cmd);
|
|
}
|
|
view
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_commands_land_in_the_active_views_entry() {
|
|
let view = fold(&[
|
|
Command::SwitchView("Second".into()),
|
|
Command::SetSelected { row: 3, col: 4 },
|
|
Command::SetRowOffset(2),
|
|
Command::SetColOffset(1),
|
|
]);
|
|
assert_eq!(
|
|
view.cursors.get("Second").copied(),
|
|
Some(ViewCursor {
|
|
selected: (3, 4),
|
|
row_offset: 2,
|
|
col_offset: 1,
|
|
})
|
|
);
|
|
assert!(
|
|
!view.cursors.contains_key("Default"),
|
|
"the view we switched away from never gained an entry"
|
|
);
|
|
}
|
|
|
|
/// The point of keying the cursor map: switching back restores where
|
|
/// you were, and the client can do that without a `Workbook`.
|
|
#[test]
|
|
fn switching_views_restores_each_cursor() {
|
|
let view = fold(&[
|
|
Command::SetSelected { row: 1, col: 1 },
|
|
Command::SwitchView("Second".into()),
|
|
Command::SetSelected { row: 9, col: 9 },
|
|
Command::SwitchView("Default".into()),
|
|
]);
|
|
assert_eq!(view.cursor().selected, (1, 1));
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_cat_expand_is_involutive() {
|
|
let view = fold(&[
|
|
Command::ToggleCatExpand("Region".into()),
|
|
Command::ToggleCatExpand("Region".into()),
|
|
]);
|
|
assert!(view.expanded_cats.is_empty());
|
|
}
|
|
|
|
/// Every arm stops at a bound rather than wrapping, because the effects
|
|
/// these were derived from do. See `context/test-evidence/protocol.md`.
|
|
#[test]
|
|
fn help_pages_stop_at_both_ends_and_set_clamps() {
|
|
let last = HELP_PAGE_COUNT - 1;
|
|
let back = fold(&[Command::HelpPage(HelpPage::Prev)]);
|
|
assert_eq!(back.help_page, 0, "page 0 stays put going backwards");
|
|
|
|
let forward: Vec<Command> = (0..HELP_PAGE_COUNT + 1)
|
|
.map(|_| Command::HelpPage(HelpPage::Next))
|
|
.collect();
|
|
let walked = fold(&forward);
|
|
assert_eq!(walked.help_page, last, "walking off the end stops there");
|
|
|
|
let clamped = fold(&[Command::HelpPage(HelpPage::Set(99))]);
|
|
assert_eq!(clamped.help_page, last, "out of range clamps, never panics");
|
|
}
|
|
|
|
/// Clearing leaves the key **present and empty**, which is what both
|
|
/// implementations do. `context/test-evidence/protocol.md`.
|
|
#[test]
|
|
fn buffers_are_set_and_cleared_by_name() {
|
|
let view = fold(&[
|
|
Command::SetBuffer {
|
|
name: "edit".into(),
|
|
value: "hi".into(),
|
|
},
|
|
Command::SetBuffer {
|
|
name: "command".into(),
|
|
value: "w".into(),
|
|
},
|
|
Command::SetBuffer {
|
|
name: "edit".into(),
|
|
value: String::new(),
|
|
},
|
|
]);
|
|
assert_eq!(view.buffers.get("command").map(String::as_str), Some("w"));
|
|
assert_eq!(view.buffers.get("edit").map(String::as_str), Some(""));
|
|
}
|
|
|
|
/// Both ends fold the same commands (improvise-m91); see
|
|
/// `context/test-evidence/protocol.md`.
|
|
#[test]
|
|
fn folding_the_same_stream_twice_gives_the_same_state() {
|
|
let stream = [
|
|
Command::SwitchView("Second".into()),
|
|
Command::SetSelected { row: 2, col: 2 },
|
|
Command::ToggleCatExpand("Region".into()),
|
|
Command::SetSearchQuery("north".into()),
|
|
Command::HelpPage(HelpPage::Next),
|
|
Command::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([
|
|
"r",
|
|
])))),
|
|
];
|
|
let a = fold(&stream);
|
|
let b = fold(&stream);
|
|
assert_eq!(format!("{a:?}"), format!("{b:?}"));
|
|
}
|
|
|
|
/// A model command is the document's business, not this client's. See
|
|
/// `context/test-evidence/protocol.md`.
|
|
#[test]
|
|
fn a_model_command_leaves_the_view_slice_untouched() {
|
|
let mut view = ViewState::default();
|
|
view.cursor_mut().selected = (3, 4);
|
|
let before = format!("{view:?}");
|
|
reduce_view(
|
|
&mut view,
|
|
&modelled(ModelCommand::AddCategory("Product".into())),
|
|
);
|
|
reduce_view(
|
|
&mut view,
|
|
&modelled(ModelCommand::TransposeAxes {
|
|
view: "Default".into(),
|
|
}),
|
|
);
|
|
assert_eq!(before, format!("{view:?}"));
|
|
}
|
|
|
|
#[test]
|
|
fn every_command_round_trips_through_json() {
|
|
let stream = [
|
|
Command::SetSelected { row: 1, col: 2 },
|
|
Command::SetRowOffset(3),
|
|
Command::SetColOffset(4),
|
|
Command::ChangeMode(AppMode::item_add("Region".into())),
|
|
Command::SetStatus("saved".into()),
|
|
Command::SetYanked(Some(CellValue::Number(1.5))),
|
|
Command::SetSearchQuery("q".into()),
|
|
Command::SetSearchMode(true),
|
|
Command::SetBuffer {
|
|
name: "edit".into(),
|
|
value: "v".into(),
|
|
},
|
|
Command::SetTileCatIdx(2),
|
|
Command::SetPanelOpen {
|
|
panel: Panel::View,
|
|
open: true,
|
|
},
|
|
Command::SetPanelCursor {
|
|
panel: Panel::Formula,
|
|
cursor: 1,
|
|
},
|
|
Command::ToggleCatExpand("Region".into()),
|
|
Command::HelpPage(HelpPage::Set(2)),
|
|
Command::SetTransientPrefix(Some((KeyCode::Char('g'), KeyModifiers::CONTROL))),
|
|
Command::SwitchView("Second".into()),
|
|
// Every arm of the envelope, each carrying a payload
|
|
// (doctrine populate_before_roundtrip).
|
|
Command::Project(Projection::Grid(GridUpdate::Cells(vec![
|
|
((0, 1), CellDisplay::number("1,234.50")),
|
|
((2, 3), CellDisplay::error("#div/0")),
|
|
]))),
|
|
Command::Project(Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([
|
|
"c0",
|
|
])))),
|
|
Command::Project(Projection::Grid(GridUpdate::ColWidths(vec![8, 12]))),
|
|
Command::Project(Projection::CategoryTree(CategoryTreeUpdate::Items {
|
|
category: "Region".into(),
|
|
items: vec!["North".into(), "South".into()],
|
|
})),
|
|
Command::Project(Projection::Formulas(vec!["Profit = Revenue - Cost".into()])),
|
|
Command::Project(Projection::ViewList(vec![ViewSummary {
|
|
name: "Default".into(),
|
|
axes: vec![("Region".into(), Axis::Row)],
|
|
}])),
|
|
Command::Project(Projection::TileBar(vec![TileEntry {
|
|
category: "Region".into(),
|
|
kind: crate::CategoryKind::Regular,
|
|
axis: Axis::Page,
|
|
}])),
|
|
// One populated value per payload family: `RecordCell`, `Axis`,
|
|
// `PathBuf`. The exhaustive 26-variant walk is in the host crate.
|
|
modelled(ModelCommand::SetCell {
|
|
cell: RecordCell {
|
|
record: RecordHash::of(&CellKey::new(vec![("Region".into(), "East".into())])),
|
|
measure: Some("Revenue".into()),
|
|
},
|
|
value: CellValue::Text("pending".into()),
|
|
}),
|
|
modelled(ModelCommand::SetAxis {
|
|
view: "Default".into(),
|
|
category: "Region".into(),
|
|
axis: Axis::Filter,
|
|
}),
|
|
modelled(ModelCommand::AddItemInGroup {
|
|
category: "Region".into(),
|
|
item: "Coastal_East".into(),
|
|
group: "Coastal".into(),
|
|
}),
|
|
modelled(ModelCommand::AddCategory("Product".into())),
|
|
modelled(ModelCommand::ImportJsonHeadless {
|
|
path: PathBuf::from("rows.json"),
|
|
model_name: Some("Rows".into()),
|
|
array_path: None,
|
|
}),
|
|
modelled(ModelCommand::TransposeAxes {
|
|
view: "Default".into(),
|
|
}),
|
|
];
|
|
for cmd in &stream {
|
|
let json = serde_json::to_string(cmd).expect("serializes");
|
|
let back: Command = serde_json::from_str(&json).expect("deserializes");
|
|
assert_eq!(&back, cmd, "round trip changed {cmd:?}");
|
|
}
|
|
}
|
|
|
|
/// improvise-56ek: `Panel` becomes a field in one place, and the fold reaches it.
|
|
/// Replaces a `Panel::Category`-only test that could not tell panels apart.
|
|
#[test]
|
|
fn a_panel_command_writes_that_panel_and_no_other() {
|
|
use crate::Panel;
|
|
for panel in Panel::ALL {
|
|
// Zeroed first, so opening is a move for every panel: the category
|
|
// panel is OPEN at launch, and asserting it is open passes a no-op.
|
|
let mut view = ViewState::default();
|
|
for p in Panel::ALL {
|
|
*view.panel_open_mut(p) = false;
|
|
*view.panel_cursor_mut(p) = 0;
|
|
}
|
|
|
|
reduce_view(&mut view, &Command::SetPanelOpen { panel, open: true });
|
|
reduce_view(&mut view, &Command::SetPanelCursor { panel, cursor: 7 });
|
|
|
|
for other in Panel::ALL {
|
|
assert_eq!(
|
|
view.panel_open(other),
|
|
other == panel,
|
|
"SetPanelOpen({panel:?}) wrote {other:?}'s open flag"
|
|
);
|
|
assert_eq!(
|
|
view.panel_cursor(other),
|
|
if other == panel { 7 } else { 0 },
|
|
"SetPanelCursor({panel:?}) wrote {other:?}'s cursor"
|
|
);
|
|
}
|
|
// By name, once per panel: asserting only through the accessors asserts
|
|
// through the mapping under test, and a bijection passes (gotcha 3).
|
|
let (open, cursor) = match panel {
|
|
Panel::Formula => (view.formula_panel_open, view.formula_cursor),
|
|
Panel::Category => (view.category_panel_open, view.cat_panel_cursor),
|
|
Panel::View => (view.view_panel_open, view.view_panel_cursor),
|
|
};
|
|
assert!(open, "{panel:?} is not the field {panel:?} names");
|
|
assert_eq!(cursor, 7, "{panel:?}'s cursor is not the field it names");
|
|
}
|
|
}
|
|
|
|
/// improvise-hkbc: every field of every `ModelCommand` names its target by
|
|
/// name, by path or by surrogate. A row or column index fails to COMPILE.
|
|
fn position_free(cmd: &ModelCommand) -> usize {
|
|
fn name(_: &str) -> usize {
|
|
1
|
|
}
|
|
fn surrogate(_: &RecordHash) -> usize {
|
|
1
|
|
}
|
|
fn formula(_: &FormulaId) -> usize {
|
|
1
|
|
}
|
|
fn record(RecordCell { record, measure }: &RecordCell) -> usize {
|
|
surrogate(record) + optional_name(measure)
|
|
}
|
|
fn path(_: &std::path::Path) -> usize {
|
|
1
|
|
}
|
|
fn value(_: &CellValue) -> usize {
|
|
1
|
|
}
|
|
fn axis(_: &Axis) -> usize {
|
|
1
|
|
}
|
|
fn optional_name(_: &Option<String>) -> usize {
|
|
1
|
|
}
|
|
// No `..` anywhere: a field added to a variant must be classified here.
|
|
match cmd {
|
|
ModelCommand::SetCell { cell, value: v } => record(cell) + value(v),
|
|
ModelCommand::ClearCell(cell) => record(cell),
|
|
ModelCommand::AddItem { category, item }
|
|
| ModelCommand::RemoveItem { category, item } => name(category) + name(item),
|
|
ModelCommand::AddItemInGroup {
|
|
category,
|
|
item,
|
|
group,
|
|
} => name(category) + name(item) + name(group),
|
|
ModelCommand::AddFormula {
|
|
raw,
|
|
target_category,
|
|
} => name(raw) + name(target_category),
|
|
ModelCommand::RemoveFormula { id } => formula(id),
|
|
ModelCommand::ReplaceFormula { id, was, raw } => formula(id) + name(was) + name(raw),
|
|
ModelCommand::CleanEmptyRecords | ModelCommand::SortData => 0,
|
|
ModelCommand::AddCategory(category)
|
|
| ModelCommand::RemoveCategory(category)
|
|
| ModelCommand::CreateView(category)
|
|
| ModelCommand::DeleteView(category) => name(category),
|
|
ModelCommand::SetAxis {
|
|
view,
|
|
category,
|
|
axis: a,
|
|
} => name(view) + name(category) + axis(a),
|
|
ModelCommand::CycleAxis { view, category } => name(view) + name(category),
|
|
ModelCommand::TransposeAxes { view } | ModelCommand::TogglePruneEmpty { view } => {
|
|
name(view)
|
|
}
|
|
ModelCommand::SetPageSelection {
|
|
view,
|
|
category,
|
|
item,
|
|
}
|
|
| ModelCommand::HideItem {
|
|
view,
|
|
category,
|
|
item,
|
|
}
|
|
| ModelCommand::ShowItem {
|
|
view,
|
|
category,
|
|
item,
|
|
} => name(view) + name(category) + name(item),
|
|
ModelCommand::ToggleGroup {
|
|
view,
|
|
category,
|
|
group,
|
|
} => name(view) + name(category) + name(group),
|
|
ModelCommand::SetNumberFormat { view, format } => name(view) + name(format),
|
|
ModelCommand::LoadModel(p) => path(p),
|
|
ModelCommand::ImportJsonHeadless {
|
|
path: p,
|
|
model_name,
|
|
array_path,
|
|
} => path(p) + optional_name(model_name) + optional_name(array_path),
|
|
}
|
|
}
|
|
|
|
/// The guard is the signature above; this only keeps it compiled and reached.
|
|
#[test]
|
|
fn every_model_command_field_is_position_free() {
|
|
assert_eq!(
|
|
position_free(&ModelCommand::TransposeAxes { view: "V".into() }),
|
|
1
|
|
);
|
|
}
|
|
}
|