Files
improvise/crates/improvise-worker-server/tests/post_message.rs
T
Edward Langley d00cea2477 fix: show decimals by default, and let a formula name its own format
improvise-e0ex. `Margin = Profit / Revenue` drew as `1`: `View::new` seeded
`,.0` — zero decimals — so a 0.66 ratio rounded half-away-from-zero. The cause
is the format, not the arithmetic; exact decimal math would not change the
rendering by one character, so nothing here touches the evaluator and gotcha 8
is unchanged.

Two halves, per the user's ruling:

* `View::new` seeds `,.2`. Revenue draws `18,400.00` and Margin `0.66`. The
  noise on whole numbers is the cheaper error — a currency column with two
  redundant zeroes is legible, a ratio with none is wrong — and a format cannot
  be guessed from a value.
* A formula may carry its own format, which wins for its target; a raw measure
  keeps the view's. `formula.pest` gains
  `format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }`, with `sp+` for `WHERE`'s
  reason (a predicate is invisible to the grammar-walking generator) and
  `FORMAT` added to `quote::KEYWORDS`.

`improv.pest` is deliberately unchanged: `formula_raw` already carries the
`## Formulas` line verbatim, so the format rides in the formula text with
nothing to add — and the generator, which reads `improv.pest`, sees no new
spelling to violate.

Resolution has one home. `FormatPlan::of(model, view)` replaces the
`(comma, decimals)` pair every render path threaded — `GridLayout::display_text`,
`viewport::grid_body`/`compute_col_widths`, `project::grid_in` and
`persistence/export.rs::render_csv` — rather than being added beside it, which
would be two answers to "what format is this cell". It is asked of the cell's
`CellKey`, so a cell aggregating over `_Measure` keeps the view's format by
construction. `GridBody::col_formats` carries the answer to the totals strip,
which is formatted on its own path.

Persistence is untouched except for metadata: `format_f64` is display,
`persistence/format.rs::format_number` writes round-trip precision, and the two
have no call between them. Newly created views write `format: ,.2`; existing
documents are unaffected, because `format_md` has always written the line
explicitly. `web/demo.improv` moves to `,.2` on both views.

The trap this sets is gotcha 34's: `format_md` writes `Formula::raw`, so a
`FORMAT` suffix round-trips whether or not the walker parsed it. The round-trip
test therefore asserts the parsed field — injecting a walker that drops the
clause fails that one test and nothing else in `improvise-io`, 242 tests
including the whole characterization suite and the grammar properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:33:52 -07:00

402 lines
14 KiB
Rust

