feat(wasm-client,web): the DOM renderer for the browser grid (improvise-cr3)
The last blocker of improvise-1ey. Mostly assembly: `Client` already had the fold, the key classifier and `set_cell`, `CellDisplay::id` already said what a cell is (ADR 002), and `flatten_cat_tree` already owned the category panel's flattening. What was missing was the three joints between them. `render.rs` is the one that matters. The DOM half is JavaScript and JavaScript here is untested, so every question with a *silent* wrong answer is answered in Rust under test and crosses as decided data: `editable` is a fact about the `CellId` variant (Record only — an Aggregate is a drill target and `Drill` is not on the wire yet, and `None` is "no projected cell", not "aggregate"), the selection is placed by subtracting the scroll offset because `GridCache` is viewport-indexed while the cursor is not, and the category panel is `flatten_cat_tree` rather than a second walk of the cache. `socket.rs` frames what `improvise-ws-server` actually speaks, which is not what `wire.rs` speaks: it has `Viewport` (the handshake — a snapshot built at extent zero is an empty grid), it batches `Commands`, and it has no `Key` variant. The two enums are duplicated because a native crate cannot be a wasm dependency; `improvise-ws-server` is a dev-dependency so the compatibility is checked through its own types, and a companion test proves the check is discriminating on the variant names that would drift. `web/` is the shell, and it is thin on purpose: four string calls in and out, no protocol knowledge, no framework. Gates: 1047 passing (was 1031), clippy and fmt clean, and `cargo check -p improvise-wasm-client --target wasm32-unknown-unknown` clean. Verified end to end headlessly — the wasm-pack artifact driven from node over a real websocket to a real `improvise-ws-server`: 1,200 read, "98765" typed, "98,765" projected back. The DOM painting itself is unverified; there is no headless browser here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
This commit is contained in:
Generated
+1
@@ -946,6 +946,7 @@ dependencies = [
|
||||
"improvise-command",
|
||||
"improvise-core",
|
||||
"improvise-protocol",
|
||||
"improvise-ws-server",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
|
||||
@@ -25,6 +25,19 @@ wasm-bindgen = "0.2"
|
||||
# this client over the same keypress and asserts their `ViewState`s agree. Dev
|
||||
# dependencies are not built for `--target wasm32-unknown-unknown`, so the
|
||||
# browser bundle is unaffected.
|
||||
#
|
||||
# `improvise-ws-server` is here for the same reason and on the same terms:
|
||||
# `socket.rs` mirrors its two wire enums, and mirroring them is only safe if
|
||||
# a rename fails a test. It is a native crate — tokio, tungstenite, an `App`
|
||||
# owning a `Box<dyn Storage>` — so it could never be a real dependency.
|
||||
[dev-dependencies]
|
||||
improvise-command = { path = "../improvise-command" }
|
||||
improvise-core = { path = "../improvise-core" }
|
||||
improvise-ws-server = { path = "../improvise-ws-server" }
|
||||
|
||||
# The `wasm-opt` wasm-pack downloads predates bulk-memory being enabled by
|
||||
# default, while LLVM emits `memory.copy` regardless — so the validator
|
||||
# rejects the module unless the feature is named. Naming it here rather than
|
||||
# turning `wasm-opt` off keeps the optimizer in the loop.
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = ["-O", "--enable-bulk-memory"]
|
||||
|
||||
@@ -53,14 +53,18 @@
|
||||
//! [`Client::on_key_event`] returned them.
|
||||
|
||||
pub mod keys;
|
||||
pub mod render;
|
||||
pub mod socket;
|
||||
pub mod state;
|
||||
pub mod wire;
|
||||
|
||||
pub use keys::{BrowserKey, KeyOutcome, resolve};
|
||||
pub use render::{RenderCatRow, RenderCell, RenderModel, RenderRow};
|
||||
pub use socket::{ClientFrame, ServerFrame};
|
||||
pub use state::ClientState;
|
||||
pub use wire::{ClientMessage, ServerMessage};
|
||||
|
||||
use improvise_protocol::{CellId, CellValue, Command, ModelCommand, Snapshot};
|
||||
use improvise_protocol::{CellId, CellValue, Command, ModelCommand, RecordCell, Snapshot};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// The handle a JS bootstrap holds for the lifetime of the tab.
|
||||
@@ -151,6 +155,52 @@ impl Client {
|
||||
let message = self.set_cell(row, col, value)?;
|
||||
serde_json::to_string(&message).ok()
|
||||
}
|
||||
|
||||
// ── the websocket surface (improvise-cr3) ───────────────────────────
|
||||
//
|
||||
// Three string calls, and they are the whole harness a page needs: send
|
||||
// `viewport`, feed every socket frame to `on_frame`, paint `render_model`
|
||||
// when the revision moves. See [`socket`] for why these frames are not
|
||||
// [`ClientMessage`]/[`ServerMessage`].
|
||||
|
||||
/// The handshake frame, and the one to resend on every resize.
|
||||
///
|
||||
/// Associated rather than a method: a page must send it *before* it has
|
||||
/// anything to fold, and nothing about this client's state is involved.
|
||||
#[wasm_bindgen]
|
||||
pub fn viewport(rows: usize, cols: usize) -> String {
|
||||
serde_json::to_string(&Self::viewport_frame(rows, cols))
|
||||
// Two `usize` in a unit variant; unreachable, and an empty frame
|
||||
// is skipped by the server rather than crashing the tab.
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fold one frame off the websocket, in arrival order, one at a time.
|
||||
///
|
||||
/// Returns the new revision so the page can skip a repaint. Never
|
||||
/// reorder or coalesce: a batch that is split or sorted loses cells
|
||||
/// (gotcha 33).
|
||||
#[wasm_bindgen]
|
||||
pub fn on_frame(&mut self, json: &str) -> Result<u64, JsError> {
|
||||
self.handle_frame(json)
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Everything the page draws, with every rendering decision already
|
||||
/// made. See [`render`].
|
||||
#[wasm_bindgen]
|
||||
pub fn render_model(&self) -> Result<String, JsError> {
|
||||
self.model_json().map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// The frame for "the user typed `text` into the cell at this viewport
|
||||
/// position", or `None` when that position is not one this client may
|
||||
/// write to. The page shows an editor only where `editable` is true, so
|
||||
/// `None` here is a race, not a normal path.
|
||||
#[wasm_bindgen]
|
||||
pub fn typed_cell(&self, row: usize, col: usize, text: &str) -> Option<String> {
|
||||
serde_json::to_string(&self.typed_cell_frame(row, col, text)?).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// The same surface in plain Rust, for tests and for any host that is not JS.
|
||||
@@ -165,6 +215,24 @@ impl Client {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// The fold's target. Crate-visible on purpose in spirit — it is `pub`
|
||||
/// only because tests in sibling modules drive it, and nothing outside
|
||||
/// this crate should be folding into a client behind its own methods.
|
||||
pub fn state_mut(&mut self) -> &mut ClientState {
|
||||
&mut self.state
|
||||
}
|
||||
|
||||
/// The address of the stored cell at a viewport position, or `None` on
|
||||
/// the three shapes that are not one: outside the grid, no projected
|
||||
/// cell (`id: None`), or an aggregate. The last is not a check — the
|
||||
/// pattern simply does not bind (ADR 002).
|
||||
pub fn record_cell_at(&self, row: usize, col: usize) -> Option<RecordCell> {
|
||||
match self.state.grid.cell(row, col)?.id.clone()? {
|
||||
CellId::Record(cell) => Some(cell),
|
||||
CellId::Aggregate(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold one server→client message given as JSON. A message that does not
|
||||
/// parse folds nothing.
|
||||
pub fn handle_message(&mut self, json: &str) -> Result<u64, serde_json::Error> {
|
||||
@@ -210,9 +278,7 @@ impl Client {
|
||||
/// which is the side that formats it; guessing it locally is how the two
|
||||
/// ends drift apart.
|
||||
pub fn set_cell(&self, row: usize, col: usize, value: CellValue) -> Option<ClientMessage> {
|
||||
let CellId::Record(cell) = self.state.grid.cell(row, col)?.id.clone()? else {
|
||||
return None;
|
||||
};
|
||||
let cell = self.record_cell_at(row, col)?;
|
||||
Some(ClientMessage::Command(Command::Model(
|
||||
ModelCommand::SetCell { cell, value },
|
||||
)))
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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::{CellDisplay, CellId};
|
||||
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,
|
||||
pub col_labels: Vec<String>,
|
||||
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,
|
||||
}
|
||||
|
||||
/// A grid row: its header label and its cells.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RenderRow {
|
||||
pub label: String,
|
||||
pub cells: Vec<RenderCell>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
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_labels: self.grid.col_labels.clone(),
|
||||
rows: self
|
||||
.grid
|
||||
.row_labels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(r, label)| RenderRow {
|
||||
label: label.clone(),
|
||||
cells: (0..self.grid.col_labels.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(),
|
||||
})
|
||||
.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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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::RowLabels(vec!["North".into(), "South".into()])),
|
||||
Projection::Grid(GridUpdate::ColLabels(vec![
|
||||
"Revenue".into(),
|
||||
"Total".into(),
|
||||
])),
|
||||
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_labels, vec!["Revenue", "Total"]);
|
||||
assert_eq!(model.rows[1].label, "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_eq!(model.revision, client.revision());
|
||||
}
|
||||
|
||||
/// 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_labels"][0], "Revenue");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! The frames a browser tab exchanges with `improvise-ws-server`, and the
|
||||
//! fold that consumes them in arrival order (improvise-cr3).
|
||||
//!
|
||||
//! # Why this is not [`crate::wire`]
|
||||
//!
|
||||
//! This crate already has an envelope pair, and it is a *different* pair.
|
||||
//! [`ClientMessage`](crate::ClientMessage)/[`ServerMessage`](crate::ServerMessage)
|
||||
//! frame the **worker** deployment (epic improvise-tm6): one command out,
|
||||
//! one command back, and `ClientMessage::Key` for the keypresses a thin
|
||||
//! client cannot resolve (gotcha 38). `improvise-ws-server`'s socket speaks
|
||||
//! a different pair for reasons that are each load-bearing:
|
||||
//!
|
||||
//! - it adds `Viewport { rows, cols }`, which is **the handshake** — the
|
||||
//! bootstrap `Snapshot` seeds the *visible* cells, so one built at extent
|
||||
//! zero is an empty grid (`improvise-ws-server::wire`);
|
||||
//! - it batches: `Commands(Vec<Command>)`, because a host's fan-out for one
|
||||
//! client frame is several projections that must arrive whole and in
|
||||
//! sequence (gotcha 33);
|
||||
//! - it has **no `Key` variant** at all. Key forwarding is not on that
|
||||
//! socket yet, which is why keyboard navigation is still improvise-bpq2.
|
||||
//!
|
||||
//! So the two are not the same enum wearing two names, and collapsing them
|
||||
//! would mean deciding those three questions for the worker transport too.
|
||||
//!
|
||||
//! # The duplication, stated rather than hidden
|
||||
//!
|
||||
//! These two enums *mirror* `improvise_ws_server::wire::{ClientMsg, ServerMsg}`
|
||||
//! and must stay JSON-compatible with them. They are duplicated because
|
||||
//! `improvise-ws-server` is a native crate — tokio, tungstenite, a
|
||||
//! `Box<dyn Storage>` — and cannot be a dependency of a wasm bundle. The
|
||||
//! honest fix is for `improvise-protocol` to own the framing and both ends
|
||||
//! to use it; that is a protocol change and deliberately not made here.
|
||||
//!
|
||||
//! What stands in for it until then is [`tests::the_socket_framing_matches_the_server`]:
|
||||
//! `improvise-ws-server` is a **dev**-dependency (dev-dependencies are not
|
||||
//! built for `--target wasm32-unknown-unknown`, the same argument
|
||||
//! `improvise-command` already makes in this crate's `Cargo.toml`), so every
|
||||
//! variant is round-tripped *through the other crate's type* in both
|
||||
//! directions. A renamed variant on either side fails that test rather than
|
||||
//! failing in a browser.
|
||||
//!
|
||||
//! # Ordering
|
||||
//!
|
||||
//! [`Client::fold_frame`] is a `for` over `Commands` in arrival order and
|
||||
//! nothing else — no batching, sorting or coalescing — because a
|
||||
//! `GridUpdate::Cells` that overtakes its framing labels is dropped, not
|
||||
//! replayed (gotcha 33). A batch is one frame precisely so a transport
|
||||
//! cannot split it.
|
||||
|
||||
use improvise_protocol::{CellValue, Command, ModelCommand, Snapshot};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Client;
|
||||
|
||||
/// Browser → `improvise-ws-server`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ClientFrame {
|
||||
/// How many rows and columns of the grid this tab can display. The
|
||||
/// connection's **first** frame, and sent again on every resize.
|
||||
Viewport { rows: usize, cols: usize },
|
||||
/// User intent, in the shared vocabulary.
|
||||
Command(Command),
|
||||
}
|
||||
|
||||
/// `improvise-ws-server` → browser.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ServerFrame {
|
||||
/// Drop everything and start from this.
|
||||
Snapshot(Box<Snapshot>),
|
||||
/// Fold these, in order.
|
||||
Commands(Vec<Command>),
|
||||
}
|
||||
|
||||
/// How a typed string becomes a [`CellValue`].
|
||||
///
|
||||
/// The same three-way split `commit_regular_cell_value` makes in
|
||||
/// `improvise-command` — empty clears, a parseable number is a number,
|
||||
/// anything else is text. It is duplicated here for the reason the whole
|
||||
/// client exists: the browser has no `App` to ask, and `ModelCommand::SetCell`
|
||||
/// takes a `CellValue`, so *somebody* on this side must decide. Putting it in
|
||||
/// Rust rather than in the page's JavaScript is what makes it testable and
|
||||
/// keeps one spelling of the rule per language boundary instead of two.
|
||||
///
|
||||
/// `None` means "clear this cell", which is a different command, not a
|
||||
/// different value — hence the `Option` rather than a `CellValue::Text("")`.
|
||||
fn typed(text: &str) -> Option<CellValue> {
|
||||
match text.is_empty() {
|
||||
true => None,
|
||||
false => Some(match text.parse::<f64>() {
|
||||
Ok(n) => CellValue::Number(n),
|
||||
Err(_) => CellValue::Text(text.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// The handshake frame, and every resize after it.
|
||||
///
|
||||
/// Nothing is folded locally: the extent is not in `ViewState` and no
|
||||
/// command carries it, so the grid this client draws next is whatever
|
||||
/// the host projects in answer.
|
||||
pub fn viewport_frame(rows: usize, cols: usize) -> ClientFrame {
|
||||
ClientFrame::Viewport { rows, cols }
|
||||
}
|
||||
|
||||
/// Fold one server frame, in arrival order.
|
||||
pub fn fold_frame(&mut self, frame: ServerFrame) -> u64 {
|
||||
match frame {
|
||||
ServerFrame::Snapshot(snapshot) => self.bootstrap(*snapshot),
|
||||
ServerFrame::Commands(commands) => {
|
||||
for cmd in &commands {
|
||||
self.state_mut().fold(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.revision()
|
||||
}
|
||||
|
||||
/// Parse and fold one server frame given as JSON.
|
||||
pub fn handle_frame(&mut self, json: &str) -> Result<u64, serde_json::Error> {
|
||||
let frame: ServerFrame = serde_json::from_str(json)?;
|
||||
Ok(self.fold_frame(frame))
|
||||
}
|
||||
|
||||
/// Compose the write a user made by typing `text` into the cell at a
|
||||
/// viewport position.
|
||||
///
|
||||
/// `None` on exactly the shapes [`Client::set_cell`] refuses — outside
|
||||
/// the grid, no projected cell, or an aggregate, which `SetCell` cannot
|
||||
/// be composed for because it does not take that kind of id (ADR 002).
|
||||
pub fn typed_cell_frame(&self, row: usize, col: usize, text: &str) -> Option<ClientFrame> {
|
||||
let cell = self.record_cell_at(row, col)?;
|
||||
Some(ClientFrame::Command(Command::Model(match typed(text) {
|
||||
Some(value) => ModelCommand::SetCell { cell, value },
|
||||
None => ModelCommand::ClearCell(cell),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::init;
|
||||
use improvise_protocol::{CellDisplay, CellId, GridUpdate, Projection, RecordCell, ViewState};
|
||||
|
||||
fn surrogate(hex: &str) -> improvise_protocol::RecordHash {
|
||||
serde_json::from_str(&format!("\"{hex}\"")).expect("a surrogate is hex")
|
||||
}
|
||||
|
||||
fn record(measure: &str) -> RecordCell {
|
||||
RecordCell {
|
||||
record: surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"),
|
||||
measure: Some(measure.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn frames() -> (Vec<ClientFrame>, Vec<ServerFrame>) {
|
||||
(
|
||||
vec![
|
||||
ClientFrame::Viewport { rows: 24, cols: 8 },
|
||||
ClientFrame::Command(Command::SetSelected { row: 1, col: 2 }),
|
||||
],
|
||||
vec![
|
||||
ServerFrame::Snapshot(Box::new(Snapshot {
|
||||
view: ViewState::default(),
|
||||
seed: vec![Projection::Formulas(vec!["Profit = Revenue".into()])],
|
||||
})),
|
||||
ServerFrame::Commands(vec![Command::SetStatus("saved".into())]),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// The compatibility this module owes, checked through the other
|
||||
/// crate's own types rather than asserted in prose. `improvise-ws-server`
|
||||
/// is a dev-dependency, so this costs the browser bundle nothing.
|
||||
#[test]
|
||||
fn the_socket_framing_matches_the_server() {
|
||||
use improvise_ws_server::wire::{ClientMsg, ServerMsg};
|
||||
|
||||
let (client_frames, server_frames) = frames();
|
||||
for frame in &client_frames {
|
||||
let json = serde_json::to_string(frame).expect("serializes");
|
||||
let theirs: ClientMsg = serde_json::from_str(&json)
|
||||
.unwrap_or_else(|e| panic!("the server cannot read {json}: {e}"));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ClientFrame>(
|
||||
&serde_json::to_string(&theirs).expect("serializes")
|
||||
)
|
||||
.expect("and back"),
|
||||
*frame,
|
||||
);
|
||||
}
|
||||
for frame in &server_frames {
|
||||
let json = serde_json::to_string(frame).expect("serializes");
|
||||
let theirs: ServerMsg = serde_json::from_str(&json)
|
||||
.unwrap_or_else(|e| panic!("the server cannot read {json}: {e}"));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ServerFrame>(
|
||||
&serde_json::to_string(&theirs).expect("serializes")
|
||||
)
|
||||
.expect("and back"),
|
||||
*frame,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `doctrine(check_the_checker)`: the test above is only worth anything
|
||||
/// if a *renamed* variant fails it. The rename is not hypothetical —
|
||||
/// this crate's other envelope spells the same payload `Bootstrap`
|
||||
/// rather than `Snapshot`, and `Command` rather than `Commands`. Neither
|
||||
/// name parses here, so the compatibility test above is discriminating
|
||||
/// on exactly the thing that would drift.
|
||||
#[test]
|
||||
fn a_frame_named_the_other_envelopes_way_does_not_parse() {
|
||||
let ours = serde_json::to_string(&ServerFrame::Commands(vec![Command::SetStatus(
|
||||
"saved".into(),
|
||||
)]))
|
||||
.expect("serializes");
|
||||
let other = ours.replacen("Commands\":[", "Command\":", 1);
|
||||
assert!(
|
||||
serde_json::from_str::<ServerFrame>(&other).is_err(),
|
||||
"a renamed variant must fail to parse, or the compatibility test proves nothing"
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<ServerFrame>(r#"{"Bootstrap":{"view":null,"seed":[]}}"#)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// A batch is folded in arrival order and is not reordered, so gotcha 33
|
||||
/// holds *inside* one frame exactly as it holds across frames: cells that
|
||||
/// precede the labels framing them are dropped.
|
||||
#[test]
|
||||
fn a_batch_is_folded_in_arrival_order() {
|
||||
let cells = Command::Project(Projection::Grid(GridUpdate::Cells(vec![(
|
||||
(0, 0),
|
||||
CellDisplay::number("1200"),
|
||||
)])));
|
||||
let rows = Command::Project(Projection::Grid(GridUpdate::RowLabels(vec![
|
||||
"North".into(),
|
||||
])));
|
||||
let cols = Command::Project(Projection::Grid(GridUpdate::ColLabels(vec![
|
||||
"Revenue".into(),
|
||||
])));
|
||||
|
||||
let mut early = init();
|
||||
early.fold_frame(ServerFrame::Commands(vec![
|
||||
cells.clone(),
|
||||
rows.clone(),
|
||||
cols.clone(),
|
||||
]));
|
||||
assert_eq!(
|
||||
early.state().grid.cell(0, 0),
|
||||
Some(&CellDisplay::default()),
|
||||
"a batched Cells that overtakes its frame is still dropped"
|
||||
);
|
||||
|
||||
let mut ordered = init();
|
||||
ordered.fold_frame(ServerFrame::Commands(vec![rows, cols, cells]));
|
||||
assert_eq!(
|
||||
ordered.state().grid.cell(0, 0),
|
||||
Some(&CellDisplay::number("1200")),
|
||||
);
|
||||
}
|
||||
|
||||
/// Folding a batch bumps the revision once per command, not once per
|
||||
/// frame — a renderer polling `revision` must see a five-command batch
|
||||
/// as movement even if it only repaints once.
|
||||
#[test]
|
||||
fn every_command_in_a_batch_bumps_the_revision() {
|
||||
let mut client = init();
|
||||
let after = client.fold_frame(ServerFrame::Commands(vec![
|
||||
Command::SetStatus("a".into()),
|
||||
Command::SetStatus("b".into()),
|
||||
Command::SetStatus("c".into()),
|
||||
]));
|
||||
assert_eq!(after, 3);
|
||||
assert_eq!(client.state().view.status_msg, "c");
|
||||
}
|
||||
|
||||
fn client_showing(id: Option<CellId>) -> Client {
|
||||
let mut cell = CellDisplay::number("1200");
|
||||
cell.id = id;
|
||||
let mut client = init();
|
||||
client.bootstrap(Snapshot {
|
||||
view: ViewState::default(),
|
||||
seed: vec![
|
||||
Projection::Grid(GridUpdate::RowLabels(vec!["North".into()])),
|
||||
Projection::Grid(GridUpdate::ColLabels(vec!["Revenue".into()])),
|
||||
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])),
|
||||
],
|
||||
});
|
||||
client
|
||||
}
|
||||
|
||||
/// The three-way split, matching `commit_regular_cell_value`: a number
|
||||
/// is a number, anything else is text, and *empty clears* rather than
|
||||
/// writing an empty string.
|
||||
#[test]
|
||||
fn a_typed_string_is_classified_as_the_host_classifies_it() {
|
||||
let client = client_showing(Some(CellId::Record(record("Revenue"))));
|
||||
assert_eq!(
|
||||
client.typed_cell_frame(0, 0, "42.5"),
|
||||
Some(ClientFrame::Command(Command::Model(
|
||||
ModelCommand::SetCell {
|
||||
cell: record("Revenue"),
|
||||
value: CellValue::Number(42.5),
|
||||
}
|
||||
)))
|
||||
);
|
||||
assert_eq!(
|
||||
client.typed_cell_frame(0, 0, "pending"),
|
||||
Some(ClientFrame::Command(Command::Model(
|
||||
ModelCommand::SetCell {
|
||||
cell: record("Revenue"),
|
||||
value: CellValue::Text("pending".into()),
|
||||
}
|
||||
)))
|
||||
);
|
||||
assert_eq!(
|
||||
client.typed_cell_frame(0, 0, ""),
|
||||
Some(ClientFrame::Command(Command::Model(
|
||||
ModelCommand::ClearCell(record("Revenue"))
|
||||
))),
|
||||
"an empty string clears the cell; it is not a Text write"
|
||||
);
|
||||
}
|
||||
|
||||
/// Typing into a cell the client may not write is no frame at all, for
|
||||
/// the same reason `set_cell` composes nothing: the id is of the wrong
|
||||
/// kind, or there is no id. `Drill` is not on the wire yet, so an
|
||||
/// aggregate is simply not editable here.
|
||||
#[test]
|
||||
fn typing_into_a_cell_that_is_not_a_record_composes_nothing() {
|
||||
let hash: improvise_protocol::AggregateHash =
|
||||
serde_json::from_str("\"5b05bbcd2364099f68a49dbf9099e075\"").expect("hex");
|
||||
assert_eq!(
|
||||
client_showing(Some(CellId::Aggregate(hash))).typed_cell_frame(0, 0, "42"),
|
||||
None
|
||||
);
|
||||
assert_eq!(client_showing(None).typed_cell_frame(0, 0, "42"), None);
|
||||
assert_eq!(
|
||||
client_showing(Some(CellId::Record(record("Revenue")))).typed_cell_frame(9, 9, "42"),
|
||||
None,
|
||||
"and neither is a position outside the grid"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! `improvise-1ey`'s stated scope, asserted end to end without a browser.
|
||||
//!
|
||||
//! > open an existing document server-side, view it in the browser, **type a
|
||||
//! > number into a cell, see the server value update and the projection
|
||||
//! > arrive with the new value.**
|
||||
//!
|
||||
//! Everything in that sentence except the DOM is Rust on both sides, so this
|
||||
//! test drives the *whole* of it: a real [`Host`] over an authoritative
|
||||
//! `App`, and a real [`Client`] reached only through the string calls the
|
||||
//! page makes. The socket is the one thing left out, and it is covered by
|
||||
//! `improvise-ws-server`'s own `tests/socket.rs`; what this adds is the half
|
||||
//! that crate cannot reach — that the **browser client** seeds, renders,
|
||||
//! composes and folds correctly against it.
|
||||
//!
|
||||
//! The discipline that makes it evidence rather than decoration: the client
|
||||
//! is touched **only** through `Client::viewport`, `Client::on_frame`,
|
||||
//! `Client::render_model` and `Client::typed_cell` — four strings in and out
|
||||
//! — and it learns the cell to write from its own render model, never from
|
||||
//! the fixture. A `CellKey` anywhere here would be the test knowing
|
||||
//! something a browser does not (ADR 002).
|
||||
|
||||
use improvise_command::ui::app::App;
|
||||
use improvise_core::model::cell::{CellKey, CellValue};
|
||||
use improvise_core::view::Axis;
|
||||
use improvise_core::workbook::Workbook;
|
||||
use improvise_wasm_client::{Client, RenderModel};
|
||||
use improvise_ws_server::host::{Addressed, Host};
|
||||
use improvise_ws_server::wire::ClientMsg;
|
||||
|
||||
/// The document the server opens: two regions by two measures, one cell
|
||||
/// already filled so the client has something to read before it writes.
|
||||
fn host() -> Host {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "North").unwrap();
|
||||
wb.add_item("Region", "South").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]),
|
||||
CellValue::Number(1200.0),
|
||||
);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
Host::new(app)
|
||||
}
|
||||
|
||||
/// The page's own loop, minus the DOM: every frame the host addressed to
|
||||
/// this session, in order, through the one call that folds them.
|
||||
fn deliver(client: &mut Client, frames: &[Addressed]) {
|
||||
for Addressed { msg, .. } in frames {
|
||||
client
|
||||
.on_frame(&serde_json::to_string(msg).expect("a server frame serializes"))
|
||||
.expect("the client must fold every frame the host sends");
|
||||
}
|
||||
}
|
||||
|
||||
/// The one frame the page sends that is not a command: the handshake.
|
||||
fn send(
|
||||
host: &mut Host,
|
||||
id: improvise_command::ui::project::SessionId,
|
||||
json: &str,
|
||||
) -> Vec<Addressed> {
|
||||
let msg: ClientMsg =
|
||||
serde_json::from_str(json).expect("the server must be able to read what the page sends");
|
||||
host.handle(id, msg)
|
||||
}
|
||||
|
||||
/// The position of the first cell the render model says may be typed into.
|
||||
/// Read off the model rather than assumed, because "which cells are
|
||||
/// editable" is precisely what the client is being trusted to say.
|
||||
fn first_editable(model: &RenderModel) -> (usize, usize) {
|
||||
model
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(r, row)| {
|
||||
row.cells
|
||||
.iter()
|
||||
.position(|cell| cell.editable)
|
||||
.map(|c| (r, c))
|
||||
})
|
||||
.expect("a pivot of raw data must offer at least one settable cell")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() {
|
||||
let mut host = host();
|
||||
let mut client = Client::new();
|
||||
|
||||
// ── the handshake ───────────────────────────────────────────────────
|
||||
// The extent comes first because the snapshot seeds the *visible*
|
||||
// cells: one built at extent zero is an empty grid.
|
||||
let handshake: ClientMsg = serde_json::from_str(&Client::viewport(10, 10))
|
||||
.expect("the viewport frame must be one the server reads");
|
||||
let ClientMsg::Viewport { rows, cols } = handshake else {
|
||||
panic!("Client::viewport must produce a Viewport frame");
|
||||
};
|
||||
let (id, snapshot) = host.connect(rows, cols);
|
||||
deliver(&mut client, &[snapshot]);
|
||||
|
||||
// ── what the page draws ─────────────────────────────────────────────
|
||||
let model = client.model();
|
||||
assert_eq!(
|
||||
model
|
||||
.rows
|
||||
.iter()
|
||||
.map(|r| r.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["North", "South"],
|
||||
"the seed must carry the row labels framing the cells"
|
||||
);
|
||||
assert_eq!(model.col_labels, vec!["Revenue", "Cost"]);
|
||||
assert_eq!(
|
||||
model.rows[0].cells[0].text, "1,200",
|
||||
"the value arrives *formatted* — the separator is the server's view \
|
||||
format, and a client that re-derived the text would not have it"
|
||||
);
|
||||
assert!(model.rows[0].cells[0].numeric);
|
||||
|
||||
// ── typing a number ─────────────────────────────────────────────────
|
||||
let (row, col) = first_editable(&model);
|
||||
assert_eq!(
|
||||
(row, col),
|
||||
(0, 0),
|
||||
"with no category on the None axis every cell is a stored cell"
|
||||
);
|
||||
let frame = client
|
||||
.typed_cell(row, col, "7500")
|
||||
.expect("an editable cell must compose a write");
|
||||
let frames = send(&mut host, id, &frame);
|
||||
|
||||
// ── the server value updated ────────────────────────────────────────
|
||||
assert_eq!(
|
||||
host.app()
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(&CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
])),
|
||||
Some(&CellValue::Number(7500.0)),
|
||||
"the authoritative document is the thing that must have moved"
|
||||
);
|
||||
|
||||
// ── and the projection arrives with the new value ───────────────────
|
||||
assert!(
|
||||
!frames.is_empty(),
|
||||
"a cell write owes this client a grid projection"
|
||||
);
|
||||
let before = client.revision();
|
||||
deliver(&mut client, &frames);
|
||||
assert!(
|
||||
client.revision() > before,
|
||||
"folding the fan-out must move the client"
|
||||
);
|
||||
assert_eq!(
|
||||
client.model().rows[row].cells[col].text,
|
||||
"7,500",
|
||||
"the page redraws from the server's formatting, never from the \
|
||||
\"7500\" the user typed"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of ADR 002, end to end: a cell the server projected as an
|
||||
/// **aggregate** is rendered non-editable, so the page never offers an
|
||||
/// editor `SetCell` could not be composed for. `Drill` is not on the wire
|
||||
/// yet (ADR 001 layer 2), which is why the answer is "not editable" rather
|
||||
/// than "opens a drill".
|
||||
#[test]
|
||||
fn an_aggregated_cell_reaches_the_page_as_not_editable() {
|
||||
let mut host = {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "North").unwrap();
|
||||
// A regular category left on the None axis is what makes a cell an
|
||||
// aggregate — the same question `EditOrDrill` asks in the TUI.
|
||||
wb.add_category("Product").unwrap();
|
||||
wb.add_item("Product", "Shirts").unwrap();
|
||||
wb.add_item("Product", "Hats").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut().set_axis("Product", Axis::None);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
Host::new(app)
|
||||
};
|
||||
let mut client = Client::new();
|
||||
let (_id, snapshot) = host.connect(10, 10);
|
||||
deliver(&mut client, &[snapshot]);
|
||||
|
||||
let model = client.model();
|
||||
assert!(
|
||||
model
|
||||
.rows
|
||||
.iter()
|
||||
.flat_map(|r| &r.cells)
|
||||
.all(|c| !c.editable),
|
||||
"every cell here aggregates over Product, so none may be written"
|
||||
);
|
||||
assert_eq!(
|
||||
client.typed_cell(0, 0, "42"),
|
||||
None,
|
||||
"and typing into one composes nothing, because the id is of the wrong kind"
|
||||
);
|
||||
}
|
||||
|
||||
/// A resize is a fan-out with nothing dirty, and it is the page's job to
|
||||
/// send one: the extent is not in `ViewState` and no command carries it, so
|
||||
/// a client that never resends it keeps the grid it first asked for.
|
||||
#[test]
|
||||
fn a_resize_reprojects_the_grid_at_the_new_extent() {
|
||||
let mut host = host();
|
||||
let mut client = Client::new();
|
||||
let (id, snapshot) = host.connect(1, 1);
|
||||
deliver(&mut client, &[snapshot]);
|
||||
assert_eq!(
|
||||
client.model().rows.len(),
|
||||
1,
|
||||
"a one-row viewport is seeded with one row"
|
||||
);
|
||||
|
||||
let frames = send(&mut host, id, &Client::viewport(10, 10));
|
||||
deliver(&mut client, &frames);
|
||||
assert_eq!(
|
||||
client.model().rows.len(),
|
||||
2,
|
||||
"the resize must bring the rest of the grid"
|
||||
);
|
||||
assert_eq!(client.model().col_labels, vec!["Revenue", "Cost"]);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# wasm-pack output; built, never committed.
|
||||
pkg/
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// The browser half of improvise-cr3, kept as thin as it can be made.
|
||||
//
|
||||
// The boundary is JSON strings in and out, so this file holds no protocol
|
||||
// knowledge at all: it never inspects a Command, never builds a CellId, never
|
||||
// decides whether a cell is editable or whether a typed string is a number.
|
||||
// Four calls are the whole harness —
|
||||
//
|
||||
// Client.viewport(rows, cols) -> the handshake frame, and every resize
|
||||
// client.on_frame(json) -> fold one socket frame, in arrival order
|
||||
// client.render_model() -> everything to paint, already decided
|
||||
// client.typed_cell(r, c, s) -> the frame for "the user typed s here"
|
||||
//
|
||||
// — and the two rules this file must not break are ordering and framing:
|
||||
// frames are folded one at a time in the order they arrive (a cell update
|
||||
// that overtakes its labels is dropped, not replayed), and the *first* frame
|
||||
// out must be the viewport, because the server builds the snapshot at it.
|
||||
|
||||
import init, { Client } from "./pkg/improvise_wasm_client.js";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const WS_URL = params.get("ws") ?? `ws://${location.hostname || "127.0.0.1"}:8787`;
|
||||
|
||||
// Rough character metrics, used only to turn the window size into an extent.
|
||||
// Being wrong here costs a row of over- or under-fetch, nothing more.
|
||||
const ROW_PX = 26;
|
||||
const COL_PX = 110;
|
||||
|
||||
const el = {
|
||||
grid: document.getElementById("grid"),
|
||||
categories: document.getElementById("categories"),
|
||||
mode: document.getElementById("mode"),
|
||||
status: document.getElementById("status"),
|
||||
viewName: document.getElementById("view-name"),
|
||||
};
|
||||
|
||||
await init();
|
||||
const client = new Client();
|
||||
const socket = new WebSocket(WS_URL);
|
||||
|
||||
/** The extent this window can show, in whole cells. */
|
||||
function extent() {
|
||||
const box = el.grid.getBoundingClientRect();
|
||||
return {
|
||||
rows: Math.max(1, Math.floor(box.height / ROW_PX) - 1),
|
||||
cols: Math.max(1, Math.floor(box.width / COL_PX) - 1),
|
||||
};
|
||||
}
|
||||
|
||||
function sendViewport() {
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
const { rows, cols } = extent();
|
||||
socket.send(Client.viewport(rows, cols));
|
||||
}
|
||||
|
||||
let painted = -1;
|
||||
let editing = null; // {row, col} while an input is open, so a repaint cannot steal it
|
||||
|
||||
function repaint() {
|
||||
const model = JSON.parse(client.render_model());
|
||||
if (model.revision === painted || editing) return;
|
||||
painted = model.revision;
|
||||
|
||||
el.mode.textContent = model.mode;
|
||||
el.status.textContent = model.status;
|
||||
el.viewName.textContent = model.view_name;
|
||||
el.grid.replaceChildren(gridTable(model));
|
||||
el.categories.replaceChildren(categoryList(model));
|
||||
}
|
||||
|
||||
function gridTable(model) {
|
||||
const table = document.createElement("table");
|
||||
|
||||
const head = table.createTHead().insertRow();
|
||||
head.appendChild(document.createElement("th"));
|
||||
for (const label of model.col_labels) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = label;
|
||||
head.appendChild(th);
|
||||
}
|
||||
|
||||
const body = table.createTBody();
|
||||
model.rows.forEach((row, r) => {
|
||||
const tr = body.insertRow();
|
||||
const header = document.createElement("th");
|
||||
header.scope = "row";
|
||||
header.textContent = row.label;
|
||||
tr.appendChild(header);
|
||||
|
||||
row.cells.forEach((cell, c) => {
|
||||
const td = tr.insertCell();
|
||||
td.textContent = cell.text;
|
||||
td.classList.toggle("numeric", cell.numeric);
|
||||
td.classList.toggle("error", cell.error);
|
||||
td.classList.toggle("selected", cell.selected);
|
||||
// `editable` is the server's answer relayed through Rust: a stored
|
||||
// cell. An aggregate is a drill target and drill is not on the wire
|
||||
// yet, so it is simply not offered here.
|
||||
td.classList.toggle("editable", cell.editable);
|
||||
if (cell.editable) td.addEventListener("click", () => edit(td, r, c));
|
||||
});
|
||||
});
|
||||
return table;
|
||||
}
|
||||
|
||||
/** Open an inline editor. Committing sends a frame; the value that comes
|
||||
* back is the server's, never the string typed here. */
|
||||
function edit(td, row, col) {
|
||||
if (editing) return;
|
||||
editing = { row, col };
|
||||
const original = td.textContent;
|
||||
const input = document.createElement("input");
|
||||
input.value = original;
|
||||
td.replaceChildren(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const close = (commit) => {
|
||||
editing = null;
|
||||
if (commit) {
|
||||
const frame = client.typed_cell(row, col, input.value);
|
||||
// null only if the grid moved under the editor; the repaint that
|
||||
// follows restores whatever the server now says is there.
|
||||
if (frame) socket.send(frame);
|
||||
}
|
||||
td.replaceChildren(original);
|
||||
painted = -1;
|
||||
repaint();
|
||||
};
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") close(true);
|
||||
else if (e.key === "Escape") close(false);
|
||||
e.stopPropagation();
|
||||
});
|
||||
input.addEventListener("blur", () => close(false));
|
||||
}
|
||||
|
||||
function categoryList(model) {
|
||||
const list = document.createElement("ul");
|
||||
for (const row of model.categories) {
|
||||
const li = document.createElement("li");
|
||||
li.className = row.is_category ? "category" : "item";
|
||||
li.classList.toggle("selected", row.selected);
|
||||
li.textContent = row.is_category
|
||||
? `${row.expanded ? "▾" : "▸"} ${row.label} (${row.item_count}) ${row.axis}`
|
||||
: row.label;
|
||||
list.appendChild(li);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
sendViewport(); // the handshake: the snapshot is built at this extent
|
||||
el.status.textContent = "connected";
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
// One frame, one fold, in arrival order. Never batch or reorder these.
|
||||
client.on_frame(event.data);
|
||||
repaint();
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
el.status.textContent = "disconnected";
|
||||
});
|
||||
|
||||
new ResizeObserver(sendViewport).observe(el.grid);
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
# Build the wasm bundle this page loads, then say how to run it.
|
||||
#
|
||||
# Needs wasm-pack (cargo install wasm-pack) and the wasm32-unknown-unknown
|
||||
# target. Nothing else in the repo depends on either — the `wasm` CI job only
|
||||
# runs `cargo check --target wasm32-unknown-unknown`, which does not need a
|
||||
# bindgen step.
|
||||
set -eu
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
wasm-pack build "$root/crates/improvise-wasm-client" \
|
||||
--target web --out-dir "$root/web/pkg"
|
||||
cat <<'MSG'
|
||||
|
||||
Built. To run it, in two terminals:
|
||||
|
||||
improvise-ws-server your-model.improv --port 8787
|
||||
python3 -m http.server 8080 --directory web
|
||||
|
||||
then open http://127.0.0.1:8080/ (override the socket with ?ws=ws://host:port).
|
||||
MSG
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>improvise</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
The browser shell for improvise-ws-server (improvise-cr3).
|
||||
|
||||
Deliberately three elements and no framework. Everything that could be
|
||||
*wrong* — what a cell is, whether it may be typed into, where the
|
||||
selection sits, how the category tree flattens — is decided in Rust and
|
||||
arrives already decided (see `render.rs`). This page paints it.
|
||||
|
||||
Build the wasm module into ./pkg first:
|
||||
|
||||
wasm-pack build crates/improvise-wasm-client --target web --out-dir ../../web/pkg
|
||||
|
||||
then serve this directory over http (a module script will not load from
|
||||
file://) and run the server beside it:
|
||||
|
||||
improvise-ws-server bank-info.improv --port 8787
|
||||
python3 -m http.server 8080 --directory web
|
||||
|
||||
The socket address can be overridden with ?ws=ws://host:port.
|
||||
-->
|
||||
<header>
|
||||
<h1>improvise</h1>
|
||||
<span id="view-name"></span>
|
||||
<span id="mode"></span>
|
||||
<span id="status" role="status"></span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside id="categories" aria-label="Categories"></aside>
|
||||
<div id="grid" tabindex="0" aria-label="Grid"></div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/* Legibility only. Styling the browser client properly is not improvise-cr3. */
|
||||
|
||||
:root {
|
||||
--bg: #fbfaf7;
|
||||
--fg: #1c1b19;
|
||||
--line: #d8d4cc;
|
||||
--accent: #2f6f4f;
|
||||
--selected: #e8e2d4;
|
||||
--error: #a8322d;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #17181a;
|
||||
--fg: #e7e4de;
|
||||
--line: #35373b;
|
||||
--accent: #7fc4a0;
|
||||
--selected: #2c2f33;
|
||||
--error: #e2827c;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#mode {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#status {
|
||||
margin-left: auto;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#categories {
|
||||
width: 15rem;
|
||||
flex: none;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
#categories ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#categories .item {
|
||||
padding-left: 1.5rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
#categories .selected,
|
||||
td.selected {
|
||||
background: var(--selected);
|
||||
outline: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
#grid {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.25rem 0.5rem;
|
||||
min-width: 6rem;
|
||||
height: 1.75rem;
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
th {
|
||||
background: color-mix(in srgb, var(--line) 35%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td.numeric {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
td.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* The only affordance that matters: a cell the server said is settable. */
|
||||
td.editable {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
td:not(.editable) {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
td input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
Reference in New Issue
Block a user