58cd5d3858
An empty record exists only as a layout position, so resolve it against the layout of the view the command names -- the one grid_in projects for that session -- rather than the host's own layout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
408 lines
14 KiB
Rust
408 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(_) | ServerFrame::Accepted => {}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
view: "Default".into(),
|
|
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 {
|
|
view: "Default".into(),
|
|
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 {
|
|
view: "Default".into(),
|
|
cell: cell.clone(),
|
|
value: CellValue::Number(1.0),
|
|
});
|
|
let winner = main.caught_up(ModelCommand::SetCell {
|
|
view: "Default".into(),
|
|
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 {
|
|
view: "Default".into(),
|
|
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 {
|
|
view: "Default".into(),
|
|
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"
|
|
);
|
|
}
|