301a74821a
`GridCache::row_labels`/`col_labels` were `Vec<String>` built by `GridLayout::row_label`, which slash-joins a multi-level tuple — the exact rendering `two_row_categories_produce_cross_product_labels` asserts must not appear. So the protocol carried a form of the labels the TUI itself considered wrong, and `improvise-wasm-client/src/render.rs` drew precisely that form: the browser showed `Food/Alice` where the TUI showed two header columns, with no groups and no collapse indicators (gotcha 44). Both fields become one `AxisFrame` per axis, carrying the five facts gotcha 44 enumerates: the categories (hence the header levels), the label *tuples* and the inline group headers, the group a position sits in, and each group's collapse state. `record_col` stays off — session-local, keys pending drill edits, correct to keep off. It is one type and not a `row_levels` beside `row_labels`, because two answers to "what are the labels" is the disease (improvise-edp). The rules that used to be written twice are methods on it and are now called from both front ends: `shows_level` (repeat suppression, improvise-jjx's fix), `group_of`, `body_from`, and `header_widths` — the row-header width rule, which existed twice already, inline in `render_grid` and again in `viewport::compute_row_header_width` (improvise-1125). `GridWidget` loses its `&View`: nothing in it reads the view any more. Evidence. `the_whole_frame_is_unchanged` — the exact-buffer characterization over a grid with row groups, column groups, a page axis, multi-level headers and a total row — passes byte for byte, so the TUI is unchanged. The new `tests/grid_frame_agreement.rs` drives the widget into a ratatui buffer and the browser client through a `Snapshot` of the *same* cache, and compares what each draws; re-introducing the slash-join on either side was injected and watched to fail before the test was kept. `tests/web_demo_model.rs::the_web_demo_has_one_category_per_visible_axis` is deleted: it guarded the gap this closes, and a consolidation that keeps its compatibility test is unfinished. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
608 lines
24 KiB
Rust
608 lines
24 KiB
Rust
//! What the page paints: the client's state with every rendering decision
|
|
//! already made (improvise-cr3).
|
|
//!
|
|
//! The DOM half of this client is JavaScript, and JavaScript in this repo is
|
|
//! untested. So the split is drawn to make that acceptable rather than to
|
|
//! make it comfortable: **every question with a wrong answer is answered
|
|
//! here, in Rust, under test**, and what crosses to the page is a list of
|
|
//! rows of cells that each already know whether they are editable, numeric,
|
|
//! erroneous and selected. The page's job is `document.createElement`.
|
|
//!
|
|
//! Three decisions in particular are here because getting them wrong is
|
|
//! silent:
|
|
//!
|
|
//! - **Editable is a fact about the id, not about the text.** Only
|
|
//! `CellId::Record` may be written (ADR 002). `CellId::Aggregate` is a
|
|
//! *drill* target and `Drill` is not on the wire yet, so it renders as
|
|
//! non-editable rather than as a control that does nothing. And `None` is
|
|
//! **not** "aggregate" — it is "no projected cell here": a padded slot, a
|
|
//! blanked one, or a records-mode column that renames a coordinate across
|
|
//! the whole row (gotcha 1) rather than writing one.
|
|
//! - **The selection is absolute and the grid is not.** `GridCache` is
|
|
//! indexed by position *within the viewport*, while `ViewState`'s
|
|
//! `selected` and `row_offset`/`col_offset` are positions within the whole
|
|
//! grid. Subtracting the offset is the entire conversion, and the trap is
|
|
//! that forgetting it looks right at the top of a document and wrong after
|
|
//! the first scroll. A selection outside the window highlights nothing.
|
|
//! - **The category panel is [`flatten_cat_tree`], not a second walk of the
|
|
//! cache.** That function exists in `improvise-protocol` precisely so this
|
|
//! end and `move-panel-cursor` share one flattening (improvise-edp); the
|
|
//! page never sees `CategoryTreeCache` at all.
|
|
//! - **The grid's frame is `AxisFrame`, and every header decision is made
|
|
//! here** (improvise-anr9). The multi-level header levels, the repeat
|
|
//! suppression, the group runs and their collapse state come off the same
|
|
//! `AxisFrame` methods the TUI widget calls, so the page receives labels
|
|
//! already blanked where they repeat and group runs already spanned. Until
|
|
//! this landed the protocol carried `GridLayout::row_label`'s slash-joined
|
|
//! tuple and the page drew `Food/Alice` where the TUI drew two header
|
|
//! columns — a derivation the TUI's own test says is wrong, shipped to the
|
|
//! other front end.
|
|
//!
|
|
//! Nothing here is on the wire. It is a *projection of a projection* — the
|
|
//! client's own caches shaped for one renderer — so it may change freely
|
|
//! without a protocol conversation.
|
|
|
|
use improvise_protocol::viewmodel::{CatTreeEntry, flatten_cat_tree};
|
|
use improvise_protocol::{AxisFrame, CellDisplay, CellId, FrameEntry};
|
|
use serde::Serialize;
|
|
|
|
use crate::{Client, ClientState};
|
|
|
|
/// One frame's worth of everything the page draws.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
|
pub struct RenderModel {
|
|
/// The revision this model was built at. A page that stores it can skip
|
|
/// a repaint without re-serializing.
|
|
pub revision: u64,
|
|
/// The mode's own label, off `AppMode` — the client does not spell it.
|
|
pub mode: String,
|
|
pub status: String,
|
|
/// The document's active view, as this client knows it (gotcha 29).
|
|
pub view_name: String,
|
|
/// The column header, one entry per level, each with one label per
|
|
/// column and `""` where the label repeats the column to its left.
|
|
/// Level-major because that is how it is drawn: one line per level.
|
|
pub col_levels: Vec<Vec<String>>,
|
|
/// The runs of columns that share a group, in column order. Empty when
|
|
/// the column axis has no groups at all — which is a fact, not a gap,
|
|
/// and the page draws no group line for it.
|
|
pub col_groups: Vec<RenderColGroup>,
|
|
/// How many row-header levels every [`RenderRow::Data`] carries.
|
|
pub row_levels: usize,
|
|
pub rows: Vec<RenderRow>,
|
|
/// The category panel, flattened by the one flattening.
|
|
pub categories: Vec<RenderCatRow>,
|
|
/// Whether the panel is open, so the page does not decide.
|
|
pub category_panel_open: bool,
|
|
/// The keys this mode offers, off `AppMode::hint_text()` — the same
|
|
/// string the TUI's footer draws.
|
|
///
|
|
/// The page shows it rather than spelling any keys of its own, which is
|
|
/// what keeps a second front end from growing its own idea of the
|
|
/// bindings (design-principles §5). It also makes the modes this client
|
|
/// can *enter* but not yet *draw* — `Help` above all — state what they
|
|
/// are instead of looking like a broken grid.
|
|
pub hint: String,
|
|
/// What the user has typed so far, while a keyboard edit is open on the
|
|
/// selected cell — `None` when none is.
|
|
///
|
|
/// The page draws this in place of the selected cell's own text. The
|
|
/// *decision* — is an edit open, and what is in it — is made here,
|
|
/// because it is `AppMode::is_editing()` and the `edit` buffer, and a
|
|
/// second front end must not have to know either
|
|
/// (design-principles §5). Without it a browser that resolved `i`
|
|
/// locally would change its mode indicator and then swallow every
|
|
/// keystroke invisibly.
|
|
pub editing: Option<String>,
|
|
}
|
|
|
|
/// A grid row: either a group header spanning the row, or data.
|
|
///
|
|
/// An enum rather than a `label` plus an `is_group` flag, so a group header
|
|
/// cannot carry cells and a data row cannot carry a collapse state
|
|
/// (design-principles §2). It is serialized with a `kind` tag because the
|
|
/// consumer is JavaScript.
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
|
pub enum RenderRow {
|
|
Group {
|
|
label: String,
|
|
collapsed: bool,
|
|
},
|
|
Data {
|
|
/// One label per row-header level, already blanked where it repeats
|
|
/// the row above — the page never decides suppression.
|
|
labels: Vec<String>,
|
|
cells: Vec<RenderCell>,
|
|
},
|
|
}
|
|
|
|
impl RenderRow {
|
|
/// The cells of a data row. A group header spans the grid and has none,
|
|
/// which is the empty slice rather than a panic — the caller asking for
|
|
/// cells of a header row is asking a reasonable question about a row it
|
|
/// did not choose.
|
|
pub fn cells(&self) -> &[RenderCell] {
|
|
match self {
|
|
RenderRow::Data { cells, .. } => cells,
|
|
RenderRow::Group { .. } => &[],
|
|
}
|
|
}
|
|
|
|
/// The row header's labels, one per level. A group header has one label
|
|
/// of its own.
|
|
pub fn labels(&self) -> &[String] {
|
|
match self {
|
|
RenderRow::Data { labels, .. } => labels,
|
|
RenderRow::Group { .. } => &[],
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A run of columns under one group header.
|
|
///
|
|
/// `label` is empty for a run of columns in no group, which keeps the runs a
|
|
/// partition of the columns: the spans sum to the column count, so a page
|
|
/// laying them out as `colspan` cannot leave a hole.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
|
pub struct RenderColGroup {
|
|
pub label: String,
|
|
pub collapsed: bool,
|
|
pub span: usize,
|
|
}
|
|
|
|
/// One cell, with every question already answered.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
|
pub struct RenderCell {
|
|
pub text: String,
|
|
/// Right-align. The server decided this; it is echoed, not re-derived.
|
|
pub numeric: bool,
|
|
pub error: bool,
|
|
/// May this client compose a write for it? True for `CellId::Record`
|
|
/// alone.
|
|
pub editable: bool,
|
|
pub selected: bool,
|
|
/// What an editor opened on this cell should start with — the stored
|
|
/// value, not the formatted one. See `CellDisplay::edit_text`: seeding
|
|
/// from `text` and committing turns `1200` into the string `"1,200"`.
|
|
pub edit_text: String,
|
|
}
|
|
|
|
impl RenderCell {
|
|
fn of(cell: &CellDisplay, selected: bool) -> Self {
|
|
Self {
|
|
text: cell.text.clone(),
|
|
numeric: cell.numeric,
|
|
error: cell.error,
|
|
editable: matches!(cell.id, Some(CellId::Record(_))),
|
|
selected,
|
|
edit_text: cell.edit_text.clone().unwrap_or_default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One row of the category panel.
|
|
///
|
|
/// `item_count` and `expanded` are meaningful on a category row and are the
|
|
/// header's own facts; an item row carries neither, which is why the two are
|
|
/// `Option` rather than sentinels.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
|
pub struct RenderCatRow {
|
|
pub label: String,
|
|
/// True for a category header, false for an item under one.
|
|
pub is_category: bool,
|
|
/// The axis initial for a category header (`""` for `Axis::None`, which
|
|
/// is a real answer and not a truncation — gotcha 26).
|
|
pub axis: String,
|
|
pub item_count: Option<usize>,
|
|
pub expanded: Option<bool>,
|
|
/// Under `ViewState::cat_panel_cursor`, which indexes this very list.
|
|
pub selected: bool,
|
|
}
|
|
|
|
impl ClientState {
|
|
/// Build the page's frame from the caches and the view slice.
|
|
pub fn render_model(&self) -> RenderModel {
|
|
let cursor = self.view.cursor();
|
|
let (sel_row, sel_col) = cursor.selected;
|
|
let entries = flatten_cat_tree(&self.categories, &self.view.expanded_cats);
|
|
let rows = &self.grid.row_frame;
|
|
let cols = &self.grid.col_frame;
|
|
|
|
RenderModel {
|
|
revision: self.revision,
|
|
mode: self.view.mode.label().to_string(),
|
|
status: self.view.status_msg.clone(),
|
|
view_name: self.view.active_view.clone(),
|
|
col_levels: col_levels(cols),
|
|
col_groups: col_groups(cols),
|
|
row_levels: rows.levels(),
|
|
rows: rows
|
|
.body_from(0)
|
|
.iter()
|
|
.scan(0usize, |data_idx, entry| {
|
|
let row = match entry {
|
|
FrameEntry::Group(group) => RenderRow::Group {
|
|
label: group.name.clone(),
|
|
collapsed: group.collapsed,
|
|
},
|
|
FrameEntry::Item(_) => {
|
|
let r = *data_idx;
|
|
*data_idx += 1;
|
|
RenderRow::Data {
|
|
labels: (0..rows.levels())
|
|
.map(|level| match rows.shows_level(r, 0, level) {
|
|
true => rows.label(r, level).to_string(),
|
|
false => String::new(),
|
|
})
|
|
.collect(),
|
|
cells: (0..cols.len())
|
|
.map(|c| {
|
|
// The one conversion: viewport
|
|
// position + offset is the absolute
|
|
// position the cursor names.
|
|
let selected = r + cursor.row_offset == sel_row
|
|
&& c + cursor.col_offset == sel_col;
|
|
match self.grid.cell(r, c) {
|
|
Some(cell) => RenderCell::of(cell, selected),
|
|
None => RenderCell {
|
|
selected,
|
|
..RenderCell::default()
|
|
},
|
|
}
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
};
|
|
Some(row)
|
|
})
|
|
.collect(),
|
|
categories: entries
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, entry)| RenderCatRow::of(entry, i == self.view.cat_panel_cursor))
|
|
.collect(),
|
|
category_panel_open: self.view.category_panel_open,
|
|
hint: self.view.mode.hint_text().to_string(),
|
|
editing: self
|
|
.view
|
|
.mode
|
|
.is_editing()
|
|
.then(|| self.view.buffers.get("edit").cloned().unwrap_or_default()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RenderCatRow {
|
|
fn of(entry: &CatTreeEntry, selected: bool) -> Self {
|
|
match entry {
|
|
CatTreeEntry::Category {
|
|
name,
|
|
item_count,
|
|
expanded,
|
|
axis,
|
|
} => Self {
|
|
label: name.clone(),
|
|
is_category: true,
|
|
axis: axis.initial().to_string(),
|
|
item_count: Some(*item_count),
|
|
expanded: Some(*expanded),
|
|
selected,
|
|
},
|
|
CatTreeEntry::Item { item_name, .. } => Self {
|
|
label: item_name.clone(),
|
|
is_category: false,
|
|
axis: String::new(),
|
|
item_count: None,
|
|
expanded: None,
|
|
selected,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The column header lines: one per level, one label per column, blanked
|
|
/// where the label repeats. Exactly `AxisFrame::shows_level`, which is what
|
|
/// the TUI's header loop calls — the suppression rule is not restated here.
|
|
fn col_levels(cols: &AxisFrame) -> Vec<Vec<String>> {
|
|
(0..cols.levels())
|
|
.map(|level| {
|
|
(0..cols.len())
|
|
.map(|c| match cols.shows_level(c, 0, level) {
|
|
true => cols.label(c, level).to_string(),
|
|
false => String::new(),
|
|
})
|
|
.collect()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The column groups as runs, by walking `AxisFrame::group_of` across the
|
|
/// columns and coalescing equal answers.
|
|
///
|
|
/// It returns nothing at all when no column is in a group, because "this
|
|
/// grid has no column groups" and "every column's group is blank" are
|
|
/// different statements and the page draws a line for only one of them —
|
|
/// the same asymmetry `GridCache::totals` has (gotcha 33).
|
|
fn col_groups(cols: &AxisFrame) -> Vec<RenderColGroup> {
|
|
let mut runs: Vec<RenderColGroup> = Vec::new();
|
|
let mut any = false;
|
|
for c in 0..cols.len() {
|
|
let group = cols.group_of(c);
|
|
any |= group.is_some();
|
|
let label = group.map(|g| g.name.clone()).unwrap_or_default();
|
|
let collapsed = group.is_some_and(|g| g.collapsed);
|
|
match runs.last_mut() {
|
|
Some(run) if run.label == label && run.collapsed == collapsed => run.span += 1,
|
|
_ => runs.push(RenderColGroup {
|
|
label,
|
|
collapsed,
|
|
span: 1,
|
|
}),
|
|
}
|
|
}
|
|
match any {
|
|
true => runs,
|
|
false => Vec::new(),
|
|
}
|
|
}
|
|
|
|
impl Client {
|
|
pub fn model(&self) -> RenderModel {
|
|
self.state().render_model()
|
|
}
|
|
|
|
pub fn model_json(&self) -> Result<String, serde_json::Error> {
|
|
serde_json::to_string(&self.model())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::init;
|
|
use improvise_core::view::Axis;
|
|
use improvise_protocol::{
|
|
CategoryRow, CategoryTreeUpdate, Command, GridUpdate, Projection, RecordCell, Snapshot,
|
|
ViewState,
|
|
};
|
|
|
|
fn surrogate() -> improvise_protocol::RecordHash {
|
|
serde_json::from_str("\"fe4c8e5e9cbd578e0d388f4b2b5a7879\"").expect("hex")
|
|
}
|
|
|
|
fn aggregate() -> improvise_protocol::AggregateHash {
|
|
serde_json::from_str("\"5b05bbcd2364099f68a49dbf9099e075\"").expect("hex")
|
|
}
|
|
|
|
fn record_id() -> CellId {
|
|
CellId::Record(RecordCell {
|
|
record: surrogate(),
|
|
measure: Some("Revenue".into()),
|
|
})
|
|
}
|
|
|
|
/// A 2x2 grid whose four cells cover the three id shapes plus a padded
|
|
/// slot, so one fixture answers every editability question.
|
|
fn client_with_grid(view: ViewState) -> Client {
|
|
let mut client = init();
|
|
client.bootstrap(Snapshot {
|
|
view,
|
|
seed: vec![
|
|
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North", "South"]))),
|
|
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue", "Total"]))),
|
|
Projection::Grid(GridUpdate::Cells(vec![
|
|
((0, 0), CellDisplay::number("1200").with_id(record_id())),
|
|
(
|
|
(0, 1),
|
|
CellDisplay::number("1900").with_id(CellId::Aggregate(aggregate())),
|
|
),
|
|
((1, 0), CellDisplay::text("pending")),
|
|
])),
|
|
],
|
|
});
|
|
client
|
|
}
|
|
|
|
/// ADR 002's permission, as the page sees it. The three cases are three
|
|
/// different facts, and only the first is a cell a user may type into.
|
|
#[test]
|
|
fn only_a_record_cell_renders_as_editable() {
|
|
let model = client_with_grid(ViewState::default()).model();
|
|
assert!(
|
|
model.rows[0].cells()[0].editable,
|
|
"a stored cell is editable"
|
|
);
|
|
assert!(
|
|
!model.rows[0].cells()[1].editable,
|
|
"an aggregate is a drill target, and Drill is not on the wire yet"
|
|
);
|
|
assert!(
|
|
!model.rows[1].cells()[0].editable,
|
|
"id: None is not 'aggregate' — it is 'no projected cell', and not writable either"
|
|
);
|
|
assert!(
|
|
!model.rows[1].cells()[1].editable,
|
|
"and neither is a slot fit_to_labels padded"
|
|
);
|
|
}
|
|
|
|
/// The numeric/error flags are the server's answers, echoed. The client
|
|
/// must not re-derive them from the text (that is how the two ends
|
|
/// drift), so the test asserts the echo rather than a parse.
|
|
#[test]
|
|
fn the_servers_formatting_facts_are_echoed_not_re_derived() {
|
|
let model = client_with_grid(ViewState::default()).model();
|
|
assert!(model.rows[0].cells()[0].numeric);
|
|
assert!(!model.rows[1].cells()[0].numeric);
|
|
assert_eq!(model.rows[0].cells()[0].text, "1200");
|
|
assert_eq!(model.col_levels[0], vec!["Revenue", "Total"]);
|
|
assert_eq!(model.rows[1].labels(), ["South"]);
|
|
}
|
|
|
|
/// The conversion that looks right until the first scroll: the grid is
|
|
/// viewport-indexed, the cursor is not. With `row_offset = 4`, absolute
|
|
/// row 5 is *render* row 1.
|
|
#[test]
|
|
fn the_selection_is_placed_by_subtracting_the_scroll_offset() {
|
|
let mut view = ViewState::default();
|
|
view.cursor_mut().selected = (5, 1);
|
|
view.cursor_mut().row_offset = 4;
|
|
view.cursor_mut().col_offset = 1;
|
|
let model = client_with_grid(view).model();
|
|
|
|
assert!(
|
|
model.rows[1].cells()[0].selected,
|
|
"absolute (5,1) under offsets (4,1) is render position (1,0)"
|
|
);
|
|
let elsewhere: usize = model
|
|
.rows
|
|
.iter()
|
|
.flat_map(|r| r.cells())
|
|
.filter(|c| c.selected)
|
|
.count();
|
|
assert_eq!(elsewhere, 1, "exactly one cell is selected");
|
|
}
|
|
|
|
/// A cursor scrolled off the projected window selects nothing, rather
|
|
/// than wrapping onto whatever cell happens to sit at that index.
|
|
#[test]
|
|
fn a_selection_outside_the_window_highlights_nothing() {
|
|
let mut view = ViewState::default();
|
|
view.cursor_mut().selected = (40, 0);
|
|
let model = client_with_grid(view).model();
|
|
assert!(
|
|
!model
|
|
.rows
|
|
.iter()
|
|
.flat_map(|r| r.cells())
|
|
.any(|c| c.selected),
|
|
"a cursor the server has not projected a row for highlights nothing"
|
|
);
|
|
}
|
|
|
|
/// The panel comes from `flatten_cat_tree` and honours this client's own
|
|
/// `expanded_cats` — which is why the flattening is per-client and
|
|
/// derived rather than sent (improvise-edp).
|
|
#[test]
|
|
fn the_category_panel_is_the_shared_flattening() {
|
|
let mut client = init();
|
|
client
|
|
.state_mut()
|
|
.fold(&Command::Project(Projection::CategoryTree(
|
|
CategoryTreeUpdate::Categories(vec![
|
|
CategoryRow {
|
|
name: "Region".into(),
|
|
items: vec!["North".into(), "South".into()],
|
|
axis: Axis::Row,
|
|
},
|
|
CategoryRow {
|
|
name: "Measure".into(),
|
|
items: vec!["Revenue".into()],
|
|
axis: Axis::Column,
|
|
},
|
|
]),
|
|
)));
|
|
|
|
let collapsed = client.model().categories;
|
|
assert_eq!(collapsed.len(), 2, "collapsed categories show headers only");
|
|
assert_eq!(collapsed[0].axis, "R");
|
|
assert_eq!(collapsed[0].item_count, Some(2));
|
|
assert_eq!(collapsed[0].expanded, Some(false));
|
|
|
|
client
|
|
.state_mut()
|
|
.view
|
|
.expanded_cats
|
|
.insert("Region".into());
|
|
client.state_mut().view.cat_panel_cursor = 2;
|
|
let expanded = client.model().categories;
|
|
assert_eq!(expanded.len(), 4, "Region's two items appear under it");
|
|
assert!(!expanded[1].is_category);
|
|
assert_eq!(expanded[1].label, "North");
|
|
assert!(
|
|
expanded[2].selected,
|
|
"cat_panel_cursor indexes the flattened list, items included"
|
|
);
|
|
assert_eq!(
|
|
expanded[3].label, "Measure",
|
|
"and the next category follows its items"
|
|
);
|
|
}
|
|
|
|
/// The mode's label is `AppMode`'s own, so a page never spells one.
|
|
#[test]
|
|
fn the_mode_label_comes_off_the_mode() {
|
|
let mut client = init();
|
|
client.state_mut().fold(&Command::SetStatus("saved".into()));
|
|
let model = client.model();
|
|
assert_eq!(model.mode, improvise_protocol::AppMode::Normal.label());
|
|
assert_eq!(model.status, "saved");
|
|
assert!(
|
|
model.hint.contains("i:edit"),
|
|
"the page draws the mode's own hint rather than spelling keys: {}",
|
|
model.hint
|
|
);
|
|
assert_eq!(model.revision, client.revision());
|
|
}
|
|
|
|
/// A keyboard edit has to be *visible*, and what is visible is a
|
|
/// decision made here rather than in the page.
|
|
///
|
|
/// The failure this rules out is quiet: a client that resolved `i`
|
|
/// locally would change its mode label, put the typing into
|
|
/// `buffers["edit"]`, and draw the cell's old value the whole time — so
|
|
/// the user sees a keystroke do nothing, types over it, and commits
|
|
/// something they never read. `None` outside an editing mode is the
|
|
/// other half: a stale buffer must not draw an editor that is not open.
|
|
#[test]
|
|
fn a_keyboard_edit_in_progress_is_part_of_the_render_model() {
|
|
let mut client = client_with_grid(ViewState::default());
|
|
assert_eq!(
|
|
client.model().editing,
|
|
None,
|
|
"nothing is being edited in Normal"
|
|
);
|
|
|
|
client
|
|
.state_mut()
|
|
.fold(&Command::ChangeMode(improvise_protocol::AppMode::editing()));
|
|
client.state_mut().fold(&Command::SetBuffer {
|
|
name: "edit".into(),
|
|
value: "12".into(),
|
|
});
|
|
assert_eq!(client.model().editing.as_deref(), Some("12"));
|
|
|
|
client
|
|
.state_mut()
|
|
.fold(&Command::ChangeMode(improvise_protocol::AppMode::Normal));
|
|
assert_eq!(
|
|
client.model().editing,
|
|
None,
|
|
"leaving the mode closes the editor even with the buffer still set"
|
|
);
|
|
}
|
|
|
|
/// The whole model crosses as JSON, so the page needs no bindings.
|
|
#[test]
|
|
fn the_render_model_crosses_as_json() {
|
|
let json = client_with_grid(ViewState::default())
|
|
.model_json()
|
|
.expect("serializes");
|
|
let value: serde_json::Value = serde_json::from_str(&json).expect("is JSON");
|
|
assert_eq!(value["rows"][0]["cells"][0]["editable"], true);
|
|
assert_eq!(value["rows"][0]["cells"][1]["editable"], false);
|
|
assert_eq!(value["col_levels"][0][0], "Revenue");
|
|
assert_eq!(
|
|
value["rows"][0]["kind"], "data",
|
|
"a row says which of the two it is; the page does not guess"
|
|
);
|
|
assert_eq!(value["rows"][0]["labels"][0], "North");
|
|
assert_eq!(
|
|
value["rows"][0]["cells"][0]["edit_text"], "1200",
|
|
"an editor seeds from the stored value, never from the drawn one"
|
|
);
|
|
}
|
|
}
|