Commit Graph

5 Commits

Author SHA1 Message Date
Edward Langley bd3967cc7c fix: guard improvise-mai9 at the call site, and correct §3's group_of
Review of PR #77 found the mai9 fix unprotected and one doc paragraph
contradicting another. Both were right; both are addressed here.

The guard was aimed at the wrong thing
--------------------------------------
The mai9 defect was in the WIRING — `draw_bottom_bar` built
`format!("{}{}", mb.prompt, buf)` plus the cursor glyph and never consulted
`status_msg`. Both tests called the composing helper `minibuffer_line`
DIRECTLY, so nothing exercised the call site. Reverting that one line restored
the bug in full with the suite green at 1411, reproduced here before rewriting.
The helper did not even go dead: the test module's `use` kept it alive while it
asserted nothing about the product.

`a_status_message_reaches_the_bottom_row_while_a_minibuffer_is_open` now renders
`draw_bottom_bar` through a real `Terminal<TestBackend>` and reads the row the
terminal received. That is the only assertion that can fail for the real defect.
`the_minibuffer_prompt_is_not_displaced_by_the_message` is beside it because a
"fix" that dropped to `draw_status` would satisfy the first on the message
alone. Injected fault: the pre-mai9 `format!` — both fail, the first naming the
missing message and printing the row it did compose.

And the negative arm could not fail, twice over
-----------------------------------------------
`fill_line` computes `pad = width - left.len() - right.len()`, so with an empty
status an unconditional `format!("  {msg}  ")` produces a BYTE-IDENTICAL row at
any width where the line fits. The `match status_msg.is_empty()` is observable
only in the band where the prompt fits but `left.len() + 4` does not.

Rewriting it against that band was not enough: the first attempt derived its
lower bound by calling `minibuffer_line` itself, so the injected fault moved the
bound with it and the loop skipped the failing band entirely. The bound is now
spelled locally. A bound read off the function under test is not a bound.

Injected fault: `right` built unconditionally. Now fails at width 16 with
"an empty status still composed 20 columns".

context/code/protocol.md contradicted itself, 270 lines apart
-------------------------------------------------------------
§3 still said `group_of` answers "nearest preceding header", DELIBERATELY, and
that encoding an end boundary "is filed rather than smuggled into" the anr9
migration — while §3a, added by this branch, says `GroupEnd` is the boundary. A
reader arriving at §3 first would conclude this PR smuggled in exactly what that
paragraph forbids. It did not; improvise-gd83 IS the filed ticket. §3a was
written as an addition where it needed to be a CORRECTION, and gotcha 48 turned
on its author — worse than the `bindings.rs` case only because both copies sit
in one file.

§3 now records that the rule changed and which ticket changed it, and §3a points
at the correction rather than restating the history. The neighbouring paragraph
was stale too: `body_from`'s argument no longer exists "only because the two
front ends scroll differently".

Filed, not fixed
----------------
  * improvise-1wl0: `#[serde(default)]` on `AxisFrame::first` forges the
    permissive claim — `first: 0` asserts "this frame leads the axis", which is
    what `body_from` acts on, so a frame that loses the field deserializes as
    leading. Same argument that gave `Basis` no `Default`. No skew ships today.
  * improvise-h0iv: `group_runs` coalesces by name alone where `FrameGroup` has
    `cat` to fix it. Right call for a consolidation — both prior copies did the
    same, and changing the rule while moving it would have cost the agreement
    test its evidence — but a single home reads as decided, which is the new
    risk and the reason to file.

Both are cited in `context/code/protocol.md` §3a, so the single home does not
read as settled.

1412 tests pass (was 1411; +3 wiring tests, -2 helper-only tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:01:02 -07:00
Edward Langley 8f3a9768b7 fix: group membership, leading headers and one run rule on AxisFrame
improvise-gd83, improvise-0o6x and improvise-8qlr. One commit because they are
one change to `AxisFrame`/`AxisEntry` — a variant, a field and a method — and
all three move `tests/grid_frame_agreement.rs`, whose two ends must be changed
together or the test stops comparing anything.

improvise-gd83 — AxisEntry carries a group-end boundary
-------------------------------------------------------
`expand_category` emitted a header on ENTERING a group and no boundary on
leaving one, so `group_of` answered with the nearest preceding header rather
than membership, and the TUI painted that span. Copilot was right on PR #23;
this is the filing gotcha 44 said the fix needed.

`AxisEntry::GroupEnd` is the boundary, carried onto the wire as
`FrameEntry::GroupEnd`. Two arms, and a fix can close one and miss the other:

  * within one expansion — Jan[Q1], Feb[Q1], Sep — a `Some -> None` transition;
  * across expansions — `cross_product` expands the inner category once per
    prefix and each call starts with `last_group = None`, so Food/Bob[Family]
    is followed directly by Clothing/Carol with no transition to hook onto.
    That needs a `GroupEnd` at the END of an expansion whose last item was
    grouped.

`GroupEnd` is NOT emitted between two groups: the next header already ends the
previous run, and emitting one there moved `data_row_to_visual_skips_headers`
and its column twin for no gain. Those two are untouched, which is the evidence
the boundary is as narrow as it claims. A trailing marker is trimmed, since the
axis itself ends the last run. Nothing draws a `GroupEnd` and it consumes no
data index.

`THE_MEMBERSHIP_CORRECT_SPANS` and its `assert_ne!` tripwire are gone, and the
value they predicted is now asserted element for element — it was right to the
element, which is worth recording.

improvise-0o6x — a leading group header is drawn
------------------------------------------------
`body_from(idx)` started at the first ITEM, so at the top of an axis a leading
`FrameEntry::Group` was dropped on the way to the screen, in both front ends. A
collapsed group first on its axis therefore had its rows hidden and its header
nowhere: no affordance to get them back.

It is a boundary condition as the ticket suspected, but the boundary is not
`idx == 0`. The windowing rule carries the nearest header ABOVE a scrolled
window for `group_of` and `body_from` discards it — correctly — and a frame
cannot tell that from the top of the axis using `idx` alone, because the
browser's frame is already windowed and always passes `0`. So `AxisFrame`
gained `first`, the absolute data index of its first item, and the rule is
`first + idx == 0`. Without it the browser would draw an extra header row at
every scrolled position that the TUI does not.

RE-PINNED DELIBERATELY, the improvise-q0vb acceptance shape: `GROUPED_FRAME` in
`src/ui/grid.rs` moves by exactly one line — the leading `▼ Co…` now draws
above `East` and everything below shifts down one. The group line, the column
headers, the separator, the data rows and the totals strip are byte-identical,
which is the evidence this is the boundary condition and not a reshuffle.

`harness/tests/grouped.spec.js` asserted TWO group rows against a fixture with
three, naming this ticket on the line and saying the count becoming 3 would be
the record of when it was fixed. It is 3, verified in Chromium. Three
assertions in that file took "the first tbody row" to be a data row; a
`firstDataRow` helper now skips the header, which is the same re-pin.

improvise-8qlr — the run rule lives once
-----------------------------------------
Column-group run coalescing was derived in `src/ui/grid.rs` and again in
`improvise-wasm-client/src/render.rs::col_groups`, held in step by a comment
saying so. `AxisFrame::group_runs`/`group_run_heads` is the one home and both
ends call it. Runs still coalesce by NAME, which is what both ends did before
and is preserved rather than quietly changed.

The TUI keeps one divergence, stated: it labels its first VISIBLE column even
mid-run, or a scrolled grid draws an unlabelled group line.

The agreement test's TUI arm was `group_line.contains("▼ Q1")` — the unpinned
arm the ticket names. It now compares the painted group line cell for cell
against spans derived from the browser's own runs. The test is fixed, not
deleted.

Injected faults, each failing by name
-------------------------------------
  * gd83: delete either `GroupEnd` emission — the two new layout tests fail
    with the leaked group, and the column-header agreement test fails with them.
  * 0o6x: force `body_from`'s match to `false` — `the_whole_frame_is_unchanged`
    and the new leading-header agreement test fail.
  * 8qlr: the ticket's own recipe, forcing the TUI to emit the group label on
    every column. `cargo test -p improvise` was GREEN before this commit and
    now fails with ["▼ Q1","▼ Q1","",...] against ["▼ Q1","","",...].

What the ticket did not predict
-------------------------------
A collapsed leading group has no item to carry its header into a window, so it
reaches the frame only through `axis_frame`'s `pending` queue. The first
version of the windowing change had `GroupEnd` CLEAR `pending` above the
window, which deleted exactly that header — the affordance 0o6x exists to
restore. `GroupEnd` is now queued like a header and clears nothing. Found by
driving the new agreement test in both collapse states; the uncollapsed arm
alone passes against the broken version.

1411 Rust tests pass (baseline 1405, +6). Playwright 28 passed + 3 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:40:57 -07:00
Edward Langley 0f14b2a39e fix(protocol): a formula-owned cell projects CellId::Derived, not Record
improvise-ir7l. project::cell_id decided Record vs Aggregate purely on whether
a regular category sat on Axis::None; it never asked whether the coordinate
named a formula target. So a formula cell was a Record, render.rs::editable
said true, and a write to it landed, was stored, and was then shadowed forever
by formula_cache — not a revert, a permanent silent shadow, on the demo's
front page.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:59:59 -07:00
Edward Langley 5f82241871 fix(check-evidence): count only #[test] functions, not every fn
ChatGPT on PR #36, and it is the repo's characteristic defect: the check
passed while the thing it names was broken. `defined` held every `fn`, so a
`###` heading naming a HELPER satisfied it — five of this PR's own entries
did — and, worse, a renamed helper let an entry silently retarget a
surviving one, which is exactly the orphan the check exists to catch.

Proved discriminating in both directions before keeping: with the fix, the
five helper headings are reported by name (and are now subordinate `####`
headings, unchecked because a renamed helper is found by the compiler); and
renaming a real test's heading is still caught. 1303 distinct `#[test]`
names over 1309 tests, the difference being duplicate names across files.

CROSS-UNIT NOTE for the wave: this tightens a checker all seven units share.
Any other unit whose evidence file gives a fixture or helper a `###` heading
will now fail `--check-evidence` at merge. That failure is correct and
visible; the alternative was leaving the check lying for six more files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:57:34 -07:00
Edward Langley 5d0fb5c2d4 docs(tui): the root crate's arguments, moved to context/ prose
improvise-9c8c. The two files this unit owns, written before any comment is
capped so nothing is capped without somewhere to land.

context/code/tui.md carries the grid widget's module doc whole (it is the
best-written statement of the no-&Model/no-&View rule in the repo, better
than the repo map's own), the two semantic-to-terminal colour mappings and
why mode_style is NOT that shape (improvise-zx8t), the wizard's
set_bottom_line and bytes-vs-cells arguments, and the per-frame cache
building that is the temporary half of improvise-35e.

context/test-evidence/tui.md carries the injected-fault records, keyed by
test name per design-principles §7 — including THE_MEMBERSHIP_CORRECT_SPANS,
whose assert_ne! is a live tripwire that starts failing the day
improvise-gd83 is fixed.

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