//! The main thread's traffic, simulated (improvise-9x6 AC 4): strings only.
//! What this sees and what it cannot: `context/test-evidence/clients.md`.
use std::path::{Path, PathBuf};
use improvise_command::ui::app::App;
use improvise_command::ui::reduce;
use improvise_core::model::Aggregation;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::view::Axis;
use improvise_core::view::layout::GridLayout;
use improvise_core::workbook::Workbook;
use improvise_io::persistence;
use improvise_io::storage::MemoryFs;
use improvise_protocol::{
CellId, ClientFrame, Command, GridCache, ModelCommand, Projection, RecordCell, ServerFrame,
WidgetCache,
};
use improvise_worker_server::vfs::{self, Opening};
use improvise_worker_server::worker::Worker;
/// The document a fresh origin is seeded with, written by the real writer: a
/// hand-rolled `.improv` gets `Measure` vs `_Measure` wrong, silently.
fn demo_bytes() -> Vec<u8> {
let mut wb = Workbook::new("Quarterly");
wb.add_category("Region").unwrap();
for item in ["North", "South", "East"] {
wb.add_item("Region", item).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);
for (region, value) in [("North", 10.0), ("South", 20.0), ("East", 30.0)] {
wb.model.data.set(
CellKey::new(vec![
("Region".to_string(), region.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(value),
);
}
persistence::format_md(&wb).into_bytes()
}
/// The main thread: it holds a port, a grid cache, and no protocol knowledge
/// beyond "post a string, fold what comes back, in order".
struct MainThread {
worker: Worker,
grid: GridCache,
document: improvise_protocol::DocumentCache,
/// Every dirty marker this client has been told, in order.
dirty_marks: Vec<bool>,
}
impl MainThread {
fn boot(storage: MemoryFs, path: &Path) -> Self {
Self::boot_seeded(storage, path, &demo_bytes())
}
fn boot_seeded(storage: MemoryFs, path: &Path, seed: &[u8]) -> Self {
let workbook = vfs::open(&storage, &Opening::named(path).with_seed(seed))
.expect("the demo seeds into an empty origin")
.workbook;
let mut app = App::with_storage(workbook, Some(path.to_path_buf()), Box::new(storage));
app.rebuild_layout();
Self {
worker: Worker::new(app),
grid: GridCache::default(),
document: improvise_protocol::DocumentCache::default(),
dirty_marks: Vec::new(),
}
}
/// Post one frame and fold everything that comes back. Panics on a frame
/// the worker refuses, as a `postMessage` handler should.
fn post(&mut self, frame: &ClientFrame) {
let json = serde_json::to_string(frame).expect("the client serializes its frame");
let replies = self
.worker
.on_message(&json)
.expect("the worker accepts it");
for reply in replies {
let frame: ServerFrame = serde_json::from_str(&reply).expect("a ServerFrame");
self.fold(&frame);
}
}
/// Post a frame and report the raw replies, for the tests whose subject is
/// what the worker *said* rather than what the client ends up holding.
fn ask(&mut self, frame: &ClientFrame) -> Vec<ServerFrame> {
let json = serde_json::to_string(frame).unwrap();
self.worker
.on_message(&json)
.expect("the worker accepts it")
.iter()
.map(|r| serde_json::from_str(r).expect("a ServerFrame"))
.collect()
}
fn fold(&mut self, frame: &ServerFrame) {
match frame {
ServerFrame::Snapshot(snapshot) => {
self.grid = GridCache::default();
for projection in &snapshot.seed {
self.apply(projection);
}
}
ServerFrame::Commands(cmds) => {
for cmd in cmds {
if let Command::Project(projection) = cmd {
self.apply(projection);
}
}
}
ServerFrame::Rejected(_) => {}
}
}
fn apply(&mut self, projection: &Projection) {
match projection {
Projection::Grid(update) => self.grid.apply(update),
Projection::Document(update) => {
self.document.apply(update);
self.dirty_marks.push(update.dirty);
}
_ => {}
}
}
fn text_at(&self, row: usize, col: usize) -> &str {
&self.grid.cells[row][col].text
}
/// The surrogate the server projected for a cell — the only handle a
/// client has to name one (ADR 002).
fn cell(&self, row: usize, col: usize) -> RecordCell {
match self.grid.cells[row][col].id.as_ref().expect("a cell") {
CellId::Record(rc) => rc.clone(),
other => panic!("({row},{col}) is not a stored cell: {other:?}"),
}
}
/// A model command composed against the document as it stands, taking the
/// `Basis` off the `App` — a shortcut only a host can take.
fn caught_up(&self, cmd: ModelCommand) -> ClientFrame {
ClientFrame::command(reduce::caught_up(self.worker.app(), cmd))
}
}
fn origin() -> (MemoryFs, PathBuf) {
(MemoryFs::new(), PathBuf::from("quarterly.improv"))
}
/// The whole acceptance criterion in one run: a document is seeded into an
/// empty origin, opened, projected, edited over the wire, saved, reloaded.
#[test]
fn the_worker_loads_projects_edits_and_persists_over_post_message() {
let (storage, path) = origin();
let mut main = MainThread::boot(storage, &path);
main.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
assert_eq!(
main.grid.row_frame.leaf_labels(),
vec!["North", "South", "East"]
);
assert_eq!(main.text_at(0, 0), "10.00");
let cell = main.cell(2, 0);
main.post(&main.caught_up(ModelCommand::SetCell {
cell,
value: CellValue::Number(31.0),
}));
assert_eq!(
main.text_at(2, 0),
"31.00",
"the edit must come back through the projection stream, not be assumed"
);
// Nothing here asks to save: no client can. The worker persists on its own
// behalf — see `context/code/clients.md`.
let saved = persistence::load_from(&*main.worker.app().storage, &path)
.expect("the document is in the VFS");
assert_eq!(
value_at(&saved, 2, 0),
Some(CellValue::Number(31.0)),
"the edit never reached storage"
);
}
/// The value at a grid position in a document freshly loaded from the backend:
/// a `CellValue`, not display text, which would depend on the number format.
fn value_at(wb: &Workbook, row: usize, col: usize) -> Option<CellValue> {
let layout = GridLayout::new(&wb.model, wb.active_view());
let key = layout.cell_key(row, col)?;
wb.model
.evaluate_aggregated(&key, Aggregation::of(&layout.none_cats))
}
/// A second worker over the same backend sees what the first one saved: what a
/// browser reload is, and why `Storage` is in the picture at all.
#[test]
fn a_reload_over_the_same_origin_reopens_what_was_saved() {
let (storage, path) = origin();
let mut main = MainThread::boot(storage, &path);
main.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
let cell = main.cell(0, 0);
main.post(&main.caught_up(ModelCommand::SetCell {
cell,
value: CellValue::Number(77.0),
}));
// The "reload": a fresh worker over the backend the first one wrote to,
// offered the same seed it was originally given.
let workbook = vfs::open(
&*main.worker.app().storage,
&Opening::named(&path).with_seed(&demo_bytes()),
)
.expect("reopens")
.workbook;
let mut app = App::with_storage(workbook, Some(path.clone()), Box::new(MemoryFs::new()));
app.rebuild_layout();
let mut reloaded = MainThread {
worker: Worker::new(app),
grid: GridCache::default(),
document: improvise_protocol::DocumentCache::default(),
dirty_marks: Vec::new(),
};
reloaded.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
assert_eq!(
reloaded.text_at(0, 0),
"77.00",
"the reload re-seeded over the user's saved work"
);
}
/// ADR 001 over a `MessagePort`: a worker that never sent `Rejected` would
/// leave a client's basis claim unanswerable.
#[test]
fn a_batch_composed_against_a_moved_document_is_refused() {
let (storage, path) = origin();
let mut main = MainThread::boot(storage, &path);
main.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
let cell = main.cell(0, 0);
let stale = main.caught_up(ModelCommand::SetCell {
cell: cell.clone(),
value: CellValue::Number(1.0),
});
let winner = main.caught_up(ModelCommand::SetCell {
cell,
value: CellValue::Number(2.0),
});
main.post(&winner);
let replies = main.ask(&stale);
assert!(
matches!(replies.as_slice(), [ServerFrame::Rejected(_)]),
"expected a rejection, got {replies:?}"
);
assert_eq!(
main.text_at(0, 0),
"2.00",
"a refused batch changed the document"
);
}
/// The handshake, enforced by dropping rather than closing: a worker has no
/// connection to close.
#[test]
fn a_command_before_the_first_viewport_is_dropped() {
let (storage, path) = origin();
let mut main = MainThread::boot(storage, &path);
let replies = main.ask(&ClientFrame::command(Command::SetSelected {
row: 2,
col: 0,
}));
assert!(
replies.is_empty(),
"answered before the handshake: {replies:?}"
);
assert_eq!(main.worker.app().cursor().selected, (0, 0));
}
/// A resize is a fan-out with nothing dirty: the document did not move, but
/// the window did, so the grid must be re-projected to the new extent.
#[test]
fn a_resize_reprojects_the_grid_although_the_document_is_unchanged() {
let (storage, path) = origin();
let mut main = MainThread::boot(storage, &path);
main.post(&ClientFrame::Viewport { rows: 1, cols: 4 });
assert_eq!(main.grid.row_frame.len(), 1, "one row fits");
main.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
assert_eq!(
main.grid.row_frame.len(),
3,
"growing the window must deliver the rows it can now show"
);
}
/// `Storage::list` is the document picker a browser has no OS file manager
/// for, and it must see created documents as well as seeded ones.
#[test]
fn the_vfs_offers_every_document_the_origin_holds() {
let (storage, path) = origin();
let main = MainThread::boot(storage, &path);
let fs = &*main.worker.app().storage;
vfs::open(fs, &Opening::named("scratch.improv")).expect("a second, empty document");
assert_eq!(
vfs::documents(fs).unwrap(),
vec![
PathBuf::from("quarterly.improv"),
PathBuf::from("scratch.improv")
],
"a picker must see both the seeded document and the created one"
);
}
/// The document the static site actually ships, driven the way the page drives
/// it. See `context/test-evidence/clients.md`.
#[test]
fn the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable() {
let seed = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/demo.improv"))
.expect("web/demo.improv is where the site build expects it");
let (storage, _) = origin();
let mut main = MainThread::boot_seeded(storage, Path::new("demo.improv"), &seed);
main.post(&ClientFrame::Viewport { rows: 40, cols: 20 });
assert_eq!(
main.grid.row_frame.leaf_labels(),
vec!["Espresso", "Latte", "Cold Brew", "Pastry"]
);
assert_eq!(
main.grid.col_frame.leaf_labels(),
vec!["Revenue", "Cost", "Profit"]
);
assert_eq!(main.text_at(0, 2), "12,200.00", "Profit is live on arrival");
// `cell` panics on anything but a stored cell, which is exactly the failure a
// visitor meets as a cell that does not answer a click (ADR 002).
for row in 0..main.grid.row_frame.len() {
for col in 0..2 {
main.cell(row, col);
}
}
// The third column is `Profit`, which a formula owns: the page must not
// offer it, so it carries no surrogate to offer (improvise-ir7l).
for row in 0..main.grid.row_frame.len() {
assert!(
matches!(main.grid.cells[row][2].id, Some(CellId::Derived { .. })),
"row {row}'s Profit cell must be Derived, got {:?}",
main.grid.cells[row][2].id
);
}
// And the payoff the page exists to show: an edit to an input moves the
// formula beside it, through the projection stream.
let revenue = main.cell(0, 0);
main.post(&main.caught_up(ModelCommand::SetCell {
cell: revenue,
value: CellValue::Number(20000.0),
}));
assert_eq!(main.text_at(0, 0), "20,000.00");
assert_eq!(main.text_at(0, 2), "13,800.00", "20,000 - 6,200");
}
/// improvise-z754: `on_frame` built the client's frames and persisted AFTER, so
/// the dirty marker cleared outside any projection. Evidence file.
#[test]
fn the_automatic_persist_tells_the_client_the_document_is_clean() {
let (storage, path) = origin();
let mut main = MainThread::boot(storage, &path);
main.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
// The bootstrap already seeded a clean marker; what this test is about is
// what the EDIT and its persist then say.
main.dirty_marks.clear();
let cell = main.cell(0, 0);
main.post(&main.caught_up(ModelCommand::SetCell {
cell,
value: CellValue::Number(31.0),
}));
assert!(
!main.worker.app().model_state.dirty,
"the worker must really have persisted, or this proves nothing"
);
// `DocumentCache::default()` is already clean, so the clean assertion alone
// cannot tell "told it cleared" from "never told anything".
assert_eq!(
main.dirty_marks,
vec![true, false],
"the client must see the marker raised and then cleared, in one batch"
);
assert!(
!main.document.dirty,
"the client must be told the document is clean — it is showing [+] otherwise"
);
}