Merge gh/main (e8562f0) into fix/web-panel-clicks
PR #61 landed while this branch was open. It deleted `harness/tests/pointer.spec.js` and `harness/tests/widgets.spec.js`, split `web/app.js` into app/shell/serial, and added a Node suite under `web/tests/`. Two conflicts, both resolved deliberately rather than by reflex. **`harness/tests/pointer.spec.js` — modify/delete, resolved as DELETE.** The habitual "keep both sides" would resurrect a file #61 removed on purpose. `contract.spec.js` states its own scope as the RenderModel→DOM boundary and the pointer's half of the JS→wasm one, so that is where these assertions belong. Merged in, rather than restored: - the category sidebar is open at launch and `C` still hides it; - a first tile click selects and does NOT touch the document, a second cycles; - the view row and formula row clicks improvise-iioi added, which `contract.spec.js` had no entry for at all. **`context/test-evidence/clients.md` — content, resolved as BOTH SIDES.** The two `pointer.spec.js` subsections are retargeted at the file that exists, with a note saying why restoring the deleted one would have been wrong. The two older `#### … the file's own scope` sections keep their history and gain a forward pointer, because a reader following them would otherwise hunt for a file that is gone. **`web/tests/bundle/wasm_surface.test.js` now names `click_formula` and `click_view`.** That test is an exact `deepEqual` over the page's `client.*` calls and its comment says a new call "has to come and say so". It did: it failed on the merge, which is the mechanism working. It could not have been satisfied earlier — it did not exist when this branch was cut. Two review findings fixed on top, neither a live bug: - **The tile guarantee was overclaimed.** `T` leaves `tile_cat_idx` at 0, so a click on the LEFTMOST tile cycles on the first gesture. "A click never mutates on its own" and "a second click cycles" are both false against that probe. The behaviour is right — a click does what `Enter` does there — so the prose now states the implemented rule, and `t_then_a_click_on_the_leftmost_tile_cycles_it_immediately` names the case and asserts the neighbouring tile answers the same gesture differently. - **`switch_view_at`'s `Option` had no guard on the keyboard side.** Removing the `unwrap_or_default()` makes `Enter` in an empty view panel `Upstream` — gotcha 47's black hole — and passed the whole workspace. That would make membership of the host-needed list state-dependent, which is the one property that list denies. One test closes it. Gates on the merged tree: 1391 Rust tests, 26 + 3 Node tests, 16 Playwright (3 WebKit OPFS skips), clippy -D warnings, fmt, harness fmt + wasm32 clippy, eight wasm crates, comment runs and 513 evidence entries clean. `.beads/` matches gh/main exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-9
File diff suppressed because one or more lines are too long
@@ -97,6 +97,25 @@ jobs:
|
||||
- name: improvise-gp
|
||||
run: cargo check -p improvise-gp --target wasm32-unknown-unknown
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The browser JavaScript's unit tests. Its own job, deliberately: a failure
|
||||
# here is a decision `web/shell.js` or `web/serial.js` got wrong, and
|
||||
# reporting that as "Browser harness" would put it behind an 80 MB browser
|
||||
# download and two wasm-pack builds. It needs neither — `node --test` is in
|
||||
# the runtime, and this job installs nothing at all.
|
||||
js:
|
||||
name: Browser JS unit tests (node --test)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# The glob is the SHELL's, not node's: `web/tests/bundle/` needs a built
|
||||
# wasm bundle and runs in the browser job instead.
|
||||
- name: What the page decides between its boundaries
|
||||
run: node --test web/tests/*.test.js
|
||||
|
||||
kb:
|
||||
name: Knowledge base consistency
|
||||
runs-on: ubuntu-latest
|
||||
@@ -169,6 +188,12 @@ jobs:
|
||||
# `harness/serve` — the same tree `pages-build` publishes, plus /opfs/.
|
||||
- name: Build the harness
|
||||
run: ./harness/build.sh
|
||||
# The JS↔wasm boundary: every call web/app.js makes is declared by the
|
||||
# bundle, with that arity. It needs the bundle and nothing else, so it
|
||||
# runs here rather than in the `js` job, and before Playwright — a
|
||||
# renamed export is cheaper to read here than as a blank page.
|
||||
- name: The page's calls match the bundle it calls
|
||||
run: node --test web/tests/bundle/*.test.js
|
||||
- name: Run the harness
|
||||
working-directory: harness
|
||||
run: npx playwright test
|
||||
@@ -243,7 +268,7 @@ jobs:
|
||||
|
||||
pages-deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: [test, wasm, browser, pages-build]
|
||||
needs: [test, wasm, js, browser, pages-build]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
+116
-17
@@ -635,20 +635,30 @@ the *keyboard* handled the identical `None` correctly by answering
|
||||
different and benign: the whole click composed nothing, which is the invariant
|
||||
rather than a breach of it.
|
||||
|
||||
**Rule 3 — a click never mutates the document on its own.** The user's ruling on
|
||||
the tile bar, and it is deliberately stated over *every* click rather than over
|
||||
tiles: the first click on a tile selects it and enters `TileSelect`, and only a
|
||||
second click on the **already-selected** tile cycles its axis. The cost accepted
|
||||
is two clicks for the common action and a **deliberate divergence from the
|
||||
keyboard**, where `Enter` cycles directly — record it as chosen, not as an
|
||||
inconsistency to tidy, or someone will read the binding and "fix" the pointer to
|
||||
match. What it buys is worth more than the keystroke: a tile cycle is a
|
||||
`LayoutGeometry` change, which bumps `cursor_epoch` and therefore resets **every
|
||||
connected client's cursor** (gotcha 24), so a single stray click used to cost
|
||||
every other viewer their position. "Already selected" is `mode == TileSelect`
|
||||
**and** `tile_cat_idx == index`; the index alone is not enough, because
|
||||
`tile_cat_idx` is 0 before anything has selected anything and tile 0 would then
|
||||
cycle on its first click.
|
||||
**Rule 3 — a click on an *unselected* tile never mutates the document.** The
|
||||
user's ruling on the tile bar. The cost accepted is two clicks for the common
|
||||
action and a **deliberate divergence from the keyboard**, where `Enter` cycles
|
||||
directly — record it as chosen, not as an inconsistency to tidy, or someone will
|
||||
read the binding and "fix" the pointer to match. What it buys is worth more than
|
||||
the keystroke: a tile cycle is a `LayoutGeometry` change, which bumps
|
||||
`cursor_epoch` and therefore resets **every connected client's cursor** (gotcha
|
||||
24), so a stray click used to cost every other viewer their position.
|
||||
|
||||
**State the rule this precisely, because the looser wordings are false.** The
|
||||
implemented rule is "clicking the already-selected tile cycles it; clicking any
|
||||
other selects it", where *already selected* is `mode == TileSelect` **and**
|
||||
`tile_cat_idx == index`. The tempting summaries — "a click never mutates on its
|
||||
own", "a **second** click cycles" — both have the same counterexample, found on
|
||||
review: `T` enters `TileSelect` leaving `tile_cat_idx` at **0**, so a click on
|
||||
the **leftmost** tile is a click on the already-selected one and cycles on the
|
||||
*first* gesture. Two adjacent tiles therefore answer the identical first click
|
||||
differently, and that is correct rather than a hole — a click does what `Enter`
|
||||
does there, and `Enter` in `TileSelect` cycles the tile under the cursor. The
|
||||
resolver cannot see *how* the state arose and must not try to.
|
||||
|
||||
The mode conjunct is still load-bearing in the other direction: without it,
|
||||
`tile_cat_idx == 0` in **Normal** mode would read tile 0 as already selected, and
|
||||
the very first click of a session on the leftmost tile would cycle it.
|
||||
|
||||
**Rule 4 — the shared helper is the idiom, and breaking it is how the two arms
|
||||
drift.** `click_category_row` calls `filter_to_item_at`, `click_tile` calls
|
||||
@@ -967,6 +977,11 @@ 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.
|
||||
|
||||
Everything in this file that is a *decision* rather than a paint now lives in
|
||||
`web/shell.js` — `socketUrl`, the `#status` composition, the watchdog, the
|
||||
extent arithmetic and `gridPlan` — so that `node --test` can reach it without a
|
||||
browser. The sections below describe those rules; the code is one file over.
|
||||
|
||||
### Two transports, one client
|
||||
|
||||
The wasm bundle is the SAME artifact either way (improvise-xks6: one envelope
|
||||
@@ -1161,6 +1176,11 @@ makes "frames that arrived before `start()` finished" an ordinary case of the
|
||||
same rule instead of a second code path with its own drain loop — and that loop
|
||||
had the race in it twice over, since `server` was set before it ran.
|
||||
|
||||
The chaining itself is `web/serial.js::serialize`, which is promise plumbing and
|
||||
nothing else, so `web/tests/serial.test.js` can force the interleaving a browser
|
||||
cannot be asked to produce on demand — including the negative arm, which runs
|
||||
the same two handlers *without* the chain and shows them coming back swapped.
|
||||
|
||||
**The document is named in the worker's own URL** — `new
|
||||
Worker('worker.js?doc=demo.improv')` — the analogue of the socket server naming
|
||||
it in the `ws://` path. It is deliberately not a `ClientFrame`: the envelope pair
|
||||
@@ -1186,12 +1206,13 @@ at all — no `package.json`, no runner, nothing in CI running a single line of
|
||||
the bug-first rule could not be honoured for a change to either. **PR #21
|
||||
declined to add regression tests for its close-listener fix on exactly that
|
||||
ground, and that decline is only honest because those assertions live here
|
||||
instead.** So **a change to `web/*.js` belongs in this spec directory**, not in a
|
||||
second ad-hoc surface beside it.
|
||||
instead.**
|
||||
|
||||
Playwright rather than a headless-DOM runner because what goes wrong in these
|
||||
files is a real Worker, a real WebSocket, real OPFS and real event ordering, none
|
||||
of which a DOM shim has. It is deliberately small (improvise-vvl3): the point is
|
||||
of which a DOM shim has. That still holds, and it is why no shim was added; what
|
||||
changed is the **scope** — see the section below, which is now the answer to
|
||||
"where does a change to `web/*.js` belong". It is deliberately small (improvise-vvl3): the point is
|
||||
to be able to run `storage_contract` against `OpfsFs` and to read `#status` out
|
||||
of a real page, not to grow a second test framework beside `cargo test`. The
|
||||
server root is `harness/serve`, built by `build.sh` — the assembled site plus the
|
||||
@@ -1251,3 +1272,81 @@ is invisible to a document picker.
|
||||
`opfs_root`/`make_directory` exist only in the harness: `OpfsFs` has no directory
|
||||
vocabulary at all — it stores one flat entry per document — so making a directory,
|
||||
to check that `refresh` skips one, has to happen outside it.
|
||||
|
||||
---
|
||||
|
||||
## Where a change to `web/*.js` is tested
|
||||
|
||||
**E2E and integration tests are rare and pin boundary contracts; unit tests
|
||||
assume those contracts and test between them.** (User ruling, 2026-09-20.) So
|
||||
the question is not "can this assertion be made cheaper" but "which boundary is
|
||||
it about", and the browser half has four:
|
||||
|
||||
| boundary | the contract | where it is pinned |
|
||||
|---|---|---|
|
||||
| `RenderModel` → DOM | every field the render model projects reaches an element | `web/tests/render_surface.test.js` (statically) **and** `harness/tests/contract.spec.js` (on the screen) |
|
||||
| JS → wasm | the calls the page makes exist, with the arity the bundle declares, and their answers are painted | `web/tests/bundle/wasm_surface.test.js` (the surface) **and** `contract.spec.js`'s pointer test (the wiring) |
|
||||
| page → peer | the handshake goes out first, a frame is answered whole, and a failure is named | `harness/tests/shell.spec.js`, `harness/tests/drill.spec.js` |
|
||||
| worker → OPFS | `storage_contract`, run against a real `OpfsFs` | `harness/tests/opfs.spec.js` |
|
||||
|
||||
Everything **between** those seams is `node --test web/tests`, and it may assume
|
||||
the boundary holds rather than re-establishing it: transport selection from
|
||||
`?ws=`, the two facts `#status` composes, the watchdog's arm and disarm, the
|
||||
`frameCost`/extent arithmetic, the collapse glyph, the grid's plan, and the
|
||||
worker's tail-promise ordering. None of it needs a DOM, so none of it is
|
||||
Playwright's.
|
||||
|
||||
**The seam that makes this possible is `web/shell.js` and `web/serial.js`**:
|
||||
pure modules that **return a description** rather than take a DOM double.
|
||||
`gridPlan(model)` answers every question `gridTable` used to answer while it was
|
||||
creating elements — which row index a click carries when group headers are
|
||||
interleaved, what a group header spans, whether a `null` group label is a name,
|
||||
whether an empty edit buffer means an editor is open — and `app.js` is then a
|
||||
loop that creates elements from it. Painting stays untested by Node; the
|
||||
decision about *what* to paint stopped being untestable.
|
||||
|
||||
### Why the four widgets were one bug
|
||||
|
||||
The tile bar, the view list, the formula panel and the category sidebar
|
||||
ignoring `category_panel_open` were reported as four broken features and fixed
|
||||
as four. They were **one failure at one boundary**: a `RenderModel` field with
|
||||
no element painting it. Four per-feature specs cost four page loads and catch
|
||||
the fifth only if somebody writes a fifth spec; one assertion that *every*
|
||||
projected field is painted catches it by construction. That assertion is cheap
|
||||
enough to make twice, from different sides:
|
||||
|
||||
- **`render_surface.test.js`** reads the `pub` fields off
|
||||
`improvise-wasm-client/src/render.rs` and requires each one to be read by
|
||||
`app.js` or `shell.js` — or named in `UNPAINTED` **with a reason**. It needs
|
||||
no browser, no bundle and no build, so it fails in seconds. The list is not
|
||||
an escape hatch: it is the debt, written down, and it currently holds
|
||||
`doc_name`/`doc_dirty`/`doc_stored_as` (improvise-5jna — the fifth instance,
|
||||
found by writing this test).
|
||||
- **`contract.spec.js`** drives the demo document into each state that fills a
|
||||
widget and asserts the element is *on the screen*. Node cannot see that, and
|
||||
the moment a reduction would have to weaken it, it stays here.
|
||||
|
||||
What is deliberately **not** in Playwright any more is what each key or click
|
||||
*means*: `improvise_protocol::resolve` decides that and its tests are where the
|
||||
meaning is pinned. One instance of "a resolved change repaints" is kept, because
|
||||
the repaint is the boundary; the other seven were the same instance again.
|
||||
|
||||
### Two numbers, and neither is the other
|
||||
|
||||
Measured on one machine at `1af51c9` versus this branch, both on a private
|
||||
`IMPROVISE_HARNESS_PORT`:
|
||||
|
||||
| suite | tests | execution | whole job |
|
||||
|---|---|---|---|
|
||||
| Playwright, before | 29 (+3 skipped) | 36.9s | ~4m30s in CI |
|
||||
| Playwright, after | 16 (+3 skipped) | 15.7s | ~4m30s in CI |
|
||||
| `node --test web/tests` | 26 | 0.11s | seconds |
|
||||
|
||||
**Cutting Playwright tests barely moves the CI number**, because roughly four
|
||||
minutes of that job is three `wasm-pack` builds, `npm ci` and a browser
|
||||
download — not test execution. The feedback-time win is the `js` job, which
|
||||
needs **no Rust toolchain and no wasm bundle at all**, and that is a constraint
|
||||
rather than an accident: the moment a `web/tests/*.test.js` file needs
|
||||
`web/pkg/`, it inherits the four-minute build and the argument for it
|
||||
evaporates. That is why the one test that does need the bundle lives in
|
||||
`web/tests/bundle/` and runs in the browser job instead.
|
||||
|
||||
@@ -862,6 +862,18 @@ at all would pass by moving neither.
|
||||
|
||||
#### harness/tests/widgets.spec.js — the file's own scope
|
||||
|
||||
**Both files were deleted by PR #61**, which moved the pointer's and the
|
||||
widgets' browser halves into `harness/tests/contract.spec.js` and the rest into
|
||||
`node --test web/tests`. What follows is the history of how these assertions
|
||||
were arrived at; the assertions themselves now live there.
|
||||
|
||||
**The file is gone; its subject is not.** All four widgets it named were one
|
||||
failure at one boundary — a `RenderModel` field with no element painting it —
|
||||
and that is now asserted once, from both sides, by
|
||||
`web/tests/render_surface.test.js` and `harness/tests/contract.spec.js`. What
|
||||
is *not* carried over is each key's meaning, which `improvise_protocol::resolve`
|
||||
decides and tests. Everything below is the history of the bug, and still true.
|
||||
|
||||
A user reported three things as broken: "I can't expand categories, and the
|
||||
Tile + View UIs don't work." None of the three was a regression. Driving the
|
||||
assembled site under Chromium separated them, and the separation is the point:
|
||||
@@ -1047,6 +1059,18 @@ this one is only meaningful *because* its neighbours assert non-empty answers.
|
||||
|
||||
#### harness/tests/pointer.spec.js — the file's own scope
|
||||
|
||||
**Both files were deleted by PR #61**, which moved the pointer's and the
|
||||
widgets' browser halves into `harness/tests/contract.spec.js` and the rest into
|
||||
`node --test web/tests`. What follows is the history of how these assertions
|
||||
were arrived at; the assertions themselves now live there.
|
||||
|
||||
**The file is gone; one test carries it.** `contract.spec.js`'s "each clickable
|
||||
surface is wired to its own index" keeps what only a browser can say — the
|
||||
handler is on the element, it passes *that* element's index, and the answer is
|
||||
painted — against three surfaces. What each click *means* is
|
||||
`improvise_protocol::resolve`'s, and its tests are named below. The six tests'
|
||||
injection stands as recorded.
|
||||
|
||||
The user's ruling on improvise-7ybz ("build it — one entry point per widget")
|
||||
and improvise-wsaw ("honour the flag"), executed in a real browser.
|
||||
|
||||
@@ -1221,16 +1245,23 @@ adds an `Enter` binding is not the person reading `resolve.rs`'s click arms. The
|
||||
positive half (`ViewPanel` *does* bind `Enter`) is there so the test cannot pass
|
||||
by asserting that no panel binds anything.
|
||||
|
||||
#### harness/tests/pointer.spec.js — the two panel tests added for improvise-iioi
|
||||
#### The view and formula row clicks in the browser (improvise-iioi)
|
||||
|
||||
The browser half. Both press `Escape` after opening the panel, leaving it **open
|
||||
but unfocused** — the only state in which the *focus* third of the click is
|
||||
observable, exactly as `openPanelThenLeave` does for categories.
|
||||
These began as two tests in `harness/tests/pointer.spec.js`. **That file no
|
||||
longer exists**: PR #61 deleted it and moved the pointer's browser half into
|
||||
`harness/tests/contract.spec.js`, whose stated scope is the wiring — the handler
|
||||
is on the element and passes *that* element's index — while what a click MEANS
|
||||
is `improvise_protocol::resolve`'s and tested there. The assertions were merged
|
||||
into that file's own click test rather than restored, because resurrecting a
|
||||
file a merge deleted is how a deliberate removal gets undone by reflex.
|
||||
|
||||
They press `Escape` after opening the panel, leaving it **open but unfocused**
|
||||
— the only state in which the *focus* third of the click is observable.
|
||||
|
||||
Proved against the bundle built immediately before the fix: both fail there (the
|
||||
view row does not switch, the formula row does not select) while the six
|
||||
pre-existing pointer tests pass, so the failure is specific to the two panels
|
||||
rather than to the harness.
|
||||
view row does not switch, the formula row does not select) while the
|
||||
pre-existing pointer assertions pass, so the failure is specific to the two
|
||||
panels rather than to the harness.
|
||||
|
||||
What the view test asserts is deliberately three things — `#view-name`, the
|
||||
`active` marker and the `selected` cursor — because switching ends in `NORMAL`
|
||||
@@ -1299,6 +1330,48 @@ cycle" fails only that last one. "`already_selected` reads the index alone"
|
||||
fails only the tile-zero test. Reverting hkr8 fails only the hkr8 test. No
|
||||
injection passes for another's reason.
|
||||
|
||||
### t_then_a_click_on_the_leftmost_tile_cycles_it_immediately
|
||||
|
||||
Found on review, and it is a defect in the *wording* rather than the code. `T`
|
||||
enters `TileSelect` leaving `tile_cat_idx` at **0**, so a click on the leftmost
|
||||
tile is a click on the already-selected one and cycles on the **first** gesture.
|
||||
Both loose summaries of the ruling — "a click never mutates on its own" and "a
|
||||
**second** click cycles" — are false against that probe.
|
||||
|
||||
The behaviour is right: a click does what `Enter` does there, and `Enter` in
|
||||
`TileSelect` cycles the tile under the cursor. The resolver cannot see how the
|
||||
state arose and must not try to. So the test exists to **name** the case rather
|
||||
than to change it, and it asserts the neighbouring tile answers the identical
|
||||
gesture by selecting — because the surprising half is that two adjacent tiles
|
||||
differ, and a reader who meets that in the wild should find it written down.
|
||||
|
||||
Note what this means for its sibling `a_second_click_on_the_selected_tile_cycles_its_axis`:
|
||||
that test sets the state directly rather than by clicking, so it **already was**
|
||||
the keyboard-entry case. Nothing was untested; the name and the argument were
|
||||
missing.
|
||||
|
||||
### enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream
|
||||
|
||||
`switch_view_at` returns `Option`, and its two callers answer the `None`
|
||||
differently on purpose. **From a `click_*`, `None` means "this gesture composed
|
||||
nothing"** — a dynamic fact, and hkr8's rule. **From `step`, `None` means "this
|
||||
client cannot execute this command *name*"** — a *static* fact about the
|
||||
vocabulary, which `the_command_names_that_still_need_the_host_are_these` exists
|
||||
to keep static. Two questions sharing one type, which is why the asymmetry reads
|
||||
wrong at first. (`delete_view_at_cursor` already answered `Some(Vec::new())` the
|
||||
same way, so this follows the file rather than inventing.)
|
||||
|
||||
Nothing held the keyboard side to it until this test. A reviewer removed the
|
||||
`unwrap_or_default()` so out-of-range propagates `None` — making `Enter` in an
|
||||
empty view panel `KeyOutcome::Upstream`, gotcha 47's black hole — and **the whole
|
||||
workspace passed under `--no-fail-fast`**. That would make membership of the
|
||||
host-needed list state-dependent, the one property that list denies. Injecting
|
||||
it now fails this test and only this test.
|
||||
|
||||
The assertion is `Ignored`, not `Local(vec![])`: `KeyOutcome::of` maps an empty
|
||||
vec to `Ignored` by construction, so `Local` is never empty. What matters is
|
||||
that it is not `Upstream`.
|
||||
|
||||
### the_first_click_on_tile_zero_selects_it_like_any_other
|
||||
|
||||
The trap in the ruling's own wording. "A second click on the already-selected
|
||||
@@ -1334,13 +1407,22 @@ spells that precondition. `with_categories()` is used by tests about a click
|
||||
*opening* the panel, so it now shuts it explicitly. Neither is the decision
|
||||
being re-made — the decision is re-made only in the harness, deliberately.
|
||||
|
||||
#### harness/tests/pointer.spec.js — the launch state and the tile ruling
|
||||
#### The launch state and the tile ruling in the browser
|
||||
|
||||
Also merged into `harness/tests/contract.spec.js` rather than left in the
|
||||
deleted `pointer.spec.js` — see the note above.
|
||||
|
||||
Two states, one mechanism: the sidebar is visible with no keypress, **and** `C`
|
||||
still hides it. Either alone passes against the bug wsaw fixed. Proved by
|
||||
injection in a real browser: setting `el.categories.hidden = false` (the page
|
||||
ignoring the flag) fails on `toBeHidden`; setting the shared default back to
|
||||
`false` fails on `toBeVisible`. Different signatures, different halves.
|
||||
still hides it. Proved by injection in a real browser: setting
|
||||
`el.categories.hidden = false` (the page ignoring the flag) fails on
|
||||
`toBeHidden`; setting the shared default back to `false` fails on `toBeVisible`.
|
||||
Different signatures, different halves.
|
||||
|
||||
**Only the second is a guard, and an earlier wording here got that wrong.** It
|
||||
claimed "either alone passes against the bug wsaw fixed" — but under the new
|
||||
default, visible-at-launch passes for correct code *and* for a sidebar drawn
|
||||
unconditionally. The launch assertion states the **ruling**; the `toBeHidden`
|
||||
after the toggle is the only thing that still catches wsaw.
|
||||
|
||||
Note the first `C` now **focuses** rather than opens, since the panel is already
|
||||
open — `toggle-panel-and-focus` closes only when the panel is both open and
|
||||
@@ -1351,3 +1433,64 @@ asserted the behaviour the ruling reverses; its failure against the new code was
|
||||
itself the injection. The first-click test waits out a timeout before asserting
|
||||
the axis did not move, since a cycle would arrive asynchronously and an
|
||||
instantaneous assertion would pass against a slow one.
|
||||
---
|
||||
|
||||
## `web/tests/` — the Node suite, and what was injected into it
|
||||
|
||||
No `###` headings here on purpose: `--check-evidence` resolves a heading to a
|
||||
Rust `fn`, and a JavaScript test name under one would read as an orphan. These
|
||||
are keyed by file.
|
||||
|
||||
Every test below was proved able to fail by patching the code it names,
|
||||
checking the patch was on disk, running the suite and reading the failure —
|
||||
never by the absence of an error. Thirteen injections, thirteen failures:
|
||||
|
||||
**`shell.test.js`** — `socketUrl` stops upgrading `ws://` on an https page;
|
||||
`composeStatus` returns the model fact alone (which fails five tests, because
|
||||
the watchdog's messages are shell facts and vanish with it); `fail()` stops
|
||||
clearing the timer, so the watchdog *pre-empts* rather than being disarmed —
|
||||
the bug PR #13 fixed and PR #21 half-lost; `extentOf` stops subtracting the
|
||||
frame; `frameCostOf` stops counting the column-group line.
|
||||
|
||||
**`grid_plan.test.js`** — the data-row index is taken from the row's position
|
||||
in `rows`, so a group header consumes one (the click coordinate bug gotcha 1
|
||||
is about); the group label is tested for truthiness, so a group legitimately
|
||||
named `""` draws as "in no group"; `typing` is `Boolean(model.editing)`, so an
|
||||
empty edit buffer reads as no editor open.
|
||||
|
||||
**`serial.test.js`** — the chain is replaced by `Promise.resolve().then(...)`,
|
||||
which is what an `async` listener does: the slow first frame is overtaken. The
|
||||
suite also keeps that fault as a *passing* test in its own right, asserting the
|
||||
unchained pair really does come back swapped, so the ordering test cannot be
|
||||
green for want of a race.
|
||||
|
||||
**`render_surface.test.js`** — a `pub sparkline: String` added to `RenderModel`
|
||||
is reported unpainted; deleting `paintTiles` reports `tiles` unpainted; a
|
||||
`getElementById` that names an id `index.html` has not got is reported. The
|
||||
first is the shape of the bug the four widgets shared, and the list it checks
|
||||
against currently names `doc_name`/`doc_dirty`/`doc_stored_as` — improvise-5jna,
|
||||
which this test found rather than remembered.
|
||||
|
||||
**`bundle/wasm_surface.test.js`** — `client.render_model()` renamed to
|
||||
`client.render_modal()` fails both its tests. It reads the generated `.d.ts`, so
|
||||
a missing bundle is an assertion failure and never a skip.
|
||||
|
||||
**`web/assemble.sh`** — dropping `web/shell.js` from the copy list, with the
|
||||
page's own imports as the check. The first version of that guard anchored its
|
||||
`sed` at `^import`, so it could not see the **multi-line** import that is the
|
||||
only one it exists for, and the injection passed: the guard was written, landed,
|
||||
and blind. That is what the injection was for.
|
||||
|
||||
## `harness/tests/contract.spec.js` — eight injections into the served page
|
||||
|
||||
Patched in `harness/serve/app/app.js`, which is what the browser loads, so no
|
||||
rebuild stands between the fault and the run. The sidebar ignoring
|
||||
`category_panel_open`; the tile bar, view panel, formula panel and help overlay
|
||||
each never drawn; `click_cell` sending a constant row; `click_category` sending
|
||||
a constant index; and the typed buffer drawn without its caret. All eight fail.
|
||||
|
||||
The last one did **not** fail at first: the test asserted the `typing` class and
|
||||
not the text, so a cell drawn blank passed. The assertion was strengthened until
|
||||
the injection failed, which is the only reason it is worth having — and it is
|
||||
the same lesson as the eleven tests above: a class is a flag, and a flag is not
|
||||
a pixel.
|
||||
|
||||
@@ -1208,6 +1208,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Entering `TileSelect` by keyboard leaves `tile_cat_idx` at 0, so a click
|
||||
/// on the leftmost tile cycles on the FIRST gesture. See clients.md.
|
||||
#[test]
|
||||
fn t_then_a_click_on_the_leftmost_tile_cycles_it_immediately() {
|
||||
let mut state = with_tiles();
|
||||
state.view.mode = AppMode::TileSelect;
|
||||
assert_eq!(state.view.tile_cat_idx, 0, "what `T` leaves behind");
|
||||
let commands = click(&state, Click::Tile(0));
|
||||
assert!(
|
||||
commands.iter().any(|c| matches!(c, Command::Model { .. })),
|
||||
"the tile really is selected, and a click does what Enter does there"
|
||||
);
|
||||
assert!(
|
||||
!click(&state, Click::Tile(1))
|
||||
.iter()
|
||||
.any(|c| matches!(c, Command::Model { .. })),
|
||||
"its neighbour answers the identical gesture by selecting — deliberate"
|
||||
);
|
||||
}
|
||||
|
||||
/// The second click is on the **already-selected** tile, not merely the
|
||||
/// second click of the session — otherwise the guarantee holds once.
|
||||
#[test]
|
||||
@@ -1244,6 +1264,20 @@ mod tests {
|
||||
assert!(!commands.iter().any(|c| matches!(c, Command::Model { .. })));
|
||||
}
|
||||
|
||||
/// `None` from a `click_*` means "this gesture composed nothing"; `None`
|
||||
/// from `step` means "not this client's command". See clients.md.
|
||||
#[test]
|
||||
fn enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream() {
|
||||
let state = in_mode(AppMode::ViewPanel);
|
||||
assert!(state.views.views.is_empty(), "no views to switch to");
|
||||
assert_eq!(
|
||||
resolve(&state, ev(KeyCode::Enter)),
|
||||
KeyOutcome::Ignored,
|
||||
"an empty panel is a state, not a command the host must answer — \
|
||||
Upstream here would make the host-needed list state-dependent"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-hkr8: a click is all of its intents or none of them. With no
|
||||
/// published basis the activation cannot be composed, so nothing is.
|
||||
#[test]
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
// Chromium + Firefox run the OPFS battery; WebKit has no OPFS at all and is
|
||||
// kept for the day it does. The shell spec is Chromium-only: clients.md.
|
||||
// kept for the day it does. Every other spec is Chromium-only: clients.md.
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// The RenderModel→DOM boundary and the pointer's half of the JS→wasm one: the
|
||||
// two contracts only a browser can establish. Why these and not more, and what
|
||||
// moved to `node --test web/tests`: context/code/clients.md.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
async function openApp(page) {
|
||||
await page.goto("/app/index.html");
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator("#grid").focus();
|
||||
}
|
||||
|
||||
test.describe("every field the render model projects reaches the screen", () => {
|
||||
// The four widgets a user reported as "not working" — the tile bar, the view
|
||||
// list, the formula panel and the category sidebar ignoring its flag — were
|
||||
// ONE failure at this boundary: a projected field with no element painting
|
||||
// it. This is that contract, and it is what catches the fifth.
|
||||
test("the widgets the demo document fills are drawn, in the state that fills them", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page);
|
||||
|
||||
// `rows`, `col_levels`, `row_levels`: the grid itself. web/demo.improv is
|
||||
// Product on row, _Measure on column (gotcha 46).
|
||||
await expect(page.locator("#grid thead th", { hasText: "Revenue" })).toBeVisible();
|
||||
await expect(page.locator("#grid tbody th").first()).toHaveText("Espresso");
|
||||
await expect(page.locator("#grid tbody td").first()).not.toHaveText("");
|
||||
|
||||
// `mode`, `hint`, `view_name`, `status`: the header's four facts.
|
||||
await expect(page.locator("#mode")).toHaveText("NORMAL");
|
||||
await expect(page.locator("#hint")).not.toHaveText("");
|
||||
await expect(page.locator("#view-name")).toHaveText("Plan");
|
||||
await expect(page.locator("#status")).toContainText("in-page worker");
|
||||
|
||||
// `tiles`: drawn on every frame, as the TUI draws them. The axis label is
|
||||
// `Axis::short_label` — "Pag", and "·" for None, an answer not a stub.
|
||||
await expect(page.locator("#tiles .tile")).toHaveCount(5);
|
||||
await expect(page.locator("#tiles .tile").filter({ hasText: "Product" })).toHaveText(
|
||||
"Product Row",
|
||||
);
|
||||
|
||||
// `categories` + `category_panel_open`: the flag is the client's answer,
|
||||
// and the sidebar was drawn always until it read it (improvise-wsaw).
|
||||
await expect(page.locator("#categories")).toBeVisible();
|
||||
await expect(page.locator("#categories li").first()).toBeVisible();
|
||||
// Open at launch by the shared default, so the first press only FOCUSES:
|
||||
// `toggle-panel-and-focus` closes only when open AND focused.
|
||||
await page.keyboard.press("Shift+C");
|
||||
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
||||
await expect(page.locator("#categories")).toBeVisible();
|
||||
// This is the assertion that catches wsaw — a sidebar drawn unconditionally
|
||||
// passes the launch one. Visible-at-launch is the ruling, not the guard.
|
||||
await page.keyboard.press("Shift+C");
|
||||
await expect(page.locator("#categories")).toBeHidden();
|
||||
await page.keyboard.press("Shift+C");
|
||||
await expect(page.locator("#categories")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// `views`: empty unless open, so the page asks no question about the mode.
|
||||
await expect(page.locator("#views")).toBeHidden();
|
||||
await page.keyboard.press("Shift+V");
|
||||
await expect(page.locator("#views li")).toHaveCount(2);
|
||||
await expect(page.locator("#views li.active")).toContainText("Plan");
|
||||
// The axis line is the shared `view_axis_summary`; its absence would mean
|
||||
// a second derivation had crept in.
|
||||
await expect(page.locator("#views li").nth(0).locator(".axes")).toHaveText(
|
||||
"R:Product C:Quarter",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// `formulas`: the third of `Panel::ALL`, on the same terms.
|
||||
await page.keyboard.press("Shift+F");
|
||||
await expect(page.locator("#formulas li")).toHaveText(["Profit = Revenue - Cost"]);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// `help`, `help_page`, `help_titles`: the rows and their keys are
|
||||
// `improvise_protocol::help`'s, the same ones the TUI draws.
|
||||
await expect(page.locator("#help")).toBeHidden();
|
||||
await page.keyboard.press("?");
|
||||
await expect(page.locator(".help-tab.active")).toHaveText("Welcome");
|
||||
await expect(page.locator(".help-body dt").first()).toBeVisible();
|
||||
await page.keyboard.press("l");
|
||||
await expect(page.locator(".help-tab.active")).toHaveText("Navigation");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("#help")).toBeHidden();
|
||||
|
||||
// `editing`: what has been typed, drawn into the selected cell with a
|
||||
// caret. The decision that an editor is open is made in Rust.
|
||||
await page.keyboard.press("i");
|
||||
await expect(page.locator("#grid td.typing")).toHaveCount(1);
|
||||
// The class alone would pass against a cell drawn blank: what is on the
|
||||
// screen is the buffer AND the caret.
|
||||
await expect(page.locator("#grid td.typing")).toContainText("▏");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("#grid td.typing")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// The other half of the same contract: a change the client resolved must
|
||||
// reach the pixels, not merely the cache. One instance is enough — what
|
||||
// each key MEANS is `improvise_protocol::resolve`'s, and tested there.
|
||||
test("a resolved change repaints the widget it moved and the grid with it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page);
|
||||
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
|
||||
const rowHeaders = page.locator("#grid tbody th");
|
||||
await expect(rowHeaders).toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]);
|
||||
|
||||
await page.keyboard.press("Shift+T");
|
||||
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowRight");
|
||||
await expect(product).toHaveClass(/selected/);
|
||||
await page.keyboard.press("c");
|
||||
|
||||
await expect(product).toHaveText("Product Col", { timeout: 10_000 });
|
||||
// The label alone would be the bar reporting an axis the document has not
|
||||
// got: the grid must have moved with it.
|
||||
await expect(
|
||||
page.locator("#grid thead th").filter({ hasText: "Espresso" }).first(),
|
||||
).toBeVisible();
|
||||
await expect(rowHeaders).not.toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("a pointer event reaches the client and its answer is painted", () => {
|
||||
// improvise-7ybz. What a click MEANS is resolved in Rust and tested in
|
||||
// `improvise_protocol::resolve`; what this pins is the wiring — the handler
|
||||
// is on the element, it passes THAT element's index, and the result repaints.
|
||||
test("each clickable surface is wired to its own index", async ({ page }) => {
|
||||
await openApp(page);
|
||||
|
||||
// A grid cell: the coordinate is the data row, which group headers do not
|
||||
// consume — `gridPlan` counts it and `web/tests/grid_plan.test.js` pins it.
|
||||
const cells = page.locator("#grid tbody td");
|
||||
await expect(cells.nth(0)).toHaveClass(/selected/);
|
||||
await cells.nth(4).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(cells.nth(4)).toHaveClass(/selected/);
|
||||
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
|
||||
// The cursor a click leaves is the one the keyboard moves from: one
|
||||
// cursor, two input paths.
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await expect(cells.nth(3)).toHaveClass(/selected/);
|
||||
|
||||
// A tile: the first click SELECTS and does not touch the document; the
|
||||
// second, on the same tile, cycles. The user's ruling — clients.md.
|
||||
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
|
||||
await product.click();
|
||||
await expect(page.locator("#mode")).toHaveText("TILES");
|
||||
await expect(product).toHaveClass(/selected/);
|
||||
// Waited out, not asserted instantly: a cycle would arrive asynchronously.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(product).toHaveText("Product Row");
|
||||
await product.click();
|
||||
await expect(product).toHaveText("Product Col", { timeout: 10_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// A category row: focuses the panel, moves its cursor and activates the
|
||||
// row — three things from one click, and the index must be the row's own.
|
||||
await expect(page.locator("#categories")).toBeVisible();
|
||||
const productRow = page.locator("#categories li").filter({ hasText: "Product" });
|
||||
await expect(page.locator("#categories li.selected")).toHaveText(/_Index/);
|
||||
await productRow.click();
|
||||
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
||||
await expect(page.locator("#categories li.selected")).toHaveText(/Product/);
|
||||
await expect(page.locator("#categories li.item")).toHaveText([
|
||||
"Espresso",
|
||||
"Latte",
|
||||
"Cold Brew",
|
||||
"Pastry",
|
||||
]);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// A view row (improvise-iioi): the same three, with `switch-view-at-cursor`
|
||||
// as the activation. The SECOND row, so a click on the row already under
|
||||
// the cursor cannot pass by accident.
|
||||
await page.keyboard.press("Shift+V");
|
||||
await expect(page.locator("#views li.active")).toContainText("Plan");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator("#views li").filter({ hasNotText: "Plan" }).first().click();
|
||||
await expect(page.locator("#view-name")).toHaveText("Default");
|
||||
|
||||
// A formula row: navigation only, because FormulaPanel binds no Enter and
|
||||
// a click mirrors Enter. The Rust guard is on the KEYMAP — clients.md.
|
||||
await page.keyboard.press("Shift+F");
|
||||
await expect(page.locator("#formulas li").first()).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator("#formulas li").first().click();
|
||||
await expect(page.locator("#mode")).toHaveText("FORMULAS");
|
||||
await expect(page.locator("#formulas li.selected")).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -1,233 +0,0 @@
|
||||
// The pointer: improvise-7ybz (a click resolves in Rust) and improvise-wsaw
|
||||
// (the sidebar obeys its flag). History: context/test-evidence/clients.md.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
async function openApp(page) {
|
||||
await page.goto("/app/index.html");
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator("#grid").focus();
|
||||
}
|
||||
|
||||
/// Leave the panel open and the keyboard in Normal — so a later click has a
|
||||
/// focus change to make. It opens at launch, so `C` here only focuses it.
|
||||
async function openPanelThenLeave(page) {
|
||||
await expect(page.locator("#categories")).toBeVisible();
|
||||
await page.keyboard.press("Shift+C");
|
||||
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("#mode")).toHaveText("NORMAL");
|
||||
await expect(page.locator("#categories")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("the category sidebar obeys category_panel_open", () => {
|
||||
// improvise-wsaw. The flag existed from the start and app.js never read it,
|
||||
// so the sidebar was drawn always and `C` changed only the mode label.
|
||||
// The launch state is the user's later ruling; the toggle is wsaw's. Both
|
||||
// are asserted, because either alone passes against the bug wsaw fixed.
|
||||
test("the sidebar is open at launch, C focuses it, and C again hides it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page);
|
||||
await expect(page.locator("#categories")).toBeVisible();
|
||||
await expect(page.locator("#categories li").first()).toBeVisible();
|
||||
await expect(page.locator("#mode")).toHaveText("NORMAL");
|
||||
|
||||
// Open but not focused, so the first press focuses rather than closes.
|
||||
await page.keyboard.press("Shift+C");
|
||||
await expect(page.locator("#categories")).toBeVisible();
|
||||
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
||||
|
||||
// And now it closes — which is the half that proves the page reads the
|
||||
// flag rather than drawing the sidebar unconditionally.
|
||||
await page.keyboard.press("Shift+C");
|
||||
await expect(page.locator("#categories")).toBeHidden();
|
||||
await expect(page.locator("#mode")).toHaveText("NORMAL");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("clicking the category panel", () => {
|
||||
// improvise-7ybz. One click means three things — focus the panel, move its
|
||||
// cursor, activate the row — and all three are resolved in Rust.
|
||||
test("a click focuses the panel, moves the cursor and expands the row", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page);
|
||||
await openPanelThenLeave(page);
|
||||
|
||||
const product = page.locator("#categories li").filter({ hasText: "Product" });
|
||||
await expect(product).toHaveText(/▶ Product \(4\)/);
|
||||
await expect(page.locator("#categories li.selected")).toHaveText(/_Index/);
|
||||
|
||||
await product.click();
|
||||
|
||||
// All three, and the third is what the user reported as impossible.
|
||||
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
||||
await expect(page.locator("#categories li.selected")).toHaveText(/Product/);
|
||||
await expect(product).toHaveText(/▼ Product \(4\)/);
|
||||
await expect(page.locator("#categories li.item")).toHaveText([
|
||||
"Espresso",
|
||||
"Latte",
|
||||
"Cold Brew",
|
||||
"Pastry",
|
||||
]);
|
||||
|
||||
// Clicking it again collapses: the click mirrors Enter, which is a toggle.
|
||||
await product.click();
|
||||
await expect(page.locator("#categories li.item")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// The other arm of `filter-to-item`: on an ITEM row the same click filters,
|
||||
// because the rule is "do what Enter does here" rather than "expand".
|
||||
test("a click on an item row filters, as Enter does", async ({ page }) => {
|
||||
await openApp(page);
|
||||
await openPanelThenLeave(page);
|
||||
|
||||
await page.locator("#categories li").filter({ hasText: "Product" }).click();
|
||||
await page.locator("#categories li.item").filter({ hasText: "Latte" }).click();
|
||||
|
||||
await expect(page.locator("#status")).toContainText("Filter: Product = Latte");
|
||||
await expect(page.locator("#categories li.selected")).toHaveText("Latte");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("clicking a tile", () => {
|
||||
// The user's ruling: a click SELECTS, a second click CYCLES. Every click in
|
||||
// the UI is non-mutating by default; the cost is two for the common action.
|
||||
test("the first click selects the tile and changes nothing", async ({ page }) => {
|
||||
await openApp(page);
|
||||
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
|
||||
await expect(product).toHaveText("Product Row");
|
||||
|
||||
await product.click();
|
||||
|
||||
// The positive half: something did happen, so a green test is not the
|
||||
// dead-page shape. `Product` is selected and the mode moved.
|
||||
await expect(page.locator("#mode")).toHaveText("TILES");
|
||||
await expect(product).toHaveClass(/selected/);
|
||||
|
||||
// And the negative half: the document did not move. Waited out rather
|
||||
// than asserted instantly, since a cycle would arrive asynchronously.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(product).toHaveText("Product Row");
|
||||
await expect(
|
||||
page.locator("#grid thead th").filter({ hasText: "Espresso" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a second click on the selected tile cycles its axis", async ({ page }) => {
|
||||
await openApp(page);
|
||||
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
|
||||
await product.click();
|
||||
await expect(product).toHaveClass(/selected/);
|
||||
|
||||
await product.click();
|
||||
|
||||
// Row → Column is `Axis::next`; the page does not spell the rotation.
|
||||
await expect(product).toHaveText("Product Col", { timeout: 10_000 });
|
||||
// And the grid moved with it, not just the label.
|
||||
await expect(
|
||||
page.locator("#grid thead th").filter({ hasText: "Espresso" }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking a different tile selects it rather than cycling it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page);
|
||||
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
|
||||
const quarter = page.locator("#tiles .tile").filter({ hasText: "Quarter" });
|
||||
await product.click();
|
||||
await expect(product).toHaveClass(/selected/);
|
||||
|
||||
await quarter.click();
|
||||
|
||||
// The guarantee has to hold for every click, not only a session's first.
|
||||
await expect(quarter).toHaveClass(/selected/);
|
||||
await page.waitForTimeout(500);
|
||||
await expect(quarter).toHaveText("Quarter Pag");
|
||||
await expect(product).toHaveText("Product Row");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("clicking a grid cell", () => {
|
||||
// Before improvise-7ybz a click reached only `edit()`, so the selection —
|
||||
// which every keyboard command reads — stayed wherever it had been.
|
||||
test("a click moves the selection, not only the editor", async ({ page }) => {
|
||||
await openApp(page);
|
||||
const cells = page.locator("#grid tbody td");
|
||||
// The cursor starts at the origin, so any other cell is a real move.
|
||||
await expect(cells.nth(0)).toHaveClass(/selected/);
|
||||
|
||||
await cells.nth(4).click();
|
||||
// demo.improv is all Records, so the click also opened an editor; close it
|
||||
// without committing and the selection must have followed the pointer.
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(cells.nth(4)).toHaveClass(/selected/);
|
||||
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
|
||||
});
|
||||
|
||||
// The selection a click leaves is the one the keyboard then moves from —
|
||||
// the property that makes the two input paths one cursor rather than two.
|
||||
test("the keyboard continues from where the pointer left the cursor", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page);
|
||||
const cells = page.locator("#grid tbody td");
|
||||
await cells.nth(4).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(cells.nth(4)).toHaveClass(/selected/);
|
||||
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await expect(cells.nth(3)).toHaveClass(/selected/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("clicking the other two panels", () => {
|
||||
// improvise-iioi. The category panel got a listener and its two siblings did
|
||||
// not, so a user who had just learned rows are clickable clicked a view row
|
||||
// and read the silence as broken — which IS the report behind improvise-7ybz.
|
||||
test("a click on a view row focuses, moves the cursor and switches", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page);
|
||||
await page.keyboard.press("Shift+V");
|
||||
await expect(page.locator("#views")).toBeVisible();
|
||||
await expect(page.locator("#view-name")).toHaveText("Plan");
|
||||
await expect(page.locator("#views li.active")).toContainText("Plan");
|
||||
// Leave the panel open but unfocused, so the focus third is observable.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("#mode")).toHaveText("NORMAL");
|
||||
|
||||
await page.locator("#views li").filter({ hasText: "Default" }).click();
|
||||
|
||||
// Switching ends in Normal — `switch-view-at-cursor` says so — but the
|
||||
// cursor and the active marker must both have moved to the clicked row.
|
||||
await expect(page.locator("#view-name")).toHaveText("Default", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.locator("#views li.active")).toContainText("Default");
|
||||
await expect(page.locator("#views li.selected")).toContainText("Default");
|
||||
});
|
||||
|
||||
// The formula panel binds NO Enter, so the rule yields navigation and
|
||||
// nothing else. That is the rule applying, not the fix stopping half way.
|
||||
test("a click on a formula row moves the cursor and does not edit", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page);
|
||||
await page.keyboard.press("Shift+F");
|
||||
await expect(page.locator("#formulas")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("#mode")).toHaveText("NORMAL");
|
||||
|
||||
await page.locator("#formulas li").first().click();
|
||||
|
||||
// Focused and selected — and still FORMULAS, because there is nothing to
|
||||
// activate. A mode change here would mean an activation was invented.
|
||||
await expect(page.locator("#mode")).toHaveText("FORMULAS");
|
||||
await expect(page.locator("#formulas li.selected")).toHaveCount(1);
|
||||
await expect(page.locator("#grid input")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
+20
-79
@@ -1,29 +1,20 @@
|
||||
// The browser shell: the three assertions improvise-hwpi could not write, plus
|
||||
// the watchdog. The history is in context/test-evidence/clients.md.
|
||||
// The page↔peer boundary: the handshake, a frame answered whole, and what the
|
||||
// page says when a peer fails. What the arm/disarm RULE is, and every other
|
||||
// decision between these seams, is `node --test web/tests` — clients.md.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const status = (page) => page.locator("#status");
|
||||
|
||||
/// A peer that accepts the TCP connection and then refuses the WebSocket
|
||||
/// upgrade — deterministic where a closed port is not (evidence file).
|
||||
const NO_WEBSOCKET_HERE = "127.0.0.1:8099";
|
||||
const json = (v) => JSON.stringify(v);
|
||||
/// upgrade — deterministic where a closed port is not (evidence file). It is
|
||||
/// the harness's OWN server, so it follows the port the config chose.
|
||||
const NO_WEBSOCKET_HERE = `127.0.0.1:${process.env.IMPROVISE_HARNESS_PORT ?? "8099"}`;
|
||||
|
||||
/// `web/app.js`'s own HANDSHAKE_TIMEOUT_MS. Two tests have to outlast it, and a
|
||||
/// `web/app.js`'s own HANDSHAKE_TIMEOUT_MS. One test has to outlast it, and a
|
||||
/// number that silently disagreed with the page would pass for the wrong reason.
|
||||
const HANDSHAKE_TIMEOUT_MS = 8_000;
|
||||
|
||||
/// Record every distinct #status the page shows, not just the last one: several
|
||||
/// of these are about a message being REPLACED.
|
||||
function logStatus() {
|
||||
window.__statusLog = [];
|
||||
new MutationObserver(() => {
|
||||
const text = document.getElementById("status")?.textContent ?? "";
|
||||
if (text && window.__statusLog.at(-1) !== text) window.__statusLog.push(text);
|
||||
}).observe(document, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
|
||||
test.describe("the shell reports what its peer is doing", () => {
|
||||
// improvise-hwpi. The fault is injected at the *peer*, not in the page, so
|
||||
// this exercises the real `on_frame` and the real catch.
|
||||
@@ -35,31 +26,15 @@ test.describe("the shell reports what its peer is doing", () => {
|
||||
await expect(status(page)).toContainText("bad frame", { timeout: 15_000 });
|
||||
});
|
||||
|
||||
// improvise-hwpi, plus the watchdog half on the same page load because it
|
||||
// needs the same nine-second wait. See the evidence file.
|
||||
test("a peer that cannot be reached is named, and the watchdog stays out of it", async ({
|
||||
// A socket that never opened did not "disconnect" (improvise-taxx): nothing
|
||||
// on the close event can discriminate, so the page records `opened` itself.
|
||||
// That it then STAYS is the watchdog's disarming, which is Node's now.
|
||||
test("a peer that cannot be reached is named, and one that dropped is not confused with it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(logStatus);
|
||||
await page.goto(`/app/index.html?ws=${NO_WEBSOCKET_HERE}`);
|
||||
await expect(status(page)).not.toHaveText("", { timeout: 15_000 });
|
||||
await page.waitForTimeout(HANDSHAKE_TIMEOUT_MS + 1_000);
|
||||
await expect(status(page)).toContainText("cannot reach", { timeout: 15_000 });
|
||||
|
||||
const log = await page.evaluate(() => window.__statusLog);
|
||||
expect(log.some((t) => t.includes("cannot reach")), `#status said: ${json(log)}`).toBe(
|
||||
true,
|
||||
);
|
||||
expect(log.some((t) => t.includes("sent no document")), `#status said: ${json(log)}`).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The other arm, which must keep working when the first is fixed. It runs
|
||||
// past HANDSHAKE_TIMEOUT_MS on purpose — see the evidence file.
|
||||
test("a socket that opened and then dropped reads as disconnected, and stays that way", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(logStatus);
|
||||
// Closes on the page's first frame, so "it opened first" is true by
|
||||
// construction rather than by timer.
|
||||
await page.routeWebSocket("ws://127.0.0.1:8790/", (ws) => {
|
||||
@@ -68,28 +43,19 @@ test.describe("the shell reports what its peer is doing", () => {
|
||||
await page.goto("/app/index.html?ws=127.0.0.1:8790");
|
||||
await expect(status(page)).toContainText("connected", { timeout: 15_000 });
|
||||
await expect(status(page)).toContainText("disconnected", { timeout: 15_000 });
|
||||
|
||||
// Outlast the watchdog it was armed against, with margin.
|
||||
await page.waitForTimeout(HANDSHAKE_TIMEOUT_MS + 2_000);
|
||||
await expect(status(page)).toContainText("disconnected");
|
||||
|
||||
const log = await page.evaluate(() => window.__statusLog);
|
||||
expect(log.some((t) => t.includes("sent no document")), `#status said: ${json(log)}`).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Two bugs this file once described as live, one of which never existed in
|
||||
// the merged tree: context/test-evidence/clients.md keeps that history.
|
||||
|
||||
// The first-frame watchdog, found by reading: no per-transport check would
|
||||
// catch it. The mock accepts the connection and never answers.
|
||||
// The one assertion that needs the real timer: that the watchdog is ARMED
|
||||
// after the handshake goes out. What it then says, and that a loud failure
|
||||
// disarms it, are unit tests. The mock accepts and never answers.
|
||||
test("a peer that accepts and then says nothing trips the watchdog", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.routeWebSocket("ws://127.0.0.1:8788/", () => {});
|
||||
await page.goto("/app/index.html?ws=127.0.0.1:8788");
|
||||
await expect(status(page)).toContainText("sent no document", { timeout: 20_000 });
|
||||
await expect(status(page)).toContainText("sent no document", {
|
||||
timeout: HANDSHAKE_TIMEOUT_MS + 12_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,13 +66,11 @@ test.describe("the standalone deployment", () => {
|
||||
await page.goto("/app/index.html");
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(status(page)).toContainText("in-page worker");
|
||||
// web/demo.improv: Product on row, _Measure on column (gotcha 46).
|
||||
await expect(page.locator("#grid th", { hasText: "Revenue" })).toBeVisible();
|
||||
await expect(page.locator("#grid td.editable").first()).toBeVisible();
|
||||
});
|
||||
|
||||
// What lands on screen is the HOST's value, not the string typed. What this
|
||||
// deliberately does NOT cover: context/test-evidence/clients.md.
|
||||
// What lands on screen is the HOST's value, not the string typed — the
|
||||
// round trip through a real worker, which no unit test can stand in for.
|
||||
test("committing an edit sends one frame and shows the host's value", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -128,26 +92,3 @@ test.describe("the standalone deployment", () => {
|
||||
await expect(page.locator("#grid input")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the help overlay", () => {
|
||||
// improvise-ktv1. `?` has entered AppMode::Help in the browser since the
|
||||
// keymap was shared; what was missing was anything to draw.
|
||||
test("? opens help, and its rows are the shared ones", async ({ page }) => {
|
||||
await page.goto("/app/index.html");
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator("#help")).toBeHidden();
|
||||
|
||||
await page.locator("#grid").focus();
|
||||
await page.keyboard.press("?");
|
||||
await expect(page.locator("#help")).toBeVisible({ timeout: 10_000 });
|
||||
// A title from improvise_protocol::help::pages(), and a keys row whose
|
||||
// key came off the keymap rather than out of the page.
|
||||
await expect(page.locator(".help-tab.active")).toHaveText("Welcome");
|
||||
await page.keyboard.press("l");
|
||||
await expect(page.locator(".help-tab.active")).toHaveText("Navigation");
|
||||
await expect(page.locator(".help-body dt").first()).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("#help")).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// The three widgets a user reported as "not working": the category panel's
|
||||
// expand, the tile bar and the view list. History: context/test-evidence/clients.md.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/// Open the demo document and put the keyboard where the page listens.
|
||||
async function openApp(page) {
|
||||
await page.goto("/app/index.html");
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator("#grid").focus();
|
||||
}
|
||||
|
||||
test.describe("the category panel", () => {
|
||||
// Expanding is resolved client-side and has always worked by KEYBOARD; the
|
||||
// panel is nevertheless the one of the three that has DOM, so this pins it.
|
||||
test("Enter on a category header reveals its items", async ({ page }) => {
|
||||
await openApp(page);
|
||||
const rows = page.locator("#categories li");
|
||||
|
||||
await page.keyboard.press("Shift+C");
|
||||
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
||||
// web/demo.improv: _Index, _Dim, _Measure, Product, Quarter. Product is
|
||||
// the first with items a user would recognise. The glyphs are
|
||||
// `indicator()`'s, which is the page's ONE spelling and the TUI's too.
|
||||
for (let i = 0; i < 3; i++) await page.keyboard.press("j");
|
||||
await expect(rows.filter({ hasText: "Product" })).toHaveText(/▶ Product \(4\)/);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(rows.filter({ hasText: "Product" })).toHaveText(/▼ Product \(4\)/);
|
||||
await expect(page.locator("#categories li.item")).toHaveText([
|
||||
"Espresso",
|
||||
"Latte",
|
||||
"Cold Brew",
|
||||
"Pastry",
|
||||
]);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#categories li.item")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the tile bar", () => {
|
||||
// Nothing was DRAWN: TileBarCache was folded, `T` entered the mode and the
|
||||
// axis keys reached the host, with no tile bar on screen to aim them at.
|
||||
test("the tiles are drawn, and tile-select moves a visible cursor", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page);
|
||||
const tiles = page.locator("#tiles .tile");
|
||||
await expect(tiles).toHaveCount(5);
|
||||
// Axis labels are `Axis::short_label`: three columns, so Page is "Pag",
|
||||
// and None is "·" — an answer rather than a truncation (gotcha 26).
|
||||
await expect(tiles.filter({ hasText: "Product" })).toHaveText("Product Row");
|
||||
await expect(tiles.filter({ hasText: "Quarter" })).toHaveText("Quarter Pag");
|
||||
await expect(tiles.filter({ hasText: "_Index" })).toHaveText("_Index ·");
|
||||
|
||||
// Out of tile select nothing is highlighted, as the TUI's widget decides.
|
||||
await expect(page.locator("#tiles .tile.selected")).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press("Shift+T");
|
||||
await expect(page.locator("#mode")).toHaveText("TILES");
|
||||
await expect(tiles.nth(0)).toHaveClass(/selected/);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(tiles.nth(1)).toHaveClass(/selected/);
|
||||
await expect(page.locator("#tiles .tile.selected")).toHaveCount(1);
|
||||
});
|
||||
|
||||
// The axis change was reaching the host all along; what it could not do was
|
||||
// show the result. Setting Product to Column must redraw the tile AND the grid.
|
||||
test("setting a tile's axis is visible in the bar it was aimed at", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page);
|
||||
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
|
||||
await expect(product).toHaveText("Product Row");
|
||||
|
||||
await page.keyboard.press("Shift+T");
|
||||
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowRight");
|
||||
await expect(product).toHaveClass(/selected/);
|
||||
// Product's four items are the grid's ROW headers to begin with.
|
||||
const rowHeaders = page.locator("#grid tbody th");
|
||||
await expect(rowHeaders).toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]);
|
||||
|
||||
await page.keyboard.press("c");
|
||||
await expect(product).toHaveText("Product Col", { timeout: 10_000 });
|
||||
// The tile label is not the point on its own: the grid must have moved
|
||||
// too, or the bar would be reporting an axis the document does not have.
|
||||
// `.first()`: Product now shares the column axis with _Measure, so the
|
||||
// label appears once per header level. That it appears at all is the point.
|
||||
await expect(
|
||||
page.locator("#grid thead th").filter({ hasText: "Espresso" }).first(),
|
||||
).toBeVisible();
|
||||
await expect(rowHeaders).not.toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the view panel", () => {
|
||||
// Nothing was DRAWN: ViewListCache held both views and `V` entered ViewPanel
|
||||
// mode, so Enter switched views off a cursor the user could not see.
|
||||
test("V lists the views, marks the active one, and Enter switches", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page);
|
||||
await expect(page.locator("#views")).toBeHidden();
|
||||
await expect(page.locator("#view-name")).toHaveText("Plan");
|
||||
|
||||
await page.keyboard.press("Shift+V");
|
||||
await expect(page.locator("#mode")).toHaveText("VIEWS");
|
||||
await expect(page.locator("#views")).toBeVisible();
|
||||
// web/demo.improv carries two views; the axis line is the shared
|
||||
// `view_axis_summary`, so its absence would be a second derivation.
|
||||
await expect(page.locator("#views li")).toHaveCount(2);
|
||||
await expect(page.locator("#views li").nth(0)).toContainText("Default");
|
||||
await expect(page.locator("#views li").nth(0).locator(".axes")).toHaveText(
|
||||
"R:Product C:Quarter",
|
||||
);
|
||||
await expect(page.locator("#views li.active")).toContainText("Plan");
|
||||
await expect(page.locator("#views li.selected")).toContainText("Default");
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#view-name")).toHaveText("Default", { timeout: 10_000 });
|
||||
// Switching returns to Normal but leaves the panel OPEN, exactly as
|
||||
// `src/draw.rs` keeps drawing it off `view_panel_open` alone. The active
|
||||
// marker is what must move, and it is the fact the list exists to carry.
|
||||
await expect(page.locator("#mode")).toHaveText("NORMAL");
|
||||
await expect(page.locator("#views")).toBeVisible();
|
||||
await expect(page.locator("#views li.active")).toContainText("Default");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the formula panel", () => {
|
||||
// The third of `Panel::ALL`, equally undrawn and fixed by the same mechanism.
|
||||
test("F lists the document's formulas", async ({ page }) => {
|
||||
await openApp(page);
|
||||
await expect(page.locator("#formulas")).toBeHidden();
|
||||
|
||||
await page.keyboard.press("Shift+F");
|
||||
await expect(page.locator("#mode")).toHaveText("FORMULAS");
|
||||
await expect(page.locator("#formulas li")).toHaveText(["Profit = Revenue - Cost"]);
|
||||
await expect(page.locator("#formulas li.selected")).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ crate(gp_tree,'crates/gp-tree','genetic-programming tree fitting; problem-agnost
|
||||
crate(improvise_gp,'crates/improvise-gp','bridge: emits a fitted gp-tree Tree as an improvise formula string and validates it through parse_formula. Exists because gp-tree must stay problem-agnostic and improvise-formula must not drag GP into the wasm bundle.').
|
||||
crate(improvise_ws_server,'crates/improvise-ws-server','the socket host: tokio + tungstenite transport and a Registry of Hosts keyed by DocumentId. Re-exports Host and the frames; defines neither. Native only.').
|
||||
crate(improvise_wasm_client,'crates/improvise-wasm-client','the browser client: the fold, the key classifier, and the render model the DOM paints. Non-dev deps are improvise-protocol + serde + wasm-bindgen and nothing else.').
|
||||
crate(improvise_worker_server,'crates/improvise-worker-server','the other host: the same App, Sessions and Host as the socket server, behind self.onmessage/self.postMessage with OPFS underneath. worker.rs and vfs.rs are ordinary Rust and hold every decision and every test; bindings.rs is the wasm32 shim and is never run.').
|
||||
crate(improvise_worker_server,'crates/improvise-worker-server','the other host: the same App, Sessions and Host as the socket server, behind self.onmessage/self.postMessage with OPFS underneath. worker.rs and vfs.rs are ordinary Rust and hold every decision and every test; bindings.rs is the wasm32 shim, run by harness/tests/shell.spec.js under Chromium and by no Rust test.').
|
||||
layer(model,improvise_core).
|
||||
layer(view,improvise_core).
|
||||
layer(formula,improvise_formula).
|
||||
@@ -102,7 +102,7 @@ invariant(a_cats_change_reidentifies_every_position,'A cells identity is AxisFra
|
||||
invariant(the_header_width_rule_has_one_home,'improvise_protocol::header_level_widths: per level, the widest label plus a gap, clamped (improvise-1125). It lived twice -- inline in src/ui/grid.rs::render_grid and again in ui::viewport::compute_row_header_width -- and a browser sizing its own row header would have been a third. It takes a borrowed iterator rather than an AxisFrame because its other caller holds a GridLayout on the keypress path and building a frame there would clone every row tuple on every key.').
|
||||
invariant(saving_is_a_frame_not_a_command,'ClientFrame::Save (improvise-368h) carries no path -- a client cannot see the hosts storage -- and is a FRAME because as a Command it put a runtime-fallible operation inside a batch ClientFrame::Commands promises is whole-or-nothing, so [Save, SetCell] reported a failed write and applied the edit anyway. It also fails the test every Command passes: it mutates no shared state and no clients slice, so reduce_view has nothing to fold and Checked has no basis to answer for. Refused as NoDocumentPath or SaveFailed to the asker alone; success is silent.').
|
||||
invariant(the_persist_trigger_is_an_observation,'improvise-worker-server persists on model_state.dirty, not on a table of Command variants (touches_document is deleted) -- the same choice as watching cursor_epoch rather than matching CycleAxis, and document_generation rather than matching LoadModel. It is strictly tighter: a refused batch raises nothing, so the "was anything rejected" guard went with it.').
|
||||
invariant(opfs_is_run_but_bindings_is_not,'harness/ (improvise-vvl3/xf1) executes improvise_io::storage::contract::storage_contract against a real OpfsFs in a real dedicated Web Worker under Chromium and Firefox, gated by the browser job in CI; two bugs fell out of the first run. Playwrights WebKit provides no OPFS at all (getDirectory rejects with UnknownError), so xf1s three-engine criterion is met for two of three and the project is kept so it starts the day that build gains OPFS. harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY -- engine-independent logic, and routeWebSocket is slower across three. Never instantiated by any harness: bindings.rs.').
|
||||
invariant(the_harness_runs_the_worker_chain_under_chromium_only,'harness/ (improvise-vvl3/xf1) executes improvise_io::storage::contract::storage_contract against a real OpfsFs in a real dedicated Web Worker under Chromium and Firefox, gated by the browser job in CI; two bugs fell out of the first run. Playwrights WebKit provides no OPFS at all (getDirectory rejects with UnknownError), so xf1s three-engine criterion is met for two of three and the project is kept so it starts the day that build gains OPFS. harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY -- engine-independent logic, and routeWebSocket is slower across three. It also runs BINDINGS.RS: its no-?ws test loads the app, which spawns web/worker.js, which imports the glue, which calls bindings.rs start() and OpfsFs::open(), and the test then asserts a grid draws. This invariant was named opfs_is_run_but_bindings_is_not and asserted the opposite in FIVE places for months (here, the crate/3 fact above, two context/repo-map.md lines and improvise-bck) -- every copy written before the harness landed and none re-read after. A never-run claim in prose is unfalsifiable by anything in this repo: the CI job checks this KB for CONSISTENCY, not truth, and the error UNDERSTATED coverage so nothing failed and nobody looked. What is genuinely unrun, BY ENGINE: Safari/WebKit (no OPFS in Playwrights build) and any non-Chromium engine for the shell spec. That is deliberately scoped to engines rather than written as a closed list of everything unreached, because a closed list is how the previous version of this invariant went wrong in the other direction: two wasm_bindgen exports on WorkerServer, documents() and refresh(), have NO caller in web/*.js or harness/ and so are run by nothing (the .refresh( hits in harness/opfs-contract are OpfsFs::refresh, a different method). The lesson the rename is about applies to its own replacement: a coverage claim in prose is unfalsifiable by anything in this repo, so state what IS covered and scope what is not, rather than enumerating an absence.').
|
||||
doctrine(bug_first,'write a failing test demonstrating the bug before fixing it').
|
||||
doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e').
|
||||
doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a "finished in" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).').
|
||||
|
||||
+61
-117
@@ -2,30 +2,26 @@
|
||||
// string calls, no protocol knowledge. See context/code/clients.md.
|
||||
|
||||
import init, { Client } from "./pkg/improvise_wasm_client.js";
|
||||
import {
|
||||
COL_PX,
|
||||
ROW_PX,
|
||||
createShell,
|
||||
extentOf,
|
||||
frameCostOf,
|
||||
gridPlan,
|
||||
indicator,
|
||||
socketUrl,
|
||||
} from "./shell.js";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
// present => socket, absent => in-page worker. The value is a host:port, and
|
||||
// the scheme is NOT the caller's to get wrong: see `socketUrl`.
|
||||
const WS_PEER = params.get("ws");
|
||||
|
||||
/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit
|
||||
* `ws://` is upgraded rather than honoured: context/code/clients.md. */
|
||||
function socketUrl(peer) {
|
||||
const secure = location.protocol === "https:";
|
||||
if (/^wss:\/\//i.test(peer)) return peer;
|
||||
if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer;
|
||||
return `${secure ? "wss" : "ws"}://${peer}`;
|
||||
}
|
||||
|
||||
const WS_URL = WS_PEER ? socketUrl(WS_PEER) : null;
|
||||
const WS_URL = WS_PEER ? socketUrl(WS_PEER, location.protocol) : null;
|
||||
const DOC = params.get("doc") ?? "demo.improv";
|
||||
const SEED = params.get("seed") ?? "demo.improv";
|
||||
|
||||
// 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"),
|
||||
@@ -42,64 +38,38 @@ const el = {
|
||||
await init();
|
||||
const client = new Client();
|
||||
|
||||
// `#status` carries two independent facts, composed rather than overwritten:
|
||||
// a repaint must not quietly erase a connection error.
|
||||
let shellStatus = "";
|
||||
let modelStatus = "";
|
||||
function paintStatus() {
|
||||
el.status.textContent = [shellStatus, modelStatus].filter(Boolean).join(" · ");
|
||||
}
|
||||
function setShellStatus(text) {
|
||||
shellStatus = text;
|
||||
paintStatus();
|
||||
}
|
||||
// A peer that never answers the handshake, and the two facts `#status` shows.
|
||||
// Both are decided in shell.js; this is the wire to the element.
|
||||
const HANDSHAKE_TIMEOUT_MS = 8000;
|
||||
const shell = createShell({
|
||||
onStatus: (text) => {
|
||||
el.status.textContent = text;
|
||||
},
|
||||
timeoutMs: HANDSHAKE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// What the grid's own frame costs, in whole cells, as of the last repaint —
|
||||
// measured off the painted model, never predicted. context/code/clients.md.
|
||||
let frameCost = { rows: 1, cols: 1 };
|
||||
|
||||
/** The extent this window can show, in whole DATA cells. */
|
||||
function extent() {
|
||||
const box = el.grid.getBoundingClientRect();
|
||||
return {
|
||||
rows: Math.max(1, Math.floor(box.height / ROW_PX) - frameCost.rows),
|
||||
cols: Math.max(1, Math.floor(box.width / COL_PX) - frameCost.cols),
|
||||
};
|
||||
}
|
||||
|
||||
function sendViewport() {
|
||||
const { rows, cols } = extent();
|
||||
const box = el.grid.getBoundingClientRect();
|
||||
const metrics = { rowPx: ROW_PX, colPx: COL_PX };
|
||||
const { rows, cols } = extentOf(box, frameCost, metrics);
|
||||
// `client.set_viewport`, NOT the associated `Client.viewport`: the same
|
||||
// frame, but it also RECORDS the window. Why that matters: clients.md.
|
||||
transport.send(client.set_viewport(rows, cols));
|
||||
}
|
||||
|
||||
// A peer that never answers the handshake. Deliberately about the *first
|
||||
// frame*, not either peer's own failure modes — context/code/clients.md.
|
||||
const HANDSHAKE_TIMEOUT_MS = 8000;
|
||||
let watchdog = null;
|
||||
function armWatchdog(message) {
|
||||
clearTimeout(watchdog);
|
||||
watchdog = setTimeout(() => setShellStatus(message), HANDSHAKE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// A peer that failed *loudly* has already said something more precise, so the
|
||||
// watchdog must be DISARMED and not merely pre-empted (PR #13 review).
|
||||
function setShellFailure(text) {
|
||||
clearTimeout(watchdog);
|
||||
watchdog = null;
|
||||
setShellStatus(text);
|
||||
}
|
||||
|
||||
/** One frame, one fold, in arrival order. Never batch or reorder these. */
|
||||
function onFrame(text) {
|
||||
clearTimeout(watchdog);
|
||||
shell.sawFrame();
|
||||
try {
|
||||
client.on_frame(text);
|
||||
} catch (err) {
|
||||
// `on_frame` returns a Result, which wasm-bindgen throws; thrown in a bare
|
||||
// listener it reached the console alone and the grid just stopped.
|
||||
setShellStatus("bad frame — the grid has stopped updating (see console)");
|
||||
shell.setShell("bad frame — the grid has stopped updating (see console)");
|
||||
console.error("improvise: frame rejected", text, err);
|
||||
return;
|
||||
}
|
||||
@@ -116,13 +86,13 @@ function workerTransport() {
|
||||
const worker = new Worker(`./worker.js?${query}`, { type: "module" });
|
||||
worker.addEventListener("message", (event) => onFrame(event.data));
|
||||
worker.addEventListener("messageerror", () =>
|
||||
setShellFailure("the worker sent a frame this page could not read"),
|
||||
shell.fail("the worker sent a frame this page could not read"),
|
||||
);
|
||||
worker.addEventListener("error", (event) => {
|
||||
setShellFailure(`worker failed to start: ${event.message ?? "see console"}`);
|
||||
shell.fail(`worker failed to start: ${event.message ?? "see console"}`);
|
||||
console.error("improvise: worker error", event);
|
||||
});
|
||||
setShellStatus(`in-page worker · ${DOC}`);
|
||||
shell.setShell(`in-page worker · ${DOC}`);
|
||||
// Frames posted before the worker has finished `start()` are held by
|
||||
// worker.js in arrival order, so the handshake can go out immediately.
|
||||
return { send: (text) => worker.postMessage(text), ready: Promise.resolve() };
|
||||
@@ -138,20 +108,20 @@ function socketTransport() {
|
||||
const ready = new Promise((resolve) => {
|
||||
socket.addEventListener("open", () => {
|
||||
opened = true;
|
||||
setShellStatus(`connected · ${WS_URL}`);
|
||||
shell.setShell(`connected · ${WS_URL}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
socket.addEventListener("message", (event) => onFrame(event.data));
|
||||
// Without this, a connection that never opens left #status blank and
|
||||
// "server not running" read exactly like "still connecting".
|
||||
socket.addEventListener("error", () => setShellFailure(unreachable));
|
||||
socket.addEventListener("error", () => shell.fail(unreachable));
|
||||
// A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms
|
||||
// route through `setShellFailure`, which disarms the watchdog — clients.md.
|
||||
// route through `shell.fail`, which disarms the watchdog — clients.md.
|
||||
socket.addEventListener("close", () =>
|
||||
setShellFailure(opened ? "disconnected" : unreachable),
|
||||
shell.fail(opened ? "disconnected" : unreachable),
|
||||
);
|
||||
setShellStatus(`connecting to ${WS_URL}…`);
|
||||
shell.setShell(`connecting to ${WS_URL}…`);
|
||||
return {
|
||||
send: (text) => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(text);
|
||||
@@ -173,7 +143,7 @@ function repaint() {
|
||||
try {
|
||||
model = JSON.parse(client.render_model());
|
||||
} catch (err) {
|
||||
setShellStatus("cannot render the current state (see console)");
|
||||
shell.setShell("cannot render the current state (see console)");
|
||||
console.error("improvise: render failed", err);
|
||||
return;
|
||||
}
|
||||
@@ -185,8 +155,7 @@ function repaint() {
|
||||
// itself; see `RenderModel::hint`.
|
||||
el.hint.textContent = model.hint;
|
||||
paintHelp(model);
|
||||
modelStatus = model.status;
|
||||
paintStatus();
|
||||
shell.setModel(model.status);
|
||||
el.viewName.textContent = model.view_name;
|
||||
el.grid.replaceChildren(gridTable(model));
|
||||
// The flag is the client's answer, not the page's: `C` toggles it and the
|
||||
@@ -201,10 +170,7 @@ function repaint() {
|
||||
|
||||
// What the frame costs, so the NEXT viewport announcement is honest.
|
||||
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
|
||||
const next = {
|
||||
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
|
||||
cols: model.row_levels,
|
||||
};
|
||||
const next = frameCostOf(model);
|
||||
const changed = next.rows !== frameCost.rows || next.cols !== frameCost.cols;
|
||||
frameCost = next;
|
||||
if (changed) sendViewport();
|
||||
@@ -249,32 +215,30 @@ function paintHelp(model) {
|
||||
el.help.replaceChildren(tabs, body);
|
||||
}
|
||||
|
||||
/** The grid, painted from a model in which every frame decision is already
|
||||
* made (improvise-anr9). The page decides nothing here: which labels repeat
|
||||
* and are therefore blank, how many header levels there are, which columns
|
||||
* share a group and whether it is collapsed all arrive answered, by the same
|
||||
* `AxisFrame` the TUI widget reads. */
|
||||
/** The grid, painted from a plan in which every decision is already made
|
||||
* (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything:
|
||||
* which labels repeat and are therefore blank, how many header levels there
|
||||
* are, which columns share a group and whether it is collapsed all arrive
|
||||
* answered, by the same `AxisFrame` the TUI widget reads. */
|
||||
function gridTable(model) {
|
||||
const plan = gridPlan(model);
|
||||
const table = document.createElement("table");
|
||||
const head = table.createTHead();
|
||||
const corner = () => {
|
||||
const th = document.createElement("th");
|
||||
th.colSpan = model.row_levels;
|
||||
th.colSpan = plan.cornerSpan;
|
||||
return th;
|
||||
};
|
||||
|
||||
// The column-group line, when there is one. An empty `col_groups` means
|
||||
// this grid has no column groups at all -- not that every group is blank.
|
||||
if (model.col_groups.length > 0) {
|
||||
// The column-group line, when there is one. An empty `groups` means this
|
||||
// grid has no column groups at all -- not that every group is blank.
|
||||
if (plan.groups.length > 0) {
|
||||
const groups = head.insertRow();
|
||||
groups.appendChild(corner());
|
||||
for (const group of model.col_groups) {
|
||||
for (const group of plan.groups) {
|
||||
const th = document.createElement("th");
|
||||
th.colSpan = group.span;
|
||||
// `label` is null for a run in no group; an empty STRING is a legal group
|
||||
// name, so this is a null check and not a truthiness test.
|
||||
th.textContent =
|
||||
group.label === null ? "" : indicator(group.collapsed) + " " + group.label;
|
||||
th.textContent = group.text;
|
||||
th.classList.add("group");
|
||||
groups.appendChild(th);
|
||||
}
|
||||
@@ -282,7 +246,7 @@ function gridTable(model) {
|
||||
|
||||
// One header line per level. A blank entry is repeat suppression, decided
|
||||
// in Rust -- never an absent label.
|
||||
for (const level of model.col_levels) {
|
||||
for (const level of plan.levels) {
|
||||
const tr = head.insertRow();
|
||||
tr.appendChild(corner());
|
||||
for (const label of level) {
|
||||
@@ -293,23 +257,16 @@ function gridTable(model) {
|
||||
}
|
||||
|
||||
const body = table.createTBody();
|
||||
// The cell coordinates a click sends are DATA row indices and a group header
|
||||
// is not a data row, so this counts rather than trusting `model.rows`.
|
||||
let r = -1;
|
||||
for (const row of model.rows) {
|
||||
for (const row of plan.body) {
|
||||
const tr = body.insertRow();
|
||||
if (row.kind === "group") {
|
||||
const th = document.createElement("th");
|
||||
th.colSpan = model.row_levels + model.col_levels[0].length;
|
||||
th.textContent = indicator(row.collapsed) + " " + row.label;
|
||||
th.colSpan = row.colSpan;
|
||||
th.textContent = row.text;
|
||||
th.classList.add("group");
|
||||
tr.appendChild(th);
|
||||
continue;
|
||||
}
|
||||
r += 1;
|
||||
// `r` is one binding shared by every closure below, so a click handler
|
||||
// that captured it would fire with the LAST row's index. Snapshot it.
|
||||
const dataRow = r;
|
||||
|
||||
for (const label of row.labels) {
|
||||
const header = document.createElement("th");
|
||||
@@ -318,39 +275,26 @@ function gridTable(model) {
|
||||
tr.appendChild(header);
|
||||
}
|
||||
|
||||
row.cells.forEach((cell, c) => {
|
||||
for (const cell of row.cells) {
|
||||
const td = tr.insertCell();
|
||||
// A keyboard edit is open when `model.editing` is a string, and it holds
|
||||
// what has been typed. Both facts are decided in Rust.
|
||||
const typing = cell.selected && typeof model.editing === "string";
|
||||
td.textContent = typing ? model.editing + "\u258f" : cell.text;
|
||||
td.classList.toggle("numeric", cell.numeric && !typing);
|
||||
td.classList.toggle("error", cell.error);
|
||||
td.classList.toggle("selected", cell.selected);
|
||||
td.classList.toggle("typing", typing);
|
||||
// `editable` is the server's answer relayed through Rust: a stored cell.
|
||||
// Drill is not on the wire yet, so an aggregate is not offered.
|
||||
td.classList.toggle("editable", cell.editable);
|
||||
td.textContent = cell.text;
|
||||
for (const [name, on] of Object.entries(cell.classes)) {
|
||||
td.classList.toggle(name, on);
|
||||
}
|
||||
// Every cell moves the selection; an editable one also opens its editor,
|
||||
// which is the pointer's `i`. Not repainted in that branch: `edit` has
|
||||
// set `editing`, and a repaint would take the <input> away.
|
||||
td.addEventListener("click", () => {
|
||||
const frame = client.click_cell(dataRow, c);
|
||||
const frame = client.click_cell(cell.row, cell.col);
|
||||
if (frame) transport.send(frame);
|
||||
if (cell.editable) edit(td, dataRow, c, cell.edit_text);
|
||||
if (cell.editable) edit(td, cell.row, cell.col, cell.editText);
|
||||
else repaint();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/** The collapse glyph. `collapsed` is the fact, decided by the view and
|
||||
* carried on the frame; only this spelling is the page's (gotcha 26). */
|
||||
function indicator(collapsed) {
|
||||
return collapsed ? "\u25b6" : "\u25bc";
|
||||
}
|
||||
|
||||
/** 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, seed) {
|
||||
@@ -495,7 +439,7 @@ window.addEventListener("keydown", (e) => {
|
||||
|
||||
await transport.ready;
|
||||
sendViewport(); // the handshake: the snapshot is built at this extent
|
||||
armWatchdog(
|
||||
shell.arm(
|
||||
WS_URL
|
||||
? `${WS_URL} accepted the connection but sent no document`
|
||||
: "the in-page worker did not answer — this browser may not allow it (see console)",
|
||||
|
||||
+17
-1
@@ -44,7 +44,10 @@ cp -rf "$root/docs/casts" "$out/casts"
|
||||
|
||||
app="$out/app"
|
||||
mkdir -p "$app"
|
||||
cp -f "$root/web/index.html" "$root/web/app.js" "$root/web/style.css" "$root/web/worker.js" "$app/"
|
||||
# shell.js and serial.js are imported by app.js and worker.js: leaving either
|
||||
# out is a page that fails to load its own module graph, not a degraded one.
|
||||
cp -f "$root/web/index.html" "$root/web/app.js" "$root/web/shell.js" \
|
||||
"$root/web/serial.js" "$root/web/style.css" "$root/web/worker.js" "$app/"
|
||||
# The document the page opens on a browser that has never seen one.
|
||||
# web/worker.js fetches it by the ?seed= URL and hands the bytes to Rust,
|
||||
# which writes them through the VFS — so leaving it out is an empty grid, not
|
||||
@@ -82,3 +85,16 @@ stamped "$app/app.js" "pkg/improvise_wasm_client.js?v=${token}"
|
||||
stamped "$app/worker.js" "worker-pkg/improvise_worker_server.js?v=${token}"
|
||||
stamped "$app/pkg/improvise_wasm_client.js" "improvise_wasm_client_bg.wasm?v=${token}"
|
||||
stamped "$app/worker-pkg/improvise_worker_server.js" "improvise_worker_server_bg.wasm?v=${token}"
|
||||
|
||||
# Every relative import the two entry points make must have been copied. The
|
||||
# cp list above is a second answer to "what ships", and a missing sibling is a
|
||||
# 404 at module-load time, which the page cannot report.
|
||||
for entry in app.js worker.js; do
|
||||
deps=$(sed -n 's|.*from "\./\([A-Za-z0-9_.-]*\.js\)".*|\1|p' "$app/$entry")
|
||||
for dep in $deps; do
|
||||
test -f "$app/$dep" || {
|
||||
echo "assemble: $entry imports ./$dep, which was not copied" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
done
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "The browser page. `node --test web/tests` runs what needs no browser (improvise-cr3)."
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// The one rule `web/worker.js` exists to keep: one frame at a time, in arrival
|
||||
// order. No DOM, no worker globals — context/code/clients.md.
|
||||
|
||||
/** Chain every arrival onto one tail promise, starting from `head`.
|
||||
*
|
||||
* An `async` listener does NOT serialize: the event loop is free to start the
|
||||
* next message while an earlier one is suspended at an `await`, so two
|
||||
* handler runs interleave and a later frame's replies can be posted before an
|
||||
* earlier frame's — which the client's fold answers by DROPPING the overtaken
|
||||
* update, not by replaying it. Chaining is also what makes "frames that
|
||||
* arrived before `start()` finished" an ordinary case of this rule rather
|
||||
* than a drain loop with the race in it twice over. */
|
||||
export function serialize(head, handler, onError) {
|
||||
let tail = head.catch(() => {});
|
||||
return (value) => {
|
||||
tail = tail.then(() => handler(value)).catch((err) => onError(err, value));
|
||||
return tail;
|
||||
};
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
// Everything `web/app.js` decides that is not painting: no DOM, no browser
|
||||
// API, one `node --test` away. What belongs here: context/code/clients.md.
|
||||
|
||||
/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit
|
||||
* `ws://` is upgraded rather than honoured: context/code/clients.md. */
|
||||
export function socketUrl(peer, pageProtocol) {
|
||||
const secure = pageProtocol === "https:";
|
||||
if (/^wss:\/\//i.test(peer)) return peer;
|
||||
if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer;
|
||||
return `${secure ? "wss" : "ws"}://${peer}`;
|
||||
}
|
||||
|
||||
/** `#status` carries two independent facts, composed rather than overwritten:
|
||||
* a repaint must not quietly erase a connection error. */
|
||||
export function composeStatus(shell, model) {
|
||||
return [shell, model].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/** The collapse glyph. `collapsed` is the fact, decided by the view and
|
||||
* carried on the frame; only this spelling is the page's (gotcha 26). */
|
||||
export function indicator(collapsed) {
|
||||
return collapsed ? "▶" : "▼";
|
||||
}
|
||||
|
||||
// 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.
|
||||
export const ROW_PX = 26;
|
||||
export const COL_PX = 110;
|
||||
|
||||
/** The extent a box of this size can show, in whole DATA cells: the frame is
|
||||
* charged for first, and at least one cell is always asked for. */
|
||||
export function extentOf(box, frameCost, metrics = { rowPx: ROW_PX, colPx: COL_PX }) {
|
||||
return {
|
||||
rows: Math.max(1, Math.floor(box.height / metrics.rowPx) - frameCost.rows),
|
||||
cols: Math.max(1, Math.floor(box.width / metrics.colPx) - frameCost.cols),
|
||||
};
|
||||
}
|
||||
|
||||
/** What the grid's own frame costs, in whole cells — measured off the painted
|
||||
* model, never predicted. Inline group ROWS are deliberately not counted. */
|
||||
export function frameCostOf(model) {
|
||||
return {
|
||||
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
|
||||
cols: model.row_levels,
|
||||
};
|
||||
}
|
||||
|
||||
/** The grid, as a description: every decision made, nothing created. The page
|
||||
* decides nothing here and neither does this — which labels repeat, how many
|
||||
* header levels there are and which columns share a group all arrive
|
||||
* answered, by the same `AxisFrame` the TUI widget reads (improvise-anr9). */
|
||||
export function gridPlan(model) {
|
||||
const cornerSpan = model.row_levels;
|
||||
const groups = model.col_groups.map((group) => ({
|
||||
span: group.span,
|
||||
// `label` is null for a run in no group; an empty STRING is a legal group
|
||||
// name, so this is a null check and not a truthiness test.
|
||||
text: group.label === null ? "" : `${indicator(group.collapsed)} ${group.label}`,
|
||||
}));
|
||||
|
||||
// The cell coordinates a click sends are DATA row indices and a group header
|
||||
// is not a data row, so this counts rather than trusting the row's position.
|
||||
let dataRow = -1;
|
||||
const body = model.rows.map((row) => {
|
||||
if (row.kind === "group") {
|
||||
return {
|
||||
kind: "group",
|
||||
colSpan: model.row_levels + model.col_levels[0].length,
|
||||
text: `${indicator(row.collapsed)} ${row.label}`,
|
||||
};
|
||||
}
|
||||
dataRow += 1;
|
||||
return {
|
||||
kind: "data",
|
||||
row: dataRow,
|
||||
labels: row.labels,
|
||||
cells: row.cells.map((cell, col) => {
|
||||
// A keyboard edit is open when `model.editing` is a string, and it
|
||||
// holds what has been typed. Both facts are decided in Rust.
|
||||
const typing = cell.selected && typeof model.editing === "string";
|
||||
return {
|
||||
row: dataRow,
|
||||
col,
|
||||
text: typing ? `${model.editing}▏` : cell.text,
|
||||
editText: cell.edit_text,
|
||||
editable: cell.editable,
|
||||
classes: {
|
||||
numeric: cell.numeric && !typing,
|
||||
error: cell.error,
|
||||
selected: cell.selected,
|
||||
typing,
|
||||
// `editable` is the server's answer relayed through Rust: a stored
|
||||
// cell. Drill is not on the wire, so an aggregate is not offered.
|
||||
editable: cell.editable,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return { cornerSpan, groups, levels: model.col_levels, body };
|
||||
}
|
||||
|
||||
/** The page's two status facts and the handshake watchdog, which share a home
|
||||
* because the one rule between them is that a *failure disarms the timer*. */
|
||||
export function createShell({ onStatus, timeoutMs, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
||||
let shell = "";
|
||||
let model = "";
|
||||
let watchdog = null;
|
||||
const paint = () => onStatus(composeStatus(shell, model));
|
||||
|
||||
return {
|
||||
setShell(text) {
|
||||
shell = text;
|
||||
paint();
|
||||
},
|
||||
setModel(text) {
|
||||
model = text;
|
||||
paint();
|
||||
},
|
||||
/** A peer that never answers the handshake. Deliberately about the *first
|
||||
* frame*, not either peer's own failure modes — clients.md. */
|
||||
arm(message) {
|
||||
clearTimer(watchdog);
|
||||
watchdog = setTimer(() => {
|
||||
watchdog = null;
|
||||
this.setShell(message);
|
||||
}, timeoutMs);
|
||||
},
|
||||
/** A frame arrived, so the peer answered: the watchdog has no question
|
||||
* left to ask. */
|
||||
sawFrame() {
|
||||
clearTimer(watchdog);
|
||||
watchdog = null;
|
||||
},
|
||||
/** A peer that failed *loudly* has already said something more precise, so
|
||||
* the watchdog must be DISARMED and not merely pre-empted (PR #13). */
|
||||
fail(text) {
|
||||
this.sawFrame();
|
||||
this.setShell(text);
|
||||
},
|
||||
armed: () => watchdog !== null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// The JS↔wasm boundary: every call the page makes is declared by the bundle,
|
||||
// with the arity it declares. Needs `web/build.sh` to have run.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const web = (name) => fileURLToPath(new URL(`../../${name}`, import.meta.url));
|
||||
|
||||
/// Declared methods and free functions, as `name -> [min, max]` arity. A
|
||||
/// missing bundle is a FAILURE and never a skip: this file exists to be run
|
||||
/// after `web/build.sh`, and a silent pass would report exactly nothing.
|
||||
function declarations(dts) {
|
||||
const text = readFileSync(web(dts), "utf8");
|
||||
const found = new Map();
|
||||
const decl = /(?:^|\n)\s*(?:export function |static )?([a-z_][A-Za-z0-9_]*)\(([^)]*)\)\s*:/g;
|
||||
for (const [, name, params] of text.matchAll(decl)) {
|
||||
const list = params.trim() === "" ? [] : params.split(",");
|
||||
const required = list.filter((p) => !p.includes("?:")).length;
|
||||
found.set(name, [required, list.length]);
|
||||
}
|
||||
assert.ok(found.size > 0, `${dts} declares nothing — was web/build.sh run?`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/// Every `<receiver>.<name>(...)` call in a source file, with how many
|
||||
/// top-level arguments it passes.
|
||||
function callsOn(file, receiver) {
|
||||
const text = readFileSync(web(file), "utf8");
|
||||
const calls = [];
|
||||
const head = new RegExp(`${receiver}\\.([a-z_][A-Za-z0-9_]*)\\(`, "g");
|
||||
for (const match of text.matchAll(head)) {
|
||||
let depth = 1;
|
||||
let i = match.index + match[0].length;
|
||||
let commas = 0;
|
||||
let body = "";
|
||||
while (depth > 0 && i < text.length) {
|
||||
const ch = text[i];
|
||||
if ("([{".includes(ch)) depth += 1;
|
||||
else if (")]}".includes(ch)) depth -= 1;
|
||||
else if (ch === "," && depth === 1) commas += 1;
|
||||
if (depth > 0) body += ch;
|
||||
i += 1;
|
||||
}
|
||||
assert.equal(depth, 0, `unbalanced call to ${match[1]} in ${file}`);
|
||||
calls.push({ name: match[1], args: body.trim() === "" ? 0 : commas + 1 });
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
test("every client call web/app.js makes is declared, with that arity", () => {
|
||||
const declared = declarations("pkg/improvise_wasm_client.d.ts");
|
||||
const calls = callsOn("app.js", "client");
|
||||
assert.ok(calls.length >= 6, `found only ${calls.length} client calls`);
|
||||
for (const call of calls) {
|
||||
const arity = declared.get(call.name);
|
||||
assert.ok(arity, `web/app.js calls client.${call.name}, which the bundle does not declare`);
|
||||
const [min, max] = arity;
|
||||
assert.ok(
|
||||
call.args >= min && call.args <= max,
|
||||
`client.${call.name} takes ${min}..${max} arguments; app.js passes ${call.args}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the page still reaches the client through a handful of calls", () => {
|
||||
const names = new Set(callsOn("app.js", "client").map((c) => c.name));
|
||||
// The named surface, so a call added without an argument fails here and
|
||||
// has to come and say so. `viewport` is deliberately absent: `set_viewport`
|
||||
// is the one that records the window (gotcha 47).
|
||||
assert.deepEqual(
|
||||
[...names].sort(),
|
||||
[
|
||||
"click_category",
|
||||
"click_cell",
|
||||
"click_formula",
|
||||
"click_tile",
|
||||
"click_view",
|
||||
"on_frame",
|
||||
"on_key_event",
|
||||
"render_model",
|
||||
"set_viewport",
|
||||
"typed_cell",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("every worker call web/worker.js makes is declared, with that arity", () => {
|
||||
const declared = declarations("worker-pkg/improvise_worker_server.d.ts");
|
||||
const calls = callsOn("worker.js", "server");
|
||||
assert.ok(calls.length >= 2, `found only ${calls.length} server calls`);
|
||||
for (const call of calls) {
|
||||
const arity = declared.get(call.name);
|
||||
assert.ok(arity, `web/worker.js calls server.${call.name}, undeclared by the bundle`);
|
||||
assert.ok(
|
||||
call.args >= arity[0] && call.args <= arity[1],
|
||||
`server.${call.name} takes ${arity[0]}..${arity[1]}; worker.js passes ${call.args}`,
|
||||
);
|
||||
}
|
||||
// `start` is a free function rather than a method, and takes the seed.
|
||||
assert.deepEqual(declared.get("start"), [1, 2]);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// The grid's plan: the decisions `gridTable` used to make while creating
|
||||
// elements, where a wrong answer showed up as a wrong pixel.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { gridPlan } from "../shell.js";
|
||||
|
||||
const cell = (text, extra = {}) => ({
|
||||
text,
|
||||
edit_text: text,
|
||||
numeric: true,
|
||||
error: false,
|
||||
selected: false,
|
||||
editable: true,
|
||||
...extra,
|
||||
});
|
||||
|
||||
/// A grid with a group header between its data rows — the shape the row
|
||||
/// index has to survive. Two column levels under one group run.
|
||||
function grouped() {
|
||||
return {
|
||||
row_levels: 2,
|
||||
col_levels: [["Q1", ""], ["Revenue", "Cost"]],
|
||||
col_groups: [{ span: 2, label: "2026", collapsed: false }],
|
||||
editing: null,
|
||||
rows: [
|
||||
{ kind: "group", label: "Hot", collapsed: false },
|
||||
{ kind: "data", labels: ["Hot", "Espresso"], cells: [cell("1"), cell("2")] },
|
||||
{ kind: "data", labels: ["", "Latte"], cells: [cell("3"), cell("4")] },
|
||||
{ kind: "group", label: "Cold", collapsed: true },
|
||||
{ kind: "data", labels: ["Cold", "Cold Brew"], cells: [cell("5"), cell("6")] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("a group header consumes no data row index", () => {
|
||||
const plan = gridPlan(grouped());
|
||||
const data = plan.body.filter((r) => r.kind === "data");
|
||||
assert.deepEqual(data.map((r) => r.row), [0, 1, 2]);
|
||||
// And every cell in a row carries that row's index, not its position in
|
||||
// `rows` — the coordinate a click sends.
|
||||
assert.deepEqual(
|
||||
data.at(-1).cells.map((c) => [c.row, c.col]),
|
||||
[[2, 0], [2, 1]],
|
||||
);
|
||||
});
|
||||
|
||||
test("a group header spans the labels and the cells together", () => {
|
||||
const plan = gridPlan(grouped());
|
||||
const groups = plan.body.filter((r) => r.kind === "group");
|
||||
assert.deepEqual(groups.map((r) => r.colSpan), [4, 4]);
|
||||
assert.deepEqual(groups.map((r) => r.text), ["▼ Hot", "▶ Cold"]);
|
||||
assert.equal(plan.cornerSpan, 2);
|
||||
});
|
||||
|
||||
test("a column run in no group is blank, and an empty name is a name", () => {
|
||||
const model = grouped();
|
||||
model.col_groups = [
|
||||
{ span: 1, label: null, collapsed: false },
|
||||
{ span: 1, label: "", collapsed: true },
|
||||
];
|
||||
const plan = gridPlan(model);
|
||||
assert.deepEqual(plan.groups, [
|
||||
{ span: 1, text: "" },
|
||||
{ span: 1, text: "▶ " },
|
||||
]);
|
||||
});
|
||||
|
||||
test("the typed text replaces only the selected cell, and carries a caret", () => {
|
||||
const model = grouped();
|
||||
model.editing = "12";
|
||||
model.rows[1].cells[1].selected = true;
|
||||
const plan = gridPlan(model);
|
||||
const row = plan.body[1];
|
||||
assert.equal(row.cells[0].text, "1");
|
||||
assert.equal(row.cells[1].text, "12▏");
|
||||
assert.equal(row.cells[1].classes.typing, true);
|
||||
// A cell being typed into is not drawn as a number: the buffer is text.
|
||||
assert.equal(row.cells[1].classes.numeric, false);
|
||||
assert.equal(row.cells[0].classes.numeric, true);
|
||||
});
|
||||
|
||||
test("a selected cell with no editor open is not typing", () => {
|
||||
const model = grouped();
|
||||
model.editing = null;
|
||||
model.rows[1].cells[0].selected = true;
|
||||
const plan = gridPlan(model);
|
||||
const first = plan.body[1].cells[0];
|
||||
assert.equal(first.classes.typing, false);
|
||||
assert.equal(first.classes.selected, true);
|
||||
assert.equal(first.text, "1");
|
||||
});
|
||||
|
||||
test("an empty edit buffer still means an editor is open", () => {
|
||||
const model = grouped();
|
||||
model.editing = "";
|
||||
model.rows[1].cells[0].selected = true;
|
||||
const plan = gridPlan(model);
|
||||
assert.equal(plan.body[1].cells[0].classes.typing, true);
|
||||
assert.equal(plan.body[1].cells[0].text, "▏");
|
||||
});
|
||||
|
||||
test("the editor is seeded from the stored value, not the drawn one", () => {
|
||||
const model = grouped();
|
||||
model.rows[1].cells[0] = cell("20,000", { edit_text: "20000" });
|
||||
const plan = gridPlan(model);
|
||||
assert.equal(plan.body[1].cells[0].text, "20,000");
|
||||
assert.equal(plan.body[1].cells[0].editText, "20000");
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// The RenderModel→DOM boundary, statically: a projected field that nothing
|
||||
// paints is the bug four widgets shared. See context/code/clients.md.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8");
|
||||
|
||||
/// Fields the page deliberately does not read, each with the reason. A field
|
||||
/// belongs here only with an argument; the list is the debt, written down.
|
||||
// improvise-z754 put the title bar's facts in one shared cache and taught
|
||||
// `src/draw.rs::draw_title` to fold it; this page never learned to. That is
|
||||
// improvise-5jna, open, and this list is what stops it being forgotten.
|
||||
const UNPAINTED = {
|
||||
doc_name: "improvise-5jna: the title bar is drawn by the TUI and not here",
|
||||
doc_dirty: "improvise-5jna",
|
||||
doc_stored_as: "improvise-5jna",
|
||||
};
|
||||
|
||||
/// The `pub` fields of `RenderModel`, read off the Rust that defines it.
|
||||
function renderModelFields() {
|
||||
const src = read("../../crates/improvise-wasm-client/src/render.rs");
|
||||
const start = src.indexOf("pub struct RenderModel {");
|
||||
assert.ok(start > 0, "RenderModel moved — this test reads the struct itself");
|
||||
const body = src.slice(start, src.indexOf("\n}", start));
|
||||
const fields = [...body.matchAll(/\n {4}pub ([a-z_0-9]+):/g)].map((m) => m[1]);
|
||||
assert.ok(fields.length > 10, `found only ${fields.length} fields`);
|
||||
return fields;
|
||||
}
|
||||
|
||||
test("every field the render model projects is painted, or named as debt", () => {
|
||||
// Both halves of the page: `app.js` paints, `shell.js` plans the grid.
|
||||
const page = read("../app.js") + read("../shell.js");
|
||||
const unpainted = renderModelFields().filter((field) => !page.includes(`model.${field}`));
|
||||
assert.deepEqual(
|
||||
unpainted.sort(),
|
||||
Object.keys(UNPAINTED).sort(),
|
||||
"a projected field reaches no element — paint it, or add it to UNPAINTED with the reason",
|
||||
);
|
||||
});
|
||||
|
||||
test("every element the page looks up exists in the page", () => {
|
||||
const app = read("../app.js");
|
||||
const html = read("../index.html");
|
||||
const ids = [...app.matchAll(/getElementById\("([^"]+)"\)/g)].map((m) => m[1]);
|
||||
assert.ok(ids.length >= 8, `found only ${ids.length} element lookups`);
|
||||
for (const id of ids) {
|
||||
assert.ok(html.includes(`id="${id}"`), `app.js looks up #${id}, which index.html has not got`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// The worker's one rule: one frame at a time, in arrival order. The bug this
|
||||
// guards against is an `async` listener, which does not serialize.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { serialize } from "../serial.js";
|
||||
|
||||
const tick = (n) => new Promise((resolve) => setTimeout(resolve, n));
|
||||
|
||||
test("a slow frame does not let a later one overtake it", async () => {
|
||||
const posted = [];
|
||||
// The first frame suspends for longer than the second: an `async` listener
|
||||
// would post B's replies while A is still awaiting.
|
||||
const handler = async (frame) => {
|
||||
await tick(frame === "A" ? 20 : 0);
|
||||
posted.push(frame);
|
||||
};
|
||||
const onMessage = serialize(Promise.resolve(), handler, () => {});
|
||||
|
||||
onMessage("A");
|
||||
await onMessage("B");
|
||||
assert.deepEqual(posted, ["A", "B"]);
|
||||
});
|
||||
|
||||
test("the same interleaving without the chain is what this rules out", async () => {
|
||||
const posted = [];
|
||||
const listener = async (frame) => {
|
||||
await tick(frame === "A" ? 20 : 0);
|
||||
posted.push(frame);
|
||||
};
|
||||
listener("A");
|
||||
await listener("B");
|
||||
await tick(30);
|
||||
assert.deepEqual(posted, ["B", "A"]);
|
||||
});
|
||||
|
||||
test("frames that arrive before startup finishes are held, not dropped", async () => {
|
||||
const posted = [];
|
||||
let ready;
|
||||
const head = new Promise((resolve) => {
|
||||
ready = resolve;
|
||||
});
|
||||
const onMessage = serialize(head, (f) => posted.push(f), () => {});
|
||||
|
||||
onMessage("first");
|
||||
onMessage("second");
|
||||
assert.deepEqual(posted, [], "nothing runs before startup settles");
|
||||
ready();
|
||||
await onMessage("third");
|
||||
assert.deepEqual(posted, ["first", "second", "third"]);
|
||||
});
|
||||
|
||||
test("a startup that failed still lets the chain run, so the handler can decline", async () => {
|
||||
const seen = [];
|
||||
const head = Promise.reject(new Error("no bundle"));
|
||||
const onMessage = serialize(head, (f) => seen.push(f), () => {});
|
||||
await onMessage("frame");
|
||||
assert.deepEqual(seen, ["frame"]);
|
||||
});
|
||||
|
||||
test("one bad frame does not poison the frames after it", async () => {
|
||||
const posted = [];
|
||||
const errors = [];
|
||||
const onMessage = serialize(
|
||||
Promise.resolve(),
|
||||
(frame) => {
|
||||
if (frame === "bad") throw new Error("rejected");
|
||||
posted.push(frame);
|
||||
},
|
||||
(err, frame) => errors.push([frame, err.message]),
|
||||
);
|
||||
|
||||
onMessage("good");
|
||||
onMessage("bad");
|
||||
await onMessage("later");
|
||||
assert.deepEqual(posted, ["good", "later"]);
|
||||
assert.deepEqual(errors, [["bad", "rejected"]]);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// What the page decides between the boundaries: transport selection, the two
|
||||
// status facts, the watchdog and the extent arithmetic. No DOM is involved.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
composeStatus,
|
||||
createShell,
|
||||
extentOf,
|
||||
frameCostOf,
|
||||
indicator,
|
||||
socketUrl,
|
||||
} from "../shell.js";
|
||||
|
||||
test("a bare peer takes the page's own scheme, and https never falls back", () => {
|
||||
assert.equal(socketUrl("127.0.0.1:8787", "http:"), "ws://127.0.0.1:8787");
|
||||
assert.equal(socketUrl("example.test", "https:"), "wss://example.test");
|
||||
// An explicit `ws://` on an https page is UPGRADED, not honoured: the
|
||||
// browser would refuse the mixed-content connection either way.
|
||||
assert.equal(socketUrl("ws://example.test", "https:"), "wss://example.test");
|
||||
assert.equal(socketUrl("ws://example.test", "http:"), "ws://example.test");
|
||||
// `wss://` is honoured on both, and the match is case-insensitive.
|
||||
assert.equal(socketUrl("WSS://example.test", "http:"), "WSS://example.test");
|
||||
});
|
||||
|
||||
test("a peer whose host merely starts with ws is not a scheme", () => {
|
||||
assert.equal(socketUrl("wsserver.test:9000", "http:"), "ws://wsserver.test:9000");
|
||||
});
|
||||
|
||||
test("the two status facts compose, and neither can erase the other", () => {
|
||||
assert.equal(composeStatus("connected", "Filter: Product = Latte"), "connected · Filter: Product = Latte");
|
||||
// An empty fact contributes nothing rather than a stray separator.
|
||||
assert.equal(composeStatus("connected", ""), "connected");
|
||||
assert.equal(composeStatus("", "saved"), "saved");
|
||||
assert.equal(composeStatus("", ""), "");
|
||||
});
|
||||
|
||||
test("a repaint's model status does not overwrite a connection failure", () => {
|
||||
const seen = [];
|
||||
const shell = createShell({ onStatus: (t) => seen.push(t), timeoutMs: 10 });
|
||||
shell.fail("cannot reach 127.0.0.1:8787");
|
||||
shell.setModel("Filter: Product = Latte");
|
||||
assert.equal(seen.at(-1), "cannot reach 127.0.0.1:8787 · Filter: Product = Latte");
|
||||
});
|
||||
|
||||
/// A clock the test drives, so the watchdog's arming is asserted rather than
|
||||
/// waited for. `fire()` runs what is pending, as the event loop would.
|
||||
function fakeClock() {
|
||||
let next = 1;
|
||||
const pending = new Map();
|
||||
return {
|
||||
setTimer: (fn) => {
|
||||
const id = next++;
|
||||
pending.set(id, fn);
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => pending.delete(id),
|
||||
fire: () => {
|
||||
const due = [...pending.values()];
|
||||
pending.clear();
|
||||
for (const fn of due) fn();
|
||||
},
|
||||
count: () => pending.size,
|
||||
};
|
||||
}
|
||||
|
||||
test("the watchdog speaks when the peer never answers the handshake", () => {
|
||||
const clock = fakeClock();
|
||||
const seen = [];
|
||||
const shell = createShell({
|
||||
onStatus: (t) => seen.push(t),
|
||||
timeoutMs: 8000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
shell.setShell("connecting…");
|
||||
shell.arm("sent no document");
|
||||
assert.equal(shell.armed(), true);
|
||||
clock.fire();
|
||||
assert.equal(seen.at(-1), "sent no document");
|
||||
});
|
||||
|
||||
test("a loud failure DISARMS the watchdog rather than being replaced by it", () => {
|
||||
const clock = fakeClock();
|
||||
const seen = [];
|
||||
const shell = createShell({
|
||||
onStatus: (t) => seen.push(t),
|
||||
timeoutMs: 8000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
shell.arm("sent no document");
|
||||
shell.fail("disconnected");
|
||||
assert.equal(shell.armed(), false);
|
||||
assert.equal(clock.count(), 0);
|
||||
clock.fire();
|
||||
// The precise message is what stands, eight seconds later and forever.
|
||||
assert.equal(seen.at(-1), "disconnected");
|
||||
});
|
||||
|
||||
test("a frame that arrives is the answer the watchdog was waiting for", () => {
|
||||
const clock = fakeClock();
|
||||
const seen = [];
|
||||
const shell = createShell({
|
||||
onStatus: (t) => seen.push(t),
|
||||
timeoutMs: 8000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
shell.setShell("connected");
|
||||
shell.arm("sent no document");
|
||||
shell.sawFrame();
|
||||
clock.fire();
|
||||
assert.equal(seen.at(-1), "connected");
|
||||
});
|
||||
|
||||
test("arming twice leaves one timer, not two", () => {
|
||||
const clock = fakeClock();
|
||||
const shell = createShell({
|
||||
onStatus: () => {},
|
||||
timeoutMs: 8000,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
shell.arm("first");
|
||||
shell.arm("second");
|
||||
assert.equal(clock.count(), 1);
|
||||
});
|
||||
|
||||
test("the frame is charged for before the extent is announced", () => {
|
||||
// 10 rows and 4 columns fit; one header level and one row-label column are
|
||||
// not data, so the peer is asked for 9 by 3.
|
||||
const box = { height: 10 * 26, width: 4 * 110 };
|
||||
assert.deepEqual(extentOf(box, { rows: 1, cols: 1 }), { rows: 9, cols: 3 });
|
||||
});
|
||||
|
||||
test("a window smaller than its own frame still asks for one cell", () => {
|
||||
assert.deepEqual(extentOf({ height: 0, width: 0 }, { rows: 3, cols: 2 }), {
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("the frame costs a line per column level, plus one for a group line", () => {
|
||||
const model = (levels, groups, rowLevels) => ({
|
||||
col_levels: levels,
|
||||
col_groups: groups,
|
||||
row_levels: rowLevels,
|
||||
});
|
||||
assert.deepEqual(frameCostOf(model([["a"]], [], 1)), { rows: 1, cols: 1 });
|
||||
// Two category levels on the column axis, and a group line above them.
|
||||
assert.deepEqual(frameCostOf(model([["a"], ["b"]], [{ span: 1 }], 2)), {
|
||||
rows: 3,
|
||||
cols: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("the collapse glyph points right when collapsed", () => {
|
||||
assert.equal(indicator(true), "▶");
|
||||
assert.equal(indicator(false), "▼");
|
||||
});
|
||||
+16
-15
@@ -2,6 +2,7 @@
|
||||
// every decision in Rust. The two rules it must not break: context/code/clients.md.
|
||||
|
||||
import init, { start } from "./worker-pkg/improvise_worker_server.js";
|
||||
import { serialize } from "./serial.js";
|
||||
|
||||
const params = new URLSearchParams(self.location.search);
|
||||
const DOC = params.get("doc") || "demo.improv";
|
||||
@@ -43,21 +44,21 @@ started.catch((err) => {
|
||||
console.error("improvise worker: failed to start", err);
|
||||
});
|
||||
|
||||
// One frame at a time, in arrival order: the listener is deliberately NOT
|
||||
// `async` — see context/code/clients.md.
|
||||
let tail = started.catch(() => {});
|
||||
// One frame at a time, in arrival order, and the listener is deliberately NOT
|
||||
// `async`: the chaining is `serial.js`'s and is tested there.
|
||||
const onFrame = serialize(
|
||||
started,
|
||||
async (data) => {
|
||||
// `start()` failed. There is nothing to answer with, and the main
|
||||
// thread's handshake watchdog is what tells the user.
|
||||
if (!server) return;
|
||||
await handle(data);
|
||||
},
|
||||
// An unhandled rejection here reaches the devtools console and nothing
|
||||
// else. Caught, so one bad frame does not poison every later one.
|
||||
(err, data) => console.error("improvise worker: frame rejected", data, err),
|
||||
);
|
||||
|
||||
self.onmessage = (event) => {
|
||||
tail = tail
|
||||
.then(async () => {
|
||||
// `start()` failed. There is nothing to answer with, and the main
|
||||
// thread's handshake watchdog is what tells the user.
|
||||
if (!server) return;
|
||||
await handle(event.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
// An unhandled rejection here reaches the devtools console and nothing
|
||||
// else. Caught, so one bad frame does not poison every later one.
|
||||
console.error("improvise worker: frame rejected", event.data, err);
|
||||
});
|
||||
onFrame(event.data);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user