Files
improvise/crates/improvise-worker-server/tests/post_message.rs
T
Edward Langley 0f14b2a39e fix(protocol): a formula-owned cell projects CellId::Derived, not Record
improvise-ir7l. project::cell_id decided Record vs Aggregate purely on whether
a regular category sat on Axis::None; it never asked whether the coordinate
named a formula target. So a formula cell was a Record, render.rs::editable
said true, and a write to it landed, was stored, and was then shadowed forever
by formula_cache — not a revert, a permanent silent shadow, on the demo's
front page.

Rather than a second "but not really" flag beside CellId (the two-answers
shape ADR 002 exists to forbid), a formula-owned cell now projects a different
kind of identity. Derived carries the formula's target and no surrogate, so
the write is unconstructible rather than refused.

The predicate is Model::formula_for(&CellKey), one home, which already existed
three times open-coded inside model/types.rs. The TUI lands in the same change:
EditOrDrill calls the same predicate and refuses, or the two front ends
diverge.

Two orderings are load-bearing and tested: Derived is asked before the record
branch (the coordinate is stored), and after the aggregate branch (gotcha 10 —
an aggregating formula cell is still drillable).

Two tests were passing while their names were false for the Profit column and
are renamed and two-sided now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:59:59 -07:00

348 lines
12 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::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,
}
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");
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(),
}
}
/// 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 {
if let Projection::Grid(update) = projection {
self.grid.apply(update);
}
}
}
ServerFrame::Commands(cmds) => {
for cmd in cmds {
if let Command::Project(Projection::Grid(update)) = cmd {
self.grid.apply(update);
}
}
}
ServerFrame::Rejected(_) => {}
}
}
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");
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",
"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, &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");
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(),
};
reloaded.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
assert_eq!(
reloaded.text_at(0, 0),
"77",
"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",
"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", "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");
assert_eq!(main.text_at(0, 2), "13,800", "20,000 - 6,200");
}