6f9cd6cad5
A user reported "I can't expand categories, and the Tile + View UIs don't
work". Driving the assembled site under Chromium separates three different
answers, and the useful finding is that two of the three were missing only
their last layer.
Category expand already worked, by keyboard: Shift-C, j, Enter folds
Command::ToggleCatExpand client-side and the items appear. What does not work
is the mouse, because categoryList builds plain <li>s with no listener — the
whole page's pointer model, filed as improvise-7ybz rather than guessed at.
The tile bar and the view panel had the cache, the keymap, the resolver and
the host round trip, and no DOM. Before this commit the browser held five
TileBarCache entries and both ViewListCache views; Shift-T entered TileSelect,
ArrowRight moved tile_cat_idx, and r/c/p really did move a category between
axes — with nothing on screen to aim them at. RenderModel had no field for
either and index.html had no element. The formula panel was the third of
Panel::ALL in the same state, and is fixed by the same mechanism rather than
left arbitrarily behind.
So RenderModel grows `tiles`, `views` and `formulas`. The two panels follow
the help overlay's rule — an empty list is what says a widget is closed, so
the page asks no question about the mode — and the tile bar is drawn always
with only its *selection* conditional, exactly as src/ui/tile_bar.rs makes it
conditional on AppMode::TileSelect.
The view panel's axis line would have been a second derivation, so
ViewContent::axis_summary moves to improvise_protocol::viewmodel as
view_axis_summary and the TUI's own panel now calls it: one home, both ends
(design-principles §5).
Tests: harness/tests/widgets.spec.js, proved by running it against the
unfixed bundle, where the four new assertions fail on zero elements and the
category one passes — the control that says the keyboard really is being
driven. Two draft assertions were wrong and both corrections are recorded:
Axis::short_label is three columns ("Quarter Pag"), and switching views
leaves the panel open as the TUI does.
Not fixed, filed: improvise-7ybz (no pointer affordance anywhere on the page),
improvise-wsaw (app.js has never read category_panel_open, so #categories is
drawn unconditionally and is now inconsistent with the two panels added here).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
808 lines
29 KiB
Rust
808 lines
29 KiB
Rust
//! What the page paints: the client's state with every rendering decision
|
|
//! already made. The four silent ones are in `context/code/clients.md`.
|
|
|
|
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, `""` where a label repeats.
|
|
/// 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
|
|
/// axis has no groups at all — a fact, not a gap.
|
|
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 view panel's rows, **empty unless it is open** — the same rule the
|
|
/// help overlay follows, so the page asks no question about the mode.
|
|
pub views: Vec<RenderViewRow>,
|
|
/// The formula panel's rows, empty unless it is open.
|
|
pub formulas: Vec<RenderFormulaRow>,
|
|
/// The tile bar, drawn on every frame as the TUI draws it. Which tile is
|
|
/// `selected` is the mode's answer, not the page's.
|
|
pub tiles: Vec<RenderTile>,
|
|
/// The keys this mode offers, off `help::hint_line` — the same string the
|
|
/// TUI's footer draws, so the page spells no keys of its own.
|
|
pub hint: String,
|
|
/// The help overlay's rows, **empty unless it is open**. Shared content
|
|
/// (improvise-ktv1); the page does the painting and nothing else.
|
|
pub help: Vec<RenderHelpRow>,
|
|
pub help_page: usize,
|
|
pub help_titles: Vec<String>,
|
|
/// What the user has typed so far while a keyboard edit is open on the
|
|
/// selected cell. The decision is made here — `context/code/clients.md`.
|
|
pub editing: Option<String>,
|
|
}
|
|
|
|
/// A grid row: either a group header spanning the row, or data. An enum so a
|
|
/// header cannot carry cells and a data row cannot carry a collapse state.
|
|
#[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; the empty slice for a group header, which is
|
|
/// a reasonable question about a row the caller did not choose.
|
|
pub fn cells(&self) -> &[RenderCell] {
|
|
match self {
|
|
RenderRow::Data { cells, .. } => cells,
|
|
RenderRow::Group { .. } => &[],
|
|
}
|
|
}
|
|
|
|
/// A data row's header labels, one per level; **empty for a group header**,
|
|
/// which carries its own label. See `context/code/clients.md`.
|
|
pub fn labels(&self) -> &[String] {
|
|
match self {
|
|
RenderRow::Data { labels, .. } => labels,
|
|
RenderRow::Group { .. } => &[],
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A run of columns under one group header. Why `label` is an `Option` and not
|
|
/// an empty string (Copilot, PR #23): `context/code/clients.md`.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
|
pub struct RenderColGroup {
|
|
pub label: Option<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? `CellId::Record` alone.
|
|
pub editable: bool,
|
|
pub selected: bool,
|
|
/// What an editor should start with: the stored value, not the formatted
|
|
/// one. Seeding from `text` 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 a category
|
|
/// header's own facts; an item row carries neither, hence `Option`.
|
|
#[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,
|
|
}
|
|
|
|
/// One row of the view panel. `active` is which view this client is on, off
|
|
/// `ViewState` and never off the cache (improvise-8uz).
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
|
pub struct RenderViewRow {
|
|
pub name: String,
|
|
/// The axis line, e.g. `R:Region C:Product` — the shared derivation both
|
|
/// front ends call, never a second walk of `ViewSummary::axes`.
|
|
pub axes: String,
|
|
pub active: bool,
|
|
pub selected: bool,
|
|
}
|
|
|
|
/// One row of the formula panel: the formula's own text, already rendered by
|
|
/// the host, plus whether the panel cursor is on it.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
|
pub struct RenderFormulaRow {
|
|
pub text: String,
|
|
pub selected: bool,
|
|
}
|
|
|
|
/// One tile: a category the view may move between axes, with `Axis`'s own
|
|
/// short label (`"·"` for None is an answer, not a truncation — gotcha 26).
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
|
pub struct RenderTile {
|
|
pub category: String,
|
|
pub axis: &'static str,
|
|
pub selected: bool,
|
|
}
|
|
|
|
/// Build the page's frame from the caches and the view slice.
|
|
pub fn render_model(state: &ClientState) -> RenderModel {
|
|
let cursor = state.view.cursor();
|
|
let (sel_row, sel_col) = cursor.selected;
|
|
let entries = flatten_cat_tree(&state.categories, &state.view.expanded_cats);
|
|
let rows = &state.grid.row_frame;
|
|
let cols = &state.grid.col_frame;
|
|
// Built once for the whole frame: the per-position accessors scan,
|
|
// so asking them inside the row loop is quadratic.
|
|
let row_labels = rows.header_labels(0);
|
|
|
|
RenderModel {
|
|
revision: state.revision,
|
|
mode: improvise_protocol::mode_label(&state.view.mode).to_string(),
|
|
status: state.view.status_msg.clone(),
|
|
view_name: state.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: row_labels[r].clone(),
|
|
cells: (0..cols.len())
|
|
.map(|c| {
|
|
// The one conversion: viewport pos +
|
|
// offset is the absolute position.
|
|
let selected = r + cursor.row_offset == sel_row
|
|
&& c + cursor.col_offset == sel_col;
|
|
match state.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 == state.view.cat_panel_cursor))
|
|
.collect(),
|
|
category_panel_open: state.view.category_panel_open,
|
|
views: view_rows(state),
|
|
formulas: formula_rows(state),
|
|
tiles: tile_row(state),
|
|
hint: improvise_protocol::hint_line(&state.view.mode),
|
|
help: help_rows(&state.view),
|
|
help_page: state.view.help_page,
|
|
help_titles: help_titles(&state.view),
|
|
editing: state
|
|
.view
|
|
.mode
|
|
.is_editing()
|
|
.then(|| state.view.buffers.get("edit").cloned().unwrap_or_default()),
|
|
}
|
|
}
|
|
|
|
/// The view panel's rows while it is open. The axis line is
|
|
/// `viewmodel::view_axis_summary`, which the TUI's own panel calls too.
|
|
fn view_rows(state: &ClientState) -> Vec<RenderViewRow> {
|
|
if !state.view.view_panel_open {
|
|
return Vec::new();
|
|
}
|
|
state
|
|
.views
|
|
.views
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, summary)| RenderViewRow {
|
|
name: summary.name.clone(),
|
|
axes: improvise_protocol::viewmodel::view_axis_summary(summary),
|
|
active: summary.name == state.view.active_view,
|
|
selected: i == state.view.view_panel_cursor,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The formula panel's rows while it is open.
|
|
fn formula_rows(state: &ClientState) -> Vec<RenderFormulaRow> {
|
|
if !state.view.formula_panel_open {
|
|
return Vec::new();
|
|
}
|
|
state
|
|
.formulas
|
|
.formulas
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, text)| RenderFormulaRow {
|
|
text: text.clone(),
|
|
selected: i == state.view.formula_cursor,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The tile bar, always. Only the *selection* is conditional, exactly as
|
|
/// `src/ui/tile_bar.rs` makes it conditional on `AppMode::TileSelect`.
|
|
fn tile_row(state: &ClientState) -> Vec<RenderTile> {
|
|
let selecting = state.view.mode == improvise_protocol::AppMode::TileSelect;
|
|
state
|
|
.tiles
|
|
.tiles
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, tile)| RenderTile {
|
|
category: tile.category.clone(),
|
|
axis: tile.axis.short_label(),
|
|
selected: selecting && i == state.view.tile_cat_idx,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// One help-overlay row, already resolved: the keys (empty for prose), the
|
|
/// description, and which of the shared line kinds it is.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
|
pub struct RenderHelpRow {
|
|
pub keys: String,
|
|
pub description: String,
|
|
/// `blank`, `banner`, `heading`, `text`, `accent`, `keys` or `dim` — the
|
|
/// page styles it; it decides nothing.
|
|
pub kind: &'static str,
|
|
}
|
|
|
|
impl RenderHelpRow {
|
|
fn of(line: &improvise_protocol::HelpLine) -> Self {
|
|
use improvise_protocol::HelpLine as L;
|
|
let prose = |kind, text: &str| Self {
|
|
keys: text.to_string(),
|
|
description: String::new(),
|
|
kind,
|
|
};
|
|
match line {
|
|
L::Blank => prose("blank", ""),
|
|
L::Banner(t) => prose("banner", t),
|
|
L::Heading(t) => prose("heading", t),
|
|
L::Text(t) => prose("text", t),
|
|
L::Accent(t) => prose("accent", t),
|
|
L::Keys { keys, description } => Self {
|
|
keys: keys.join(" "),
|
|
description: description.to_string(),
|
|
kind: "keys",
|
|
},
|
|
L::Literal {
|
|
keys,
|
|
description,
|
|
dim,
|
|
} => Self {
|
|
keys: (*keys).to_string(),
|
|
description: description.to_string(),
|
|
kind: match dim {
|
|
true => "dim",
|
|
false => "keys",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
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 tab bar's labels, on the same terms as the rows: building five pages
|
|
/// on every frame to fill a list the page would not draw is not free.
|
|
fn help_titles(view: &improvise_protocol::ViewState) -> Vec<String> {
|
|
match view.mode == improvise_protocol::AppMode::Help {
|
|
false => Vec::new(),
|
|
true => improvise_protocol::help::pages()
|
|
.into_iter()
|
|
.map(|page| page.title.to_string())
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
/// The overlay's rows, and **only while it is open**: an empty list is what
|
|
/// tells the page not to draw one. Shared content, per-front-end painting.
|
|
fn help_rows(view: &improvise_protocol::ViewState) -> Vec<RenderHelpRow> {
|
|
if view.mode != improvise_protocol::AppMode::Help {
|
|
return Vec::new();
|
|
}
|
|
let pages = improvise_protocol::help::pages();
|
|
let page = pages.get(view.help_page).unwrap_or(&pages[0]);
|
|
page.lines.iter().map(RenderHelpRow::of).collect()
|
|
}
|
|
|
|
/// The column header lines: one per level, blanked where a label repeats.
|
|
/// Exactly `AxisFrame::shows_level`; the suppression rule is not restated.
|
|
fn col_levels(cols: &AxisFrame) -> Vec<Vec<String>> {
|
|
// `header_labels` answers per column; the page draws per *line*, so this
|
|
// is a transpose and nothing more.
|
|
let per_column = cols.header_labels(0);
|
|
(0..cols.levels())
|
|
.map(|level| {
|
|
per_column
|
|
.iter()
|
|
.map(|levels| levels[level].clone())
|
|
.collect()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The column groups as runs, coalescing equal answers from
|
|
/// `AxisFrame::group_of`. Nothing at all when no column is in a group.
|
|
fn col_groups(cols: &AxisFrame) -> Vec<RenderColGroup> {
|
|
let mut runs: Vec<RenderColGroup> = Vec::new();
|
|
let mut any = false;
|
|
for group in cols.groups() {
|
|
any |= group.is_some();
|
|
let label = group.map(|g| g.name.clone());
|
|
let collapsed = group.is_some_and(|g| g.collapsed);
|
|
// Coalesced by NAME alone, as `src/ui/grid.rs` does, and the run's
|
|
// indicator is its FIRST group's. See `context/code/clients.md`.
|
|
match runs.last_mut() {
|
|
Some(run) if run.label == label => run.span += 1,
|
|
_ => runs.push(RenderColGroup {
|
|
label,
|
|
collapsed,
|
|
span: 1,
|
|
}),
|
|
}
|
|
}
|
|
match any {
|
|
true => runs,
|
|
false => Vec::new(),
|
|
}
|
|
}
|
|
|
|
impl Client {
|
|
pub fn model(&self) -> RenderModel {
|
|
render_model(self.state())
|
|
}
|
|
|
|
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,
|
|
TileEntry, ViewState, ViewSummary,
|
|
};
|
|
|
|
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 group(cat: &str, name: &str, collapsed: bool) -> FrameEntry {
|
|
FrameEntry::Group(improvise_protocol::FrameGroup {
|
|
cat: cat.into(),
|
|
name: name.into(),
|
|
collapsed,
|
|
})
|
|
}
|
|
|
|
fn item(label: &str) -> FrameEntry {
|
|
FrameEntry::Item(vec![label.into()])
|
|
}
|
|
|
|
/// The run boundary must be the **name alone**, or the two front ends
|
|
/// disagree about where a span begins. See the evidence file.
|
|
#[test]
|
|
fn column_groups_coalesce_by_name_as_the_tui_does() {
|
|
let frame = AxisFrame {
|
|
cats: vec!["Month".into()],
|
|
entries: vec![
|
|
group("Month", "Q1", false),
|
|
item("Jan"),
|
|
group("Fiscal", "Q1", true),
|
|
item("Feb"),
|
|
group("Month", "Q2", false),
|
|
item("Apr"),
|
|
],
|
|
};
|
|
assert_eq!(
|
|
col_groups(&frame)
|
|
.iter()
|
|
.map(|g| (g.label.as_deref(), g.span, g.collapsed))
|
|
.collect::<Vec<_>>(),
|
|
vec![(Some("Q1"), 2, false), (Some("Q2"), 1, false)],
|
|
"the two Q1 headers are one span, drawn with the FIRST one's \
|
|
indicator; keying on (name, collapsed) would split them"
|
|
);
|
|
}
|
|
|
|
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: 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 — 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. Offsets are nonzero and unequal.
|
|
#[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 sits 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 it is derived rather than sent.
|
|
#[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"
|
|
);
|
|
}
|
|
|
|
fn client_with_panels() -> Client {
|
|
let mut client = init();
|
|
client
|
|
.state_mut()
|
|
.fold(&Command::Project(Projection::ViewList(vec![
|
|
ViewSummary {
|
|
name: "Default".into(),
|
|
axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)],
|
|
},
|
|
ViewSummary {
|
|
name: "Plan".into(),
|
|
axes: vec![],
|
|
},
|
|
])));
|
|
client
|
|
.state_mut()
|
|
.fold(&Command::Project(Projection::Formulas(vec![
|
|
"Profit = Revenue - Cost".into(),
|
|
])));
|
|
client
|
|
.state_mut()
|
|
.fold(&Command::Project(Projection::TileBar(vec![
|
|
TileEntry {
|
|
category: "Region".into(),
|
|
axis: Axis::Row,
|
|
},
|
|
TileEntry {
|
|
category: "Product".into(),
|
|
axis: Axis::None,
|
|
},
|
|
])));
|
|
client
|
|
}
|
|
|
|
/// An empty list is what says a panel is closed — the same rule the help
|
|
/// overlay follows, so the page asks no question about the mode.
|
|
#[test]
|
|
fn a_closed_panel_projects_no_rows_and_an_open_one_does() {
|
|
let mut client = client_with_panels();
|
|
let closed = client.model();
|
|
assert!(closed.views.is_empty(), "the cache is full but the panel is shut");
|
|
assert!(closed.formulas.is_empty());
|
|
|
|
client.state_mut().view.view_panel_open = true;
|
|
client.state_mut().view.formula_panel_open = true;
|
|
client.state_mut().view.view_panel_cursor = 1;
|
|
client.state_mut().view.active_view = "Default".into();
|
|
let open = client.model();
|
|
assert_eq!(open.views.len(), 2);
|
|
assert_eq!(
|
|
open.views[0].axes, "R:Region",
|
|
"the axis line is the shared derivation, and _Dim is not part of it"
|
|
);
|
|
assert!(open.views[0].active, "active is ViewState's, not the cache's");
|
|
assert!(open.views[1].selected, "and selected is the panel cursor's");
|
|
assert_eq!(open.formulas[0].text, "Profit = Revenue - Cost");
|
|
}
|
|
|
|
/// The tile bar is drawn always; only the *selection* waits for the mode,
|
|
/// exactly as `src/ui/tile_bar.rs` makes it conditional.
|
|
#[test]
|
|
fn the_tile_bar_is_always_present_and_only_its_cursor_is_modal() {
|
|
let mut client = client_with_panels();
|
|
let normal = client.model();
|
|
assert_eq!(normal.tiles.len(), 2);
|
|
assert_eq!(normal.tiles[1].axis, "·", "None is an answer, not a truncation");
|
|
assert!(
|
|
!normal.tiles.iter().any(|t| t.selected),
|
|
"out of tile select nothing is highlighted"
|
|
);
|
|
|
|
client.state_mut().view.mode = improvise_protocol::AppMode::TileSelect;
|
|
client.state_mut().view.tile_cat_idx = 1;
|
|
let selecting = client.model();
|
|
assert!(selecting.tiles[1].selected);
|
|
assert_eq!(selecting.tiles.iter().filter(|t| t.selected).count(), 1);
|
|
}
|
|
|
|
/// 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::mode_label(&improvise_protocol::AppMode::Normal)
|
|
);
|
|
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 that is decided here rather
|
|
/// than in the page. See `context/test-evidence/clients.md`.
|
|
#[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"
|
|
);
|
|
}
|
|
}
|