bdce1636bb
improvise-9x6: the server half of the standalone static-web deployment (epic improvise-tm6). `improvise-worker-server` hosts the authoritative `App`, the command pipeline, the projection layer and VFS persistence behind `self.onmessage`/`self.postMessage`. It is small because everything it needs already existed: the frames are `improvise_protocol::frame`'s (improvise-xks6), the session loop is `Host` (previous commit), the basis check is `Sessions::apply`/`Checked` (improvise-ondj), and persistence is `Storage` + `OpfsFs` (improvise-6mq, i34). What is genuinely new is three things. `vfs.rs` — how a document gets INTO a browser's empty filesystem. Loading is unchanged: it is `persistence::load_from` on a `Storage`, the same call the native binary makes. Seeding is the new problem, and it is a `Storage::write`, so a fetched demo, an upload and a saved document take the same parse path and a seed never clobbers an edit. `list` is the document picker a browser has no OS file manager for. `worker.rs` — the loop: one port, one client, the `Viewport` handshake (dropped rather than closed, since there is no connection to close), and the filter from `Addressed`. Every decision is here and every test points at it. `bindings.rs` — `cfg(wasm32)`, decision-free, and the only part no test here can reach: it awaits `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` and forwards strings. Two gaps found and reported rather than papered over. No client on any transport can ask its host to save — `effect::Save` is `ViewOnly`, so the wire vocabulary derived from the model effects cannot name it — and a wire `ModelCommand::SetCell` never sets `model_state.dirty`, because dirtiness is raised by a separate `MarkDirty` effect the key path emits. So the worker persists on its own behalf, keyed off the command variant rather than off a flag that is never set; `touches_document` carries the argument. `ClientFrame::Key` is still answered with nothing. A worker CAN call `reduce_key` — it holds the `App` — but nothing in the projection stream carries the host's own `ViewState`, so a half-wired arm diverges rather than under-delivers. `a_key_resolved_on_the_host_moves_state_no_client_can_hear` demonstrates it against a real `App` and `Sessions` (improvise-bpq2). 22 tests, all host-side: 16 in the crate and 6 in `tests/post_message.rs`, which drives the worker with nothing but the JSON a `MessagePort` carries. Bundle: 1,334,892 bytes raw, 496,965 gzipped — 485 KB against a 3 MB budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
323 lines
12 KiB
Rust
323 lines
12 KiB
Rust
//! The main thread's traffic, simulated (improvise-9x6 AC 4).
|
|
//!
|
|
//! The counterpart of `improvise-ws-server`'s `tests/socket.rs` and the wasm
|
|
//! client's `tests/browser_round_trip.rs`: a real `Worker` over a real `App`
|
|
//! over a real `Storage`, driven with **nothing but strings** — exactly what
|
|
//! crosses a `MessagePort` — and asserted on the strings that come back.
|
|
//!
|
|
//! Two things this can see that a unit test cannot, and one it cannot.
|
|
//!
|
|
//! It sees the **JSON boundary**: every frame is serialized and deserialized,
|
|
//! so a `ClientFrame` the browser could compose but this crate could not parse
|
|
//! fails here. And it sees **arrival order**, because the harness folds the
|
|
//! frames in the order the worker returned them, which is the order a
|
|
//! `postMessage` loop must preserve (gotcha 33).
|
|
//!
|
|
//! What it cannot see is OPFS. `MemoryFs` stands in, so what is exercised is
|
|
//! the `Storage` *contract* rather than the browser's implementation of it —
|
|
//! `OpfsFs` owes `storage_contract` and has never been run (gotcha 28,
|
|
//! improvise-xf1 deferred). Everything above the trait is real.
|
|
|
|
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.
|
|
///
|
|
/// Not a hand-rolled `.improv`: the virtual category is spelled `Measure` on
|
|
/// disk and `_Measure` in the model, and getting that wrong yields a document
|
|
/// that parses into an empty grid — which reads as a projection bug rather
|
|
/// than a bad fixture.
|
|
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 {
|
|
let workbook = vfs::open(&storage, &Opening::named(path).with_seed(&demo_bytes()))
|
|
.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, which is what a `postMessage` handler should do
|
|
/// with an error it cannot route.
|
|
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(),
|
|
CellId::Aggregate(_) => panic!("({row},{col}) is an aggregate"),
|
|
}
|
|
}
|
|
|
|
/// A model command composed against the document as it stands. A real
|
|
/// client builds its `Basis` from `Projection::Basis`; this shortcut takes
|
|
/// it off the `App`, which only a host can do — the staleness case is
|
|
/// covered by `a_batch_composed_against_a_moved_document_is_refused`.
|
|
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, and found
|
|
/// again on disk.
|
|
#[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_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. There is no `ModelCommand::Save` for a client
|
|
// to send — `effect::Save` is `ViewOnly`, so the wire vocabulary derived
|
|
// from the model effects cannot name it (gotcha 38) — so the worker
|
|
// persists on its own behalf after a batch dirties the document.
|
|
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, so the assertion does not depend on the
|
|
/// view's 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. This
|
|
/// is what a browser reload is, and the reason `Storage` is in the picture at
|
|
/// all rather than the document living in the page.
|
|
#[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`. `ServerFrame::Rejected` is not socket-only,
|
|
/// and a worker that never sent one 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 by closing: a worker has no
|
|
/// connection to close, and the client has been sent no document to compose
|
|
/// against.
|
|
#[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_labels.len(), 1, "one row fits");
|
|
|
|
main.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
|
|
assert_eq!(
|
|
main.grid.row_labels.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 documents the worker created 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"
|
|
);
|
|
}
|