Merge pull request #60 from fiddlerwoaroof/fix/web-panel-clicks
fix(web): a click is all of its intents, never mutates the document, and the panel opens at launch
This commit is contained in:
+119
-6
@@ -552,12 +552,16 @@ gotcha 47 asks for — closing e24v has to come to that line and delete it.
|
||||
`text`**: the drawn text has been through `format_f64`, so a stored 1200 reads
|
||||
`"1,200"` under a comma-grouping format and committing it would store the
|
||||
*string* `"1,200"`. `tests/lockstep.rs` found exactly that.
|
||||
- **Drill does not go upstream.** Every `Upstream` is a black hole, and a key
|
||||
that visibly does nothing is the worst of the three answers. Drill is
|
||||
session-local residue and reaching it needs a `CellKey` this client cannot
|
||||
build, so the honest answer is to say so: a `SetStatus`, which is a view-half
|
||||
command — `Sessions::apply` folds it into the asker's own mirror and
|
||||
broadcasts nothing, so it reaches this user and no one else.
|
||||
- **Drill does not go upstream — by *either* door.** Every `Upstream` is a black
|
||||
hole, and a key that visibly does nothing is the worst of the three answers.
|
||||
Drill is session-local residue and reaching it needs a `CellKey` this client
|
||||
cannot build, so the honest answer is to say so: a `SetStatus`, which is a
|
||||
view-half command — `Sessions::apply` folds it into the asker's own mirror and
|
||||
broadcasts nothing, so it reaches this user and no one else. This argument
|
||||
arrived as `edit_or_drill`'s, i.e. about `i`/`a` on an aggregate, and for a
|
||||
while it read as doctrine for `>` while `>` was still silent. Both doors now
|
||||
refuse, and **they refuse with different sentences on purpose**: "this key is
|
||||
not built yet" and "this cell is not yours to write" are different facts.
|
||||
- **The commit's write is not spelled again here.** It is
|
||||
`Client::typed_cell`'s, the same one click-to-edit uses, so a browser keyboard
|
||||
commit and a browser mouse commit cannot disagree about what `""` or `"42"`
|
||||
@@ -587,6 +591,115 @@ applied it (`"A"`, not shift+`"a"`), and the shared keymap's `lookup_local`
|
||||
retries character keys without modifiers for that very reason, so keeping the
|
||||
flag would only make an exact binding miss.
|
||||
|
||||
### The pointer: `Click`, and the four rules it obeys
|
||||
|
||||
A click is resolved here for the same reason a keypress is — a front end paints
|
||||
and wires, it does not decide (design-principles §5). `resolve::click` takes a
|
||||
`Click` naming a widget and an index, and returns the `Command`s. `web/app.js`
|
||||
attaches listeners and knows nothing else; there is one entry point per widget
|
||||
on `Client`, which was the user's ruling on improvise-7ybz.
|
||||
|
||||
**Rule 1 — a click navigates to what was clicked, and activates it where
|
||||
activation is something the client can express.** Every arm composes the same
|
||||
shape: `focus` the panel, `SetPanelCursor` to the row, then whatever `Enter`
|
||||
does on that row. The activation is resolved **against the clicked index, never
|
||||
against the cursor**, because the `SetPanelCursor` ahead of it has not been
|
||||
folded when the resolver runs — the trap that reads as "the cursor lands on
|
||||
`Plan` and the document switches to `Default`", silent and wrong in the half a
|
||||
user does not watch.
|
||||
|
||||
The second clause of rule 1 is what makes the formula panel look inconsistent
|
||||
and is in fact the rule holding. `ModeKey::ViewPanel` binds `Enter` to
|
||||
`switch-view-at-cursor`; `ModeKey::FormulaPanel` binds `a`/`n`/`o` and
|
||||
`d`/`Delete` and **no `Enter` at all**. So a view-row click switches and a
|
||||
formula-row click only navigates. Opening the editor on a formula click would
|
||||
be an activation *invented for the pointer that the keyboard does not have* —
|
||||
a second derivation of what a formula row means, which is the disease §5 names.
|
||||
**The guard for this must be aimed at the keymap, not at the click**: asserting
|
||||
that the click emits no `FormulaEdit` only restates the author's reading, and a
|
||||
reviewer demonstrated the gap by adding `Enter -> enter-mode formula-edit` to
|
||||
`FormulaPanel` and watching the whole workspace stay green while the click and
|
||||
the keyboard disagreed. `the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`
|
||||
asserts the binding's absence and names `click_formula_row` as what must change
|
||||
if it ever appears.
|
||||
|
||||
**Rule 2 — a click is all of its intents or none of them** (improvise-hkr8).
|
||||
Each `click_*` returns `Option<Vec<Command>>` and `click` does the single
|
||||
`unwrap_or_default`. The case that matters is an activation the client cannot
|
||||
compose: `filter_to_item_at` goes through `model`, which goes through
|
||||
`BasisCache::claim`, which returns `None` until the server has published a basis
|
||||
(gotcha 43). Before the fix the `None` was swallowed per-intent, so the click
|
||||
kept its navigation prefix, dropped its activation, and reported success — while
|
||||
the *keyboard* handled the identical `None` correctly by answering
|
||||
`KeyOutcome::Upstream`. Note that `None` at the `click` level means something
|
||||
different and benign: the whole click composed nothing, which is the invariant
|
||||
rather than a breach of it.
|
||||
|
||||
**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
|
||||
`tile_axis_at`, `click_view_row` calls `switch_view_at` — each is `foo_at(index)`
|
||||
with the keyboard's `foo_at_cursor` calling `foo_at(cursor)`. `click_view_row`
|
||||
originally re-spelled `SwitchView + ChangeMode(Normal)` inline, so a host-side
|
||||
change to `SwitchViewAtCursor` was forced into the keyboard arm by
|
||||
`tests/lockstep.rs` and left the click behind **silently**. A helper that returns
|
||||
only the *name* rather than the whole activation is what invites that.
|
||||
|
||||
### The category panel opens at launch, in both front ends
|
||||
|
||||
`ViewState::default()` sets `category_panel_open: true`. The user ruled this
|
||||
twice: first that the sidebar be visible at launch, then — asked whether the
|
||||
terminal was collateral — that it applies to **both**. `improvise model.improv`
|
||||
opening with the sidebar showing is intended, not a side effect of sharing a
|
||||
default. It is the **shared** default for the reason wsaw gives: a browser-only
|
||||
default would be "the page overrides the flag", which is the same defect as "the
|
||||
page ignores the flag" wearing different clothes.
|
||||
|
||||
**A launch default disarms every test that asserted the thing it now supplies**,
|
||||
and that is the expensive part. Three tests asserted `category_panel_open` was
|
||||
*true* after doing something to make it true; all three then passed without the
|
||||
code under them working. The worst was `set_panel_open_and_cursor`: with it
|
||||
vacuous, `effect::SetPanelOpen`'s Category arm could be replaced by a **no-op**
|
||||
— `C` doing nothing in the terminal — with the whole workspace green. The rule
|
||||
this leaves: **assert the direction the default cannot supply.** Closing the
|
||||
panel is the discriminating move; opening it proves nothing.
|
||||
|
||||
Note the two mappings that had to be repaired separately. `effect::SetPanelOpen`
|
||||
and `reduce_view`'s `Command::SetPanelOpen` are two three-way matches in two
|
||||
crates — the TUI folds one and the browser the other — so a guard on either says
|
||||
nothing about the other. That is a derivation done twice (§5), pre-existing and
|
||||
filed rather than fixed here.
|
||||
|
||||
Two tests, because either alone passes against the bug wsaw fixed: the panel is
|
||||
visible with no keypress, *and* `C` still hides it. Injecting "the page ignores
|
||||
the flag" fails the second; injecting "the default is false" fails the first.
|
||||
A test that only asserts the sidebar is visible cannot tell the flag being
|
||||
honoured from the flag being ignored.
|
||||
|
||||
---
|
||||
|
||||
## `improvise-wasm-client` — the crate, the fold, the render model
|
||||
|
||||
@@ -920,6 +920,11 @@ 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
|
||||
@@ -1112,6 +1117,11 @@ 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
|
||||
@@ -1173,14 +1183,22 @@ addressed to precisely one. That is the same argument that removed
|
||||
`ModelCommand::Drill` would reintroduce the bug that removal fixed. It needs the
|
||||
**addressed** form — ADR 001 layer 2.
|
||||
|
||||
**The two entry points fail differently, and that asymmetry is the finding.**
|
||||
`i`/`a` on an aggregate declines **out loud**: `edit_or_drill` answers the
|
||||
`CellId::Aggregate` arm with a status the user can read. But `>`
|
||||
(`drill-into-cell`) is a name `resolve::step` does not handle, so it becomes
|
||||
**The two entry points used to fail differently, and that asymmetry was the
|
||||
finding.** `i`/`a` on an aggregate declines **out loud**: `edit_or_drill`
|
||||
answers the `CellId::Aggregate` arm with a status the user can read. But `>`
|
||||
(`drill-into-cell`) was a name `resolve::step` did not handle, so it became
|
||||
`KeyOutcome::Upstream` → `ClientFrame::Key` → `Host::handle`, whose `Key` arm is
|
||||
an explicit empty vec — **a black hole, not a slow path** (gotcha 47). Pressing
|
||||
`>` in the browser does nothing whatsoever: no status, no mode change, nothing.
|
||||
That is why this reads as "never confirmed working" rather than as a refusal.
|
||||
`>` in the browser did nothing whatsoever: no status, no mode change, nothing.
|
||||
That is why this read as "never confirmed working" rather than as a refusal.
|
||||
|
||||
**That silence is now closed, and the closing is what this file ships.** `step`
|
||||
answers `"drill-into-cell"` with a `SetStatus` saying drill is not available in
|
||||
the browser yet — an interim the wording of which is load-bearing, since it must
|
||||
be true both today and on the day improvise-gd28 lands. The wire vocabulary is
|
||||
still absent; only the *silence* was fixed. Note for gd28: when the real drill
|
||||
arrives, a leftover `"drill-into-cell"` arm in `step` would **shadow** it with
|
||||
nothing failing, because a name that resolves never reaches the host.
|
||||
|
||||
**The TUI is the control and it works**: 12 drill tests pass across
|
||||
`cmd/grid.rs`, `cmd/mode.rs`, `ui/effect.rs` and `ui/app.rs`. So this is
|
||||
@@ -1196,20 +1214,429 @@ becomes an aggregate. The precondition is then **asserted** (`td.editable`
|
||||
count 0) rather than assumed, so neither test can pass against a grid of
|
||||
Records.
|
||||
|
||||
**Both injections, run.** Replacing the aggregate arm with `None` — silencing
|
||||
the honest refusal — fails **both** tests, and the second fails on its
|
||||
*`check_the_checker` control*, which is the point: with nothing answering, the
|
||||
control can no longer tell "`>` is silent" from "the page is dead", and the test
|
||||
correctly refuses to pass. Adding `"drill-into-cell" => Some(vec![SetStatus(…)])`
|
||||
to `step` — making `>` resolve — fails **only** the second test and leaves the
|
||||
**Both injections, run** — against the tree *before* the interim, which is what
|
||||
made them two faults rather than one. Replacing the aggregate arm with `None` —
|
||||
silencing the honest refusal — failed **both** tests, and the second failed on
|
||||
its *`check_the_checker` control*, which is the point: with nothing answering,
|
||||
the control can no longer tell "`>` is silent" from "the page is dead", and the
|
||||
test correctly refuses to pass. Adding `"drill-into-cell" => Some(vec![SetStatus(…)])`
|
||||
to `step` — making `>` resolve — failed **only** the second test and left the
|
||||
first green. Two faults, two different failure signatures, neither test passing
|
||||
for the other's reason.
|
||||
for the other's reason. That second injection is now the shipped code, which is
|
||||
why the test it broke was rewritten rather than kept.
|
||||
|
||||
That control is load-bearing and not decoration: a test asserting *nothing
|
||||
happened* passes against a page that has stopped listening, so it presses `i`
|
||||
afterwards and requires `#status` to change. Without that, this file would be
|
||||
folklore with a green tick.
|
||||
|
||||
### the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons
|
||||
|
||||
The Rust half of the interim, and the assertion that carries the whole weight.
|
||||
Two keys on the same aggregate cell must both refuse and must **not** refuse
|
||||
with the same sentence: `>` says drill is unavailable, `i` says drilling into an
|
||||
aggregate needs the host. Collapsing them into one message would be the
|
||||
tempting tidy-up and would lose the distinction between "this key does nothing
|
||||
here yet" and "this cell is not one you may write".
|
||||
|
||||
**The injection, and why it is not the obvious one.** Replacing `>`'s sentence
|
||||
with the *aggregate* sentence looks like it should fail on the first assertion —
|
||||
and does not: that sentence contains both "drill" and "browser", so it clears
|
||||
the substring check. What fires is the anti-aggregate assertion lower down. The
|
||||
claim that the second assertion is the load-bearing one was doubted on review,
|
||||
tested, and confirmed; record it here because the first assertion reads like the
|
||||
discriminating one and is not.
|
||||
|
||||
The harness sibling (`drill.spec.js`) asserts the same distinction in a browser,
|
||||
but its `not.toContainText("aggregate")` is **decoration under the only
|
||||
injection that exists** — Playwright throws at the positive assertion above it
|
||||
first, so that line is never reached. The Rust assertion is the guard; the
|
||||
harness one is end-to-end confirmation that the string survives the wire.
|
||||
|
||||
### a_view_row_click_focuses_moves_and_switches_to_the_row_clicked
|
||||
|
||||
improvise-iioi, and it is the **same trap** as the category click one level
|
||||
over: the activation must be resolved against the row **clicked**, never
|
||||
`view_panel_cursor`, because the `SetPanelCursor` ahead of it has not been
|
||||
folded when the resolver runs. Injecting exactly that — reading the name at the
|
||||
cursor instead of at `index` — yields
|
||||
`SwitchView("Default")` beside `SetPanelCursor { cursor: 1 }`, i.e. the cursor
|
||||
lands on `Plan` and the document switches to `Default`. Silent, and wrong in the
|
||||
half a user does not watch.
|
||||
|
||||
The fixture therefore holds **two** views and clicks the **second**. With one
|
||||
view, or with a click on the row already under the cursor, the bug is invisible
|
||||
— the same reason `a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row`
|
||||
clicks Product rather than `_Index`.
|
||||
|
||||
### a_formula_row_click_navigates_and_activates_nothing
|
||||
|
||||
The negative arm, and it is a statement about the *rule* rather than about the
|
||||
formula panel. The rule is "go there, then do what `Enter` does there";
|
||||
`ModeKey::FormulaPanel`'s keymap binds `a`/`n`/`o` to `enter-mode formula-edit`
|
||||
and `d`/`Delete` to delete, and **no `Enter` at all**. So the rule yields
|
||||
navigation and nothing else, and that is the rule applying rather than the fix
|
||||
stopping half way.
|
||||
|
||||
This test asserts the *exact* command vector, which is the whole of what it can
|
||||
say. It once carried a second, explanatory `assert!` that no `FormulaEdit` mode
|
||||
change appears — **which could never have been the failing assertion**, because
|
||||
the `assert_eq!` above it pins the same three commands and aborts first. A
|
||||
reviewer demonstrated it by injecting a `ChangeMode(FormulaEdit)` and watching
|
||||
the panic land on the `assert_eq!` with the explanatory message never printed.
|
||||
It was a comment wearing an `assert!`, and the argument now lives in
|
||||
`context/code/clients.md` where a comment run cannot hold it.
|
||||
|
||||
### the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does
|
||||
|
||||
The guard that the removed assertion was pretending to be. The asymmetry — a
|
||||
view-row click activates, a formula-row click does not — is a fact about the
|
||||
**keymap**, and nothing tied the click to it. The reviewer proved the gap by
|
||||
adding `Enter -> enter-mode formula-edit` to `ModeKey::FormulaPanel` and running
|
||||
the whole workspace: **1381 passing, nothing failed**, while `click_formula_row`
|
||||
still activated nothing. The click and the keyboard then disagree about what a
|
||||
formula row does, silently.
|
||||
|
||||
Injecting that same binding now fails this test and only this test. The failure
|
||||
message names `click_formula_row` as what must change, because the person who
|
||||
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.
|
||||
|
||||
#### The view and formula row clicks in the browser (improvise-iioi)
|
||||
|
||||
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 leave the panel **open but unfocused** — the only state in which the
|
||||
*focus* third of the click is observable.
|
||||
|
||||
**History, not current evidence:** the original pair was proved against the
|
||||
bundle built immediately before the fix, both failing there while the
|
||||
pre-existing pointer assertions passed. That run was against
|
||||
`harness/tests/pointer.spec.js`, which no longer exists. What holds the
|
||||
relocated assertions honest is the injection below, run in their new home.
|
||||
|
||||
The view test asserts three things — `#view-name`, the `active` marker and the
|
||||
`selected` cursor — because switching ends in `NORMAL` and a test that only
|
||||
checked the mode would pass against a click that did nothing but leave the
|
||||
panel. **The relocation initially dropped two of the three**, and a reviewer
|
||||
caught it; they are restored.
|
||||
|
||||
**And the fixture was vacuous in a way the prose had already warned about.**
|
||||
`Default` is index 0 and holds `view_panel_cursor` by default, so clicking it —
|
||||
which both the old `pointer.spec.js` and the first relocated version did — makes
|
||||
`index` and the cursor the same number, and reading either gives the same name.
|
||||
A reviewer injected `switch_view_at(state, state.view.view_panel_cursor)` into
|
||||
`click_view_row` and **the whole contract suite passed 3/3**: the exact fault
|
||||
this test, the ticket and the Rust test all exist for was invisible to the
|
||||
browser half. The fixture now switches to `Default` by **keyboard**, leaving the
|
||||
cursor there, then **clicks `Plan`** — so the two readings name different rows
|
||||
and the outcomes differ. Re-running that injection now fails it.
|
||||
|
||||
The blindness was inherited rather than introduced: the deleted file clicked
|
||||
`Default` too. It became worth fixing because the relocated test is now the
|
||||
*only* browser assertion on that surface.
|
||||
|
||||
**One fact the reviewer's report supplied and this fix leans on**: shipping the
|
||||
cure for one panel and not its two siblings *reproduces the original report*. A
|
||||
user who has just learned category rows are clickable clicks a view row and
|
||||
reads the silence as broken — which is literally what "I can't expand
|
||||
categories" meant.
|
||||
|
||||
#### The harness port: reuse is now opt-in
|
||||
|
||||
`reuseExistingServer: !process.env.CI` became `=== "1"` on
|
||||
`IMPROVISE_HARNESS_REUSE`. A default-on reuse adopts a server another checkout
|
||||
left on the port and then tests **that tree**, which fired in both directions in
|
||||
one session — a false negative and, worse, a false *positive*. A path-hash
|
||||
default was considered and rejected: it still silently reuses when two agents
|
||||
share a worktree path, where an explicit opt-in cannot. CI is unaffected (it
|
||||
never reused) and a developer wanting the old speed sets the variable.
|
||||
|
||||
### a_click_that_cannot_compose_its_activation_composes_nothing
|
||||
|
||||
improvise-hkr8. `click_category_row` ended with
|
||||
`filter_to_item_at(state, index).unwrap_or_default()`, and that `None` arm is
|
||||
not "nothing to do" — it is `BasisCache::claim` answering that the server has
|
||||
published no basis yet (gotcha 43), so the document change cannot be composed.
|
||||
The click therefore kept its **navigation prefix** (focus the panel, move the
|
||||
cursor), dropped its activation, and reported success. The keyboard handles the
|
||||
identical `None` correctly, by mapping `step(..) == None` to
|
||||
`KeyOutcome::Upstream`.
|
||||
|
||||
The fixture has to do two things a careless one would not. It **expands
|
||||
`Region`** so that row 1 is an `Item` row — a header row activates via
|
||||
`ToggleCatExpand`, which needs no basis and so cannot show the bug — and it
|
||||
asserts both preconditions (`cat_tree_entry_at` is an `Item`, `claim` is `None`)
|
||||
rather than assuming them, because an empty `ClientState` makes every
|
||||
state-dependent refusal look like the one under test.
|
||||
|
||||
Injecting the old `unwrap_or_default()` fails this test and only this test.
|
||||
|
||||
### the_same_click_composes_everything_once_a_basis_is_published
|
||||
|
||||
The positive arm of the one above, and it is what stops the fix from being
|
||||
"return nothing more often". With a basis published, the same click composes all
|
||||
six commands: open, focus, move the cursor, two `Command::Model`s and the
|
||||
status. Without this, deleting the activation entirely would pass hkr8's test.
|
||||
|
||||
### a_first_tile_click_selects_without_touching_the_document
|
||||
|
||||
The user's ruling: a click selects, a second click cycles. The fixture publishes
|
||||
a basis **deliberately**, so the absence of a `Command::Model` is a decision
|
||||
rather than a client that could not compose one — pointed at an unpublished
|
||||
basis this test would pass for hkr8's reason instead of its own.
|
||||
|
||||
Both halves are asserted, because "the axis did not change" passes against a
|
||||
page where nothing happened at all: the positive half requires `SetTileCatIdx`
|
||||
plus `ChangeMode(TileSelect)`, the negative half requires no `Command::Model`.
|
||||
|
||||
**Four injections, four distinct signatures.** "First click also cycles" fails
|
||||
this test, `the_first_click_on_tile_zero_selects_it_like_any_other` and
|
||||
`clicking_a_different_tile_selects_it_rather_than_cycling_it` — and **not**
|
||||
`a_second_click_on_the_selected_tile_cycles_its_axis`. "Second click does not
|
||||
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
|
||||
tile" reads as an index comparison, and `tile_cat_idx` is **0 before anything
|
||||
has selected anything** — so an index-only test would read tile 0 as already
|
||||
selected and cycle it on its very first click, making the non-mutation
|
||||
guarantee true of every tile but one. The condition is `mode == TileSelect`
|
||||
**and** `tile_cat_idx == index`.
|
||||
|
||||
### clicking_a_different_tile_selects_it_rather_than_cycling_it
|
||||
|
||||
The other half of the same trap, from the opposite side: without it, "a click
|
||||
does not mutate" would hold only for the first click of a session, since every
|
||||
click after the first is a "second click" in the loose reading. Clicking A then
|
||||
B must select B.
|
||||
|
||||
#### How to know a launch default has stopped disarming things
|
||||
|
||||
The question this left open was **how to know you have exhausted the class**.
|
||||
Grep does not answer it: grep finds sites that *name* the flag, and the hazard
|
||||
is a test that merely **starts from a default-built state** and asserts
|
||||
something downstream, mentioning nothing. Three disarmed tests were found by
|
||||
grepping `category_panel_open`; that was luck, not method.
|
||||
|
||||
**Mutation answers it, because it does not care what a test mentions.** The
|
||||
recipe, which a reviewer ran across five sites in about twenty minutes:
|
||||
|
||||
> For every production site that **writes, propagates or reads** the value,
|
||||
> replace it with the constant the default supplies, and run
|
||||
> `--no-fail-fast`. Sites where nothing fails are suspects. Then **re-run each
|
||||
> suspect with the default inverted.** If it now fails something, the test was
|
||||
> **vacuous** — the default disarmed it, and the fix is the fixture. If it
|
||||
> still fails nothing, it was simply **unguarded** — a different problem,
|
||||
> pre-existing, and a bead rather than a fixture.
|
||||
|
||||
**The second step is the one that matters**, and without it the two classes are
|
||||
indistinguishable: both look like "nothing failed". It is what established that
|
||||
the class is closed at **two** (`set_panel_open_and_cursor`,
|
||||
`panel_commands_address_the_named_panel_only`) and that two further silent sites
|
||||
are older problems this ruling did not cause:
|
||||
|
||||
- `ui/app.rs`'s `App::cmd_context` propagator — hardcode it `true` and the whole
|
||||
workspace passes. `registry.rs` reads it for `toggle-panel-and-focus`'s
|
||||
open-vs-close decision, so `C` on a closed panel would decide to *close* and
|
||||
nothing would fail. Invisible because `cmd/mod.rs`'s test helper sets the
|
||||
field explicitly, so no command test reaches the propagator. Filed.
|
||||
- `improvise-wasm-client/src/render.rs`'s `RenderModel` field — hardcode it
|
||||
`true` and **Rust sees nothing at all**; only the Chromium harness catches it,
|
||||
at `contract.spec.js`'s `toBeHidden`. That is literally improvise-wsaw
|
||||
reintroduced one layer up, and **a Rust-only CI run cannot see it** — which is
|
||||
the sharpest argument on file for why the browser job must be able to fail the
|
||||
build.
|
||||
|
||||
**And a repair needs its own probe, because a repair can be shaped like a fix.**
|
||||
The two-step recipe above finds a *disarmed* test; it does not tell you whether
|
||||
the repair you then write is real. That gap is not hypothetical — the first
|
||||
repair of `panel_commands_address_the_named_panel_only` folded
|
||||
`[open:false, open:true]`, read as a fix in review, and was still vacuous.
|
||||
|
||||
The probe that settles it is a **one-directional** fault: make the writer
|
||||
`if open { set true }`, so the flag can be raised but never lowered. **A vacuous
|
||||
repair cannot see this**, because a test that only ever asserts the `true`
|
||||
direction is satisfied by a writer that only ever writes `true`. Injected into
|
||||
both copies at once, it fails `set_panel_open_and_cursor` **and**
|
||||
`panel_commands_address_the_named_panel_only`, each in its own copy — which is
|
||||
what establishes both repairs are genuine rather than merely differently
|
||||
spelled. Prefer it to re-reading the diff: the diff contained `open: false` in
|
||||
the version that was vacuous.
|
||||
|
||||
Note `resolve.rs`'s `panel_open` reader is **well** guarded by contrast:
|
||||
hardcoding it `true` fails two click tests. So the picture is not "everything
|
||||
touching this flag is untested"; it is specifically the propagators.
|
||||
|
||||
### the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it
|
||||
|
||||
**Prospective rather than present-tense, and the doc comment says so.** With
|
||||
three panels, `the_terminal_app_launches_with_the_category_panel_open` and
|
||||
`the_other_two_panels_are_shut_at_launch` already cover every `Panel::ALL`
|
||||
member, so this test cannot currently fail without one of them failing too. A
|
||||
reviewer confirmed that by control.
|
||||
|
||||
It earns its place the way gotcha 12 describes `Panel::ALL`: a **fourth** panel
|
||||
reaches it from one `ALL` entry, and at that moment it becomes the only test
|
||||
asserting that `App::new` did not quietly give the TUI its own launch state —
|
||||
the divergence improvise-wsaw closed. Recorded here so nobody later reads a
|
||||
green tick as evidence of something it is not yet checking.
|
||||
|
||||
### the_terminal_app_launches_with_the_category_panel_open
|
||||
|
||||
The user ruled (2026-09-20) that the category sidebar is visible at launch in
|
||||
the **TUI as well as** the browser, so `improvise model.improv` opening with it
|
||||
showing is intended. This is the guard on the front end the ruling newly
|
||||
changes: `a_fresh_session_opens_with_the_category_panel` asserts
|
||||
`ViewState::default()` in the *protocol* crate, and nothing downstream of it.
|
||||
|
||||
Why not a rendered-frame test: design-principles §7 says not to test ratatui
|
||||
`Widget::render`, and there is no `TestBackend` anywhere in this repo. The
|
||||
honest target is the TUI's own constructor. `src/draw.rs`'s one-line read of the
|
||||
flag stays argued-not-tested, as gotcha 48 has it.
|
||||
|
||||
Three injections. Putting the shared default back to `false` fails this test and
|
||||
`a_fresh_session_opens_with_the_category_panel`. Making `App::new` override the
|
||||
default (`app.view_state.category_panel_open = false`) fails this test **and**
|
||||
`the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it` —
|
||||
which is the one that would catch a TUI-only launch state being reintroduced, the
|
||||
divergence improvise-wsaw closed. `the_other_two_panels_are_shut_at_launch`
|
||||
stops "open everything at launch" passing the first.
|
||||
|
||||
### set_panel_open_and_cursor
|
||||
|
||||
**The launch default silently disarmed this test, and that is the finding.** It
|
||||
did `SetPanelOpen { Category, open: true }` then asserted the flag was true — but
|
||||
`test_app()` builds from `ViewState::default()`, where it is *already* true. Its
|
||||
Formula and View siblings still default to `false`, so only the category arm
|
||||
went vacuous.
|
||||
|
||||
What that cost, demonstrated: replacing `Panel::Category =>
|
||||
app.view_state.category_panel_open = self.open` with a **no-op** — so no effect
|
||||
could open or close the TUI's category sidebar at all — left the entire
|
||||
workspace green. `C` could be made to do nothing in the terminal with 1391 tests
|
||||
passing.
|
||||
|
||||
The fix is the fixture stating its own precondition: close the panel first and
|
||||
assert it closed, so the effect has to move the flag before the `true` direction
|
||||
is asserted. Injecting the no-op now fails this test.
|
||||
|
||||
Note the harness cannot see this one, and that is correct rather than a gap: the
|
||||
browser folds `Command::SetPanelOpen` through `reduce_view`, a *different*
|
||||
three-way match in a different crate from `effect::SetPanelOpen`'s. Two
|
||||
derivations of one mapping — which is why `panel_commands_address_the_named_panel_only`
|
||||
needed the same repair independently.
|
||||
|
||||
### panel_commands_address_the_named_panel_only
|
||||
|
||||
The `reduce_view` half of the one above, and **the first repair of it was still
|
||||
vacuous** — worth recording because the mistake is subtle. Folding
|
||||
`[open: false, open: true]` and asserting the final state is *true* passes
|
||||
against a reducer that folds nothing at all: the default is already true, so
|
||||
neither command needs to land. Only asserting the **`false`** direction
|
||||
discriminates, because that is the one the default cannot supply.
|
||||
|
||||
Caught by injection rather than by reading — the no-op fold reported NONE
|
||||
failing after the first fix. Now a separate `closed` fold asserts the flag
|
||||
moved, before the original assertions run.
|
||||
|
||||
### a_fresh_session_opens_with_the_category_panel
|
||||
|
||||
The user's ruling that the category panel is visible at launch, implemented as
|
||||
the **shared** `ViewState::default()` rather than a browser special case — a
|
||||
browser-only default is "the page overrides the flag", which is the same defect
|
||||
as the "the page ignores the flag" bug improvise-wsaw fixed. The consequence,
|
||||
accepted deliberately, is that the TUI opens with the panel too.
|
||||
|
||||
The test also asserts the *other two* panels stay shut, so it cannot pass
|
||||
against a default that simply opens everything.
|
||||
|
||||
Two existing tests had to change, and both were pinning the old decision by
|
||||
**inheriting** it rather than stating it.
|
||||
`panel_focus_cycles_from_the_focused_panel_not_the_first` is about skipping a
|
||||
*closed* panel and took "the category panel is closed" from the default; it now
|
||||
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.
|
||||
|
||||
#### 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. 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
|
||||
focused — so the test presses it twice and the hide is the second press.
|
||||
|
||||
The tile tests were rewritten rather than adjusted, because the previous one
|
||||
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
|
||||
|
||||
@@ -2263,6 +2263,15 @@ mod tests {
|
||||
.apply(&mut app);
|
||||
assert_eq!(app.view_state.formula_cursor, 3);
|
||||
|
||||
// Closed first: the category panel is OPEN at launch, so asserting
|
||||
// `true` straight away would pass against an effect that does nothing.
|
||||
SetPanelOpen {
|
||||
panel: Panel::Category,
|
||||
open: false,
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert!(!app.view_state.category_panel_open);
|
||||
|
||||
SetPanelOpen {
|
||||
panel: Panel::Category,
|
||||
open: true,
|
||||
|
||||
@@ -372,7 +372,7 @@ impl Default for ViewState {
|
||||
search_query: String::new(),
|
||||
search_mode: false,
|
||||
formula_panel_open: false,
|
||||
category_panel_open: false,
|
||||
category_panel_open: true,
|
||||
view_panel_open: false,
|
||||
cat_panel_cursor: 0,
|
||||
view_panel_cursor: 0,
|
||||
|
||||
@@ -305,6 +305,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn panel_commands_address_the_named_panel_only() {
|
||||
// Closing it is what discriminates: the category panel is OPEN at
|
||||
// launch, so any assertion that it is open passes against a no-op.
|
||||
let closed = fold(&[Command::SetPanelOpen {
|
||||
panel: Panel::Category,
|
||||
open: false,
|
||||
}]);
|
||||
assert!(!closed.category_panel_open, "the fold moved the flag");
|
||||
|
||||
let view = fold(&[
|
||||
Command::SetPanelOpen {
|
||||
panel: Panel::Category,
|
||||
|
||||
@@ -72,30 +72,62 @@ pub fn resolve(state: &ClientState, event: KeyEvent) -> KeyOutcome {
|
||||
pub enum Click {
|
||||
/// A row of the category panel, indexing the flattened tree.
|
||||
CategoryRow(usize),
|
||||
/// A row of the view panel, indexing `ViewListCache::views`.
|
||||
ViewRow(usize),
|
||||
/// A row of the formula panel, indexing `FormulaCache::formulas`.
|
||||
FormulaRow(usize),
|
||||
/// A tile, indexing `TileBarCache::tiles`.
|
||||
Tile(usize),
|
||||
/// A grid cell, in **viewport** coordinates as the page painted it.
|
||||
Cell { row: usize, col: usize },
|
||||
}
|
||||
|
||||
/// One rule for all three widgets: **go there, then do what `Enter` does
|
||||
/// there.** Why that rather than three answers: `context/code/clients.md`.
|
||||
/// One rule for all the widgets: **go there, then do what `Enter` does
|
||||
/// there.** Why that rather than one answer each: `context/code/clients.md`.
|
||||
pub fn click(state: &ClientState, target: Click) -> Vec<Command> {
|
||||
match target {
|
||||
// `None` here means the whole click composed nothing, which is the
|
||||
// invariant rather than a breach of it (improvise-hkr8) — clients.md.
|
||||
let resolved = match target {
|
||||
Click::CategoryRow(index) => click_category_row(state, index),
|
||||
Click::ViewRow(index) => click_view_row(state, index),
|
||||
Click::FormulaRow(index) => click_formula_row(state, index),
|
||||
Click::Tile(index) => click_tile(state, index),
|
||||
Click::Cell { row, col } => click_cell(state, row, col),
|
||||
}
|
||||
};
|
||||
resolved.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Focus the panel, move its cursor, then switch — what `V` then `Enter` do,
|
||||
/// in one gesture. `switch-view-at-cursor` is the activation.
|
||||
fn click_view_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
||||
let mut commands = focus(state, Panel::View);
|
||||
commands.push(Command::SetPanelCursor {
|
||||
panel: Panel::View,
|
||||
cursor: index,
|
||||
});
|
||||
// Off `index`, never the cursor: `SetPanelCursor` is not folded yet.
|
||||
commands.extend(switch_view_at(state, index)?);
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// The formula panel binds **no** `Enter`, so the rule yields navigation and
|
||||
/// nothing else. That is the rule applying, not an omission — clients.md.
|
||||
fn click_formula_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
||||
state.formulas.formulas.get(index)?;
|
||||
let mut commands = focus(state, Panel::Formula);
|
||||
commands.push(Command::SetPanelCursor {
|
||||
panel: Panel::Formula,
|
||||
cursor: index,
|
||||
});
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// Focus the panel, move its cursor, then activate — the three things the
|
||||
/// keyboard needs three keystrokes for, and `filter-to-item` is the activation.
|
||||
fn click_category_row(state: &ClientState, index: usize) -> Vec<Command> {
|
||||
fn click_category_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
||||
// A click past the last row is a click on the panel's empty space, which
|
||||
// is not a row and must not move the cursor onto one.
|
||||
if cat_tree_entry_at(state, index).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
cat_tree_entry_at(state, index)?;
|
||||
let mut commands = focus(state, Panel::Category);
|
||||
commands.push(Command::SetPanelCursor {
|
||||
panel: Panel::Category,
|
||||
@@ -103,35 +135,41 @@ fn click_category_row(state: &ClientState, index: usize) -> Vec<Command> {
|
||||
});
|
||||
// Against `index`, never the cursor: the `SetPanelCursor` above is not
|
||||
// folded yet, so the cursor still reads the old row. Evidence file.
|
||||
commands.extend(filter_to_item_at(state, index).unwrap_or_default());
|
||||
commands
|
||||
commands.extend(filter_to_item_at(state, index)?);
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// Select the tile and cycle its axis — `TileSelect` plus what `Enter` does
|
||||
/// in it. A tile the bar does not have is not a click.
|
||||
fn click_tile(state: &ClientState, index: usize) -> Vec<Command> {
|
||||
if state.tiles.tiles.get(index).is_none() {
|
||||
return Vec::new();
|
||||
/// Select the tile; a **second** click on the already-selected one cycles its
|
||||
/// axis. Why a click never mutates on its own: `context/code/clients.md`.
|
||||
fn click_tile(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
||||
state.tiles.tiles.get(index)?;
|
||||
// Mode *and* index, because `tile_cat_idx` is 0 before anything selects
|
||||
// anything — index alone would cycle tile 0 on its first click.
|
||||
let already_selected =
|
||||
state.view.mode == AppMode::TileSelect && state.view.tile_cat_idx == index;
|
||||
match already_selected {
|
||||
true => tile_axis_at(state, index, None),
|
||||
false => {
|
||||
let mut commands = vec![Command::SetTileCatIdx(index)];
|
||||
if state.view.mode != AppMode::TileSelect {
|
||||
commands.push(Command::ChangeMode(AppMode::TileSelect));
|
||||
}
|
||||
Some(commands)
|
||||
}
|
||||
}
|
||||
let mut commands = vec![Command::SetTileCatIdx(index)];
|
||||
if state.view.mode != AppMode::TileSelect {
|
||||
commands.push(Command::ChangeMode(AppMode::TileSelect));
|
||||
}
|
||||
commands.extend(tile_axis_at(state, index, None).unwrap_or_default());
|
||||
commands
|
||||
}
|
||||
|
||||
/// Move the selection to a clicked cell. Activation is the page's inline
|
||||
/// editor, which is the pointer's `i` — `context/code/clients.md`.
|
||||
fn click_cell(state: &ClientState, row: usize, col: usize) -> Vec<Command> {
|
||||
fn click_cell(state: &ClientState, row: usize, col: usize) -> Option<Vec<Command>> {
|
||||
let cursor = state.view.cursor();
|
||||
let GridExtent { rows, cols } = state.grid.extent;
|
||||
// The page paints a window; `SetSelected` is absolute (gotcha 42).
|
||||
let (absolute_row, absolute_col) = (row + cursor.row_offset, col + cursor.col_offset);
|
||||
if absolute_row >= rows || absolute_col >= cols {
|
||||
return Vec::new();
|
||||
return None;
|
||||
}
|
||||
scrolled_to(state, absolute_row, absolute_col)
|
||||
Some(scrolled_to(state, absolute_row, absolute_col))
|
||||
}
|
||||
|
||||
/// Open a panel and focus it, emitting only what is not already true — so a
|
||||
@@ -273,13 +311,9 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Op
|
||||
"delete-category-at-cursor" => delete_at_cat_cursor(state),
|
||||
|
||||
// ── the view panel ───────────────────────────────────────────
|
||||
"switch-view-at-cursor" => Some(match view_at_cursor(state) {
|
||||
Some(name) => vec![
|
||||
Command::SwitchView(name),
|
||||
Command::ChangeMode(AppMode::Normal),
|
||||
],
|
||||
None => Vec::new(),
|
||||
}),
|
||||
"switch-view-at-cursor" => {
|
||||
Some(switch_view_at(state, state.view.view_panel_cursor).unwrap_or_default())
|
||||
}
|
||||
"create-and-switch-view" => {
|
||||
let name = format!("View {}", state.views.views.len() + 1);
|
||||
Some(vec![
|
||||
@@ -834,6 +868,16 @@ fn view_at_cursor(state: &ClientState) -> Option<String> {
|
||||
.map(|summary| summary.name.clone())
|
||||
}
|
||||
|
||||
/// `SwitchViewAtCursor::execute`'s whole answer, at an arbitrary index. The
|
||||
/// keyboard passes its cursor and a click passes the row — clients.md.
|
||||
fn switch_view_at(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
||||
let summary = state.views.views.get(index)?;
|
||||
Some(vec![
|
||||
Command::SwitchView(summary.name.clone()),
|
||||
Command::ChangeMode(AppMode::Normal),
|
||||
])
|
||||
}
|
||||
|
||||
/// `DeleteViewAtCursor::execute`, including the cursor step back that only
|
||||
/// happens away from the top of the list.
|
||||
fn delete_view_at_cursor(state: &ClientState) -> Option<Vec<Command>> {
|
||||
@@ -1005,10 +1049,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's ruling: the category panel is open at launch. It is the
|
||||
/// *shared* default, so both front ends open with it — clients.md.
|
||||
#[test]
|
||||
fn a_fresh_session_opens_with_the_category_panel() {
|
||||
let fresh = ViewState::default();
|
||||
assert!(
|
||||
fresh.category_panel_open,
|
||||
"a client that has pressed nothing can already see its categories"
|
||||
);
|
||||
assert!(
|
||||
!fresh.formula_panel_open && !fresh.view_panel_open,
|
||||
"and only that one — the ruling names the category panel alone"
|
||||
);
|
||||
}
|
||||
|
||||
// ── the pointer (improvise-7ybz) ────────────────────────────────
|
||||
|
||||
fn with_categories() -> ClientState {
|
||||
let mut state = ClientState::default();
|
||||
// Explicitly shut: these tests are about a click *opening* the panel,
|
||||
// which the launch default would otherwise answer for them.
|
||||
state.view.category_panel_open = false;
|
||||
state.fold(&Command::Project(Projection::CategoryTree(
|
||||
crate::CategoryTreeUpdate::Categories(vec![
|
||||
crate::CategoryRow {
|
||||
@@ -1081,6 +1143,286 @@ mod tests {
|
||||
fn a_click_past_the_last_row_resolves_to_nothing() {
|
||||
assert!(click(&with_categories(), Click::CategoryRow(9)).is_empty());
|
||||
assert!(click(&ClientState::default(), Click::Tile(0)).is_empty());
|
||||
assert!(click(&ClientState::default(), Click::ViewRow(0)).is_empty());
|
||||
assert!(click(&ClientState::default(), Click::FormulaRow(0)).is_empty());
|
||||
}
|
||||
|
||||
/// Two tiles and a **published basis**, so a missing `Command::Model` is
|
||||
/// a decision rather than a client that could not compose one.
|
||||
fn with_tiles() -> ClientState {
|
||||
let mut state = ClientState::default();
|
||||
state.bootstrap(Snapshot {
|
||||
view: ViewState::default(),
|
||||
seed: vec![Projection::Basis(published())],
|
||||
});
|
||||
state.tiles.tiles = vec![
|
||||
crate::TileEntry {
|
||||
category: "Region".into(),
|
||||
axis: Axis::Row,
|
||||
},
|
||||
crate::TileEntry {
|
||||
category: "Product".into(),
|
||||
axis: Axis::Column,
|
||||
},
|
||||
];
|
||||
state
|
||||
}
|
||||
|
||||
/// The user's ruling: a click **selects**, it never mutates. The basis is
|
||||
/// published here, so nothing stops the cycle but the rule itself.
|
||||
#[test]
|
||||
fn a_first_tile_click_selects_without_touching_the_document() {
|
||||
let state = with_tiles();
|
||||
let commands = click(&state, Click::Tile(1));
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec![
|
||||
Command::SetTileCatIdx(1),
|
||||
Command::ChangeMode(AppMode::TileSelect),
|
||||
],
|
||||
"select and enter TileSelect, and stop there"
|
||||
);
|
||||
assert!(
|
||||
!commands.iter().any(|c| matches!(c, Command::Model { .. })),
|
||||
"a click must not change the document; the axis cycle needs a second one"
|
||||
);
|
||||
}
|
||||
|
||||
/// `tile_cat_idx` is 0 before anything has selected anything, so the
|
||||
/// already-selected test needs the **mode** as well as the index.
|
||||
#[test]
|
||||
fn the_first_click_on_tile_zero_selects_it_like_any_other() {
|
||||
let state = with_tiles();
|
||||
assert_eq!(state.view.tile_cat_idx, 0, "the default index is the trap");
|
||||
let commands = click(&state, Click::Tile(0));
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec![
|
||||
Command::SetTileCatIdx(0),
|
||||
Command::ChangeMode(AppMode::TileSelect),
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
!commands.iter().any(|c| matches!(c, Command::Model { .. })),
|
||||
"index alone would read tile 0 as already selected and cycle it"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn a_second_click_on_the_selected_tile_cycles_its_axis() {
|
||||
let mut state = with_tiles();
|
||||
state.view.mode = AppMode::TileSelect;
|
||||
state.view.tile_cat_idx = 1;
|
||||
assert_eq!(
|
||||
click(&state, Click::Tile(1)),
|
||||
vec![
|
||||
Command::Model {
|
||||
basis: state.basis.claim([]).unwrap(),
|
||||
cmd: ModelCommand::CycleAxis("Product".into()),
|
||||
},
|
||||
// `Column.next()` is `Page` — the *new* axis, as `Enter` says.
|
||||
Command::SetStatus("Product → Page".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Clicking A then B selects B. Cycling B would make "a click does not
|
||||
/// mutate" true only of the first click in a session.
|
||||
#[test]
|
||||
fn clicking_a_different_tile_selects_it_rather_than_cycling_it() {
|
||||
let mut state = with_tiles();
|
||||
state.view.mode = AppMode::TileSelect;
|
||||
state.view.tile_cat_idx = 0;
|
||||
let commands = click(&state, Click::Tile(1));
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec![Command::SetTileCatIdx(1)],
|
||||
"already in TileSelect, so only the index moves"
|
||||
);
|
||||
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]
|
||||
fn a_click_that_cannot_compose_its_activation_composes_nothing() {
|
||||
let mut state = with_categories();
|
||||
state.view.expanded_cats.insert("Region".into());
|
||||
assert!(
|
||||
matches!(
|
||||
cat_tree_entry_at(&state, 1),
|
||||
Some(CatTreeEntry::Item { .. })
|
||||
),
|
||||
"row 1 is an item row, whose activation is a document change"
|
||||
);
|
||||
assert!(
|
||||
state.basis.claim([]).is_none(),
|
||||
"and this client has been published no basis"
|
||||
);
|
||||
assert!(
|
||||
click(&state, Click::CategoryRow(1)).is_empty(),
|
||||
"the focus and the cursor move must not survive the activation's failure"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same click, once the basis has arrived: all four intents.
|
||||
#[test]
|
||||
fn the_same_click_composes_everything_once_a_basis_is_published() {
|
||||
let mut state = with_categories();
|
||||
state.view.expanded_cats.insert("Region".into());
|
||||
state.fold(&Command::Project(Projection::Basis(published())));
|
||||
let basis = state.basis.claim([]).unwrap();
|
||||
assert_eq!(
|
||||
click(&state, Click::CategoryRow(1)),
|
||||
vec![
|
||||
Command::SetPanelOpen {
|
||||
panel: Panel::Category,
|
||||
open: true
|
||||
},
|
||||
Command::ChangeMode(AppMode::CategoryPanel),
|
||||
Command::SetPanelCursor {
|
||||
panel: Panel::Category,
|
||||
cursor: 1
|
||||
},
|
||||
Command::Model {
|
||||
basis: basis.clone(),
|
||||
cmd: ModelCommand::SetAxis {
|
||||
category: "Region".into(),
|
||||
axis: Axis::Page
|
||||
},
|
||||
},
|
||||
Command::Model {
|
||||
basis,
|
||||
cmd: ModelCommand::SetPageSelection {
|
||||
category: "Region".into(),
|
||||
item: "North".into()
|
||||
},
|
||||
},
|
||||
Command::SetStatus("Filter: Region = North".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn with_panels() -> ClientState {
|
||||
let mut state = ClientState::default();
|
||||
state.fold(&Command::Project(Projection::ViewList(vec![
|
||||
crate::ViewSummary {
|
||||
name: "Default".into(),
|
||||
axes: vec![],
|
||||
},
|
||||
crate::ViewSummary {
|
||||
name: "Plan".into(),
|
||||
axes: vec![],
|
||||
},
|
||||
])));
|
||||
state.fold(&Command::Project(Projection::Formulas(vec![
|
||||
"Profit = Revenue - Cost".into(),
|
||||
"Margin = Profit / Revenue".into(),
|
||||
])));
|
||||
state
|
||||
}
|
||||
|
||||
/// improvise-iioi: the same three intents the category panel gets, with
|
||||
/// `switch-view-at-cursor` as the activation.
|
||||
#[test]
|
||||
fn a_view_row_click_focuses_moves_and_switches_to_the_row_clicked() {
|
||||
let state = with_panels();
|
||||
assert_eq!(
|
||||
click(&state, Click::ViewRow(1)),
|
||||
vec![
|
||||
Command::SetPanelOpen {
|
||||
panel: Panel::View,
|
||||
open: true
|
||||
},
|
||||
Command::ChangeMode(AppMode::ViewPanel),
|
||||
Command::SetPanelCursor {
|
||||
panel: Panel::View,
|
||||
cursor: 1
|
||||
},
|
||||
// `Plan`, the row clicked — not `Default`, where the cursor is.
|
||||
Command::SwitchView("Plan".into()),
|
||||
Command::ChangeMode(AppMode::Normal),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The formula panel binds no `Enter`, so the rule yields navigation and
|
||||
/// nothing else. A mode change here would be an invented activation.
|
||||
#[test]
|
||||
fn a_formula_row_click_navigates_and_activates_nothing() {
|
||||
let state = with_panels();
|
||||
let commands = click(&state, Click::FormulaRow(1));
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec![
|
||||
Command::SetPanelOpen {
|
||||
panel: Panel::Formula,
|
||||
open: true
|
||||
},
|
||||
Command::ChangeMode(AppMode::FormulaPanel),
|
||||
Command::SetPanelCursor {
|
||||
panel: Panel::Formula,
|
||||
cursor: 1
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The asymmetry is a fact about the keymap, so it is asserted against
|
||||
/// the keymap — not against a reading of it. See `context/code/clients.md`.
|
||||
#[test]
|
||||
fn the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does() {
|
||||
let keymaps = Keymaps::shared();
|
||||
assert!(
|
||||
keymaps
|
||||
.lookup(&ModeKey::FormulaPanel, KeyCode::Enter, KeyModifiers::NONE)
|
||||
.is_none(),
|
||||
"if FormulaPanel gains an Enter binding, click_formula_row must \
|
||||
activate too — the click mirrors Enter, and this is the only \
|
||||
thing tying the two together"
|
||||
);
|
||||
assert!(
|
||||
keymaps
|
||||
.lookup(&ModeKey::ViewPanel, KeyCode::Enter, KeyModifiers::NONE)
|
||||
.is_some(),
|
||||
"and the view panel's click activates because its Enter does"
|
||||
);
|
||||
}
|
||||
|
||||
/// The pointer and the keyboard are one cursor: a click resolves to the
|
||||
@@ -1415,9 +1757,12 @@ mod tests {
|
||||
/// to `enter-mode normal` inside a panel, so no key reaches them.
|
||||
#[test]
|
||||
fn panel_focus_cycles_from_the_focused_panel_not_the_first() {
|
||||
// Spelled out rather than inherited: this test is about skipping a
|
||||
// *closed* panel, so the default's own answer must not decide it.
|
||||
let open_two = |mode: AppMode| ViewState {
|
||||
mode,
|
||||
formula_panel_open: true,
|
||||
category_panel_open: false,
|
||||
view_panel_open: true,
|
||||
..ViewState::default()
|
||||
};
|
||||
@@ -1436,8 +1781,12 @@ mod tests {
|
||||
vec![Command::ChangeMode(AppMode::FormulaPanel)],
|
||||
"and from the last, it wraps"
|
||||
);
|
||||
let nothing_open = ViewState {
|
||||
category_panel_open: false,
|
||||
..ViewState::default()
|
||||
};
|
||||
assert!(
|
||||
cycle_panel_focus(&ViewState::default()).is_empty(),
|
||||
cycle_panel_focus(¬hing_open).is_empty(),
|
||||
"with nothing open there is nowhere to go"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +118,19 @@ impl Client {
|
||||
self.clicked(Click::CategoryRow(index))
|
||||
}
|
||||
|
||||
/// The user clicked row `index` of the view panel: focus, move, switch.
|
||||
#[wasm_bindgen]
|
||||
pub fn click_view(&mut self, index: usize) -> Option<String> {
|
||||
self.clicked(Click::ViewRow(index))
|
||||
}
|
||||
|
||||
/// The user clicked row `index` of the formula panel. Navigation only —
|
||||
/// that panel binds no `Enter` (improvise-iioi).
|
||||
#[wasm_bindgen]
|
||||
pub fn click_formula(&mut self, index: usize) -> Option<String> {
|
||||
self.clicked(Click::FormulaRow(index))
|
||||
}
|
||||
|
||||
/// The user clicked tile `index`: select it and cycle its axis.
|
||||
#[wasm_bindgen]
|
||||
pub fn click_tile(&mut self, index: usize) -> Option<String> {
|
||||
|
||||
@@ -2,9 +2,10 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// The one place a line of web/*.js is executed, and where a change to either
|
||||
// file belongs. Why, and why Playwright: context/code/clients.md.
|
||||
// `reuseExistingServer` will happily adopt a server another checkout started on
|
||||
// this port, and then you test THAT tree. See context/code/clients.md.
|
||||
// Reuse is OPT-IN because adopting another checkout's server tests THAT tree,
|
||||
// and the symptom is a result contradicting the file you just read.
|
||||
const PORT = process.env.IMPROVISE_HARNESS_PORT ?? "8099";
|
||||
const REUSE = process.env.IMPROVISE_HARNESS_REUSE === "1";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
@@ -38,7 +39,7 @@ export default defineConfig({
|
||||
// The APP, not the site root — since improvise-kh8 the root is the landing
|
||||
// page, which would answer 200 with no bundle assembled at all.
|
||||
url: `http://127.0.0.1:${PORT}/app/index.html`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
reuseExistingServer: REUSE,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,12 +41,20 @@ test.describe("every field the render model projects reaches the screen", () =>
|
||||
|
||||
// `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 expect(page.locator("#categories li").first()).toBeVisible();
|
||||
await page.keyboard.press("Shift+C");
|
||||
await expect(page.locator("#categories")).toBeHidden();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// `views`: empty unless open, so the page asks no question about the mode.
|
||||
await expect(page.locator("#views")).toBeHidden();
|
||||
@@ -133,19 +141,22 @@ test.describe("a pointer event reaches the client and its answer is painted", ()
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await expect(cells.nth(3)).toHaveClass(/selected/);
|
||||
|
||||
// A tile: selects and cycles, because a click is the whole interaction.
|
||||
// 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 page.keyboard.press("Shift+C");
|
||||
await expect(page.locator("#categories")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
const productRow = page.locator("#categories li").filter({ hasText: "Product" });
|
||||
await expect(page.locator("#categories li.selected")).toHaveText(/_Index/);
|
||||
await productRow.click();
|
||||
@@ -157,5 +168,33 @@ test.describe("a pointer event reaches the client and its answer is painted", ()
|
||||
"Cold Brew",
|
||||
"Pastry",
|
||||
]);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// A view row (improvise-iioi). The activation must read the CLICKED index,
|
||||
// not `view_panel_cursor` — so the cursor must not already be on the row
|
||||
// clicked, or both readings give the same answer. `Default` is index 0 and
|
||||
// holds the cursor by default, so move to it and click `Plan`.
|
||||
await page.keyboard.press("Shift+V");
|
||||
await expect(page.locator("#views li.selected")).toContainText("Default");
|
||||
// Switch to Default by KEYBOARD, leaving the cursor on it. The click then
|
||||
// names a different row than the cursor does, which is the whole point.
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#view-name")).toHaveText("Default");
|
||||
|
||||
const plan = page.locator("#views li").filter({ hasText: "Plan" }).first();
|
||||
await plan.click();
|
||||
// Reading the cursor instead of the index re-selects Default and fails.
|
||||
await expect(page.locator("#view-name")).toHaveText("Plan");
|
||||
await expect(page.locator("#views li.active")).toContainText("Plan");
|
||||
await expect(page.locator("#views li.selected")).toContainText("Plan");
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The terminal app's first screen carries a decision (improvise-4ibz).
|
||||
//! Why this is not a rendered-frame test: `context/test-evidence/tui.md`.
|
||||
|
||||
use improvise::ui::app::{App, Panel};
|
||||
use improvise::workbook::Workbook;
|
||||
|
||||
fn launched() -> App {
|
||||
App::new(Workbook::new("launch"), None)
|
||||
}
|
||||
|
||||
/// The user's ruling, 2026-09-20: the category sidebar is visible at launch in
|
||||
/// the TUI as well as the browser. `src/draw.rs` draws it off this flag.
|
||||
#[test]
|
||||
fn the_terminal_app_launches_with_the_category_panel_open() {
|
||||
let app = launched();
|
||||
assert!(
|
||||
app.view_state.category_panel_open,
|
||||
"improvise model.improv opens with the category sidebar showing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The ruling names one panel. Without this, "open everything at launch" would
|
||||
/// pass the test above, and the sidebar would be three panels tall.
|
||||
#[test]
|
||||
fn the_other_two_panels_are_shut_at_launch() {
|
||||
let app = launched();
|
||||
assert!(!app.view_state.formula_panel_open, "formulas stay shut");
|
||||
assert!(!app.view_state.view_panel_open, "views stay shut");
|
||||
}
|
||||
|
||||
/// `App::new` could override the shared default without the protocol test
|
||||
/// noticing. PROSPECTIVE with three panels — see the evidence file.
|
||||
#[test]
|
||||
fn the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it() {
|
||||
let shared = improvise::ui::app::ViewState::default();
|
||||
let app = launched();
|
||||
for panel in Panel::ALL {
|
||||
let (in_app, in_default) = (open(&app.view_state, panel), open(&shared, panel));
|
||||
assert_eq!(
|
||||
in_app,
|
||||
in_default,
|
||||
"{} differs between App::new and ViewState::default: a TUI-only \
|
||||
launch default is the divergence improvise-wsaw closed",
|
||||
panel.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn open(view: &improvise::ui::app::ViewState, panel: Panel) -> bool {
|
||||
match panel {
|
||||
Panel::Formula => view.formula_panel_open,
|
||||
Panel::Category => view.category_panel_open,
|
||||
Panel::View => view.view_panel_open,
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -166,8 +166,10 @@ function repaint() {
|
||||
el.categories.hidden = !model.category_panel_open;
|
||||
el.categories.replaceChildren(categoryList(model));
|
||||
paintTiles(model);
|
||||
paintPanel(el.views, model.views, viewRow);
|
||||
paintPanel(el.formulas, model.formulas, formulaRow);
|
||||
paintPanel(el.views, model.views, viewRow, (i) => client.click_view(i));
|
||||
paintPanel(el.formulas, model.formulas, formulaRow, (i) =>
|
||||
client.click_formula(i),
|
||||
);
|
||||
|
||||
// What the frame costs, so the NEXT viewport announcement is honest.
|
||||
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
|
||||
@@ -340,10 +342,14 @@ function edit(td, row, col, seed) {
|
||||
|
||||
/** A side panel. An empty row list is what says it is closed — the page asks
|
||||
* no question about the mode, exactly as `paintHelp` does not. */
|
||||
function paintPanel(host, rows, build) {
|
||||
function paintPanel(host, rows, build, clicked) {
|
||||
host.hidden = rows.length === 0;
|
||||
const list = document.createElement("ul");
|
||||
for (const row of rows) list.appendChild(build(row));
|
||||
rows.forEach((row, index) => {
|
||||
const li = build(row);
|
||||
li.addEventListener("click", () => send(clicked(index)));
|
||||
list.appendChild(li);
|
||||
});
|
||||
host.replaceChildren(list);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,9 @@ test("the page still reaches the client through a handful of calls", () => {
|
||||
[
|
||||
"click_category",
|
||||
"click_cell",
|
||||
"click_formula",
|
||||
"click_tile",
|
||||
"click_view",
|
||||
"on_frame",
|
||||
"on_key_event",
|
||||
"render_model",
|
||||
|
||||
Reference in New Issue
Block a user