Merge pull request #96 from fiddlerwoaroof/wave/gate-and-coverage

Three checks that could not fail: duplicate evidence headings, the records-mode settle, the LayoutGeometry case list
This commit is contained in:
Ed Langley
2026-09-21 11:03:45 -07:00
committed by GitHub
12 changed files with 524 additions and 87 deletions
+66
View File
@@ -732,6 +732,24 @@ layout (gotcha 23), so a batch that **crosses** — `:toggle-records-mode . ente
grid. One edit, after the rebuild, has no crossing case to get wrong, and it
also removes the per-edit coverage question three separate writers created.
**The guard is two-armed and the crossing test could only see one arm**
(improvise-4dpj). `settle_normal_mode` matches `Normal | RecordsNormal`, and
replacing that with a one-way `Normal` left the whole workspace green: the `:`
crossing drives the layout both ways, but both of its branches end in
`enter-mode normal`, so the mode handed to the settle is `AppMode::Normal`
either way. It discriminates "settles at all" and "settles against the ending
layout", and not "settles from **either** mode" — three questions that look
like one until an injection separates them. The `RecordsNormal` arm is live:
`SetAxis { "_Index", Axis::None }` applied from a records grid leaves
`is_records_mode()` false with the mode still `RecordsNormal`.
**And records-ness is auto-detected from the axes (gotcha 1), so a view switch
crosses it too.** `SwitchViewAtCursor` emits `[SwitchView(target),
ChangeMode(Normal)]`, which is a genuinely different path from the toggle the
`:` test drives — nothing in it says "records", the crossing falls out of the
target view's axis map. Moving the settle back before `rebuild_layout` is the
injection for both, and it fails each by name.
**This fixes the TUI and not the browser, and that is LIVE (improvise-e5pf).**
`Command::ChangeMode(AppMode::Normal)` is on the wire and `reduce.rs` folds it
as `view.mode = mode.clone()` with no resolution, so the browser front end still
@@ -1323,6 +1341,54 @@ cells will bump their records when it shares a batch with a `CellData` one — a
over-bump, costing a rejection that need not have happened, which is the same
asymmetry `Dirty::of` is deliberately sloppy in.
**The case list is checked for completeness, not for length** (improvise-26dm).
`layout_geometry_effects_actually_change_document_config` walked a hand-built
list under an `assert_eq!(cases.len(), 13)`, and a count catches a case being
*deleted* and never one being *absent* — the two are written identically. The
adversarial review of PR #78 added a fourteenth `pub struct` declaring
`LayoutGeometry` and the suite stayed green.
`doctrine(a_list_checked_for_length_reads_like_a_list_checked_for_completeness)`.
improvise-5ubs's answer — read serde's derive-generated variant list out of its
`unknown variant … expected one of` message — cannot reach this one: an effect
is a struct implementing a trait, so there is no derived enumeration of effects
at all. But gotcha 38 says `ModelCommand` is derived from the model effects,
with `model_effects` mapping each variant back to the effect it came from, so
the list is now diffed against the `LayoutGeometry` effects *reachable through
`ModelCommand`*, and the failure names the missing effect rather than a number.
The two lists agree exactly today, which is itself the evidence the
substitution lost nothing.
**The right-hand side had to be bounded before it could be believed** (PR #96,
found independently by this branch's review and by ChatGPT Codex).
`every_model_command()`'s doc comment claimed the enumeration was
compiler-forced — `variant_name` matches exhaustively, so a new variant fails
the build. That inference is invalid: the build fails in *`variant_name`*, and
nothing obliged the value list to gain an entry. Deleting one from it failed
this check and nothing else in the workspace.
So the list is now itself checked, by `improvise-5ubs`'s mechanism:
`the_enumeration_names_every_variant_the_derive_declares` diffs it against
`variants::declared::<ModelCommand>()`, serde's derive-generated variant list.
That makes the chain **derive → `every_model_command()` → `model_effects` →
case list**, every link checked, and the whole thing is what the ticket asked
for. `variants` was `#[cfg(test)] pub(crate)`, so reaching it costs a
`test-support` feature on `improvise-protocol` gating an optional `serde_json`
— **off in every shipped build**, which is why the wasm gate is unaffected.
The assertion message still names **both** directions, because a short
right-hand side reads as a spurious case and invites deleting the left,
degrading both sides together — the failure the ticket exists to close.
**Its bound is stated rather than assumed**: this closes the effects on the
wire, and an effect declaring `LayoutGeometry` that no `ModelCommand` names is
still invisible — which is precisely the probe variant PR #78 injected. That is
an honest residual and not a fix, and it is the reason the ticket asked for a
compiler-maintained enumeration of *effects*: none exists without a proc macro
or inventory-style registration, and a fourth hand-rolled list in a fourth
place is the disease. A second `[…; 13]` array survives one function away in
`every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint` and is
filed, not smuggled in.
On abort, the loop resets `abort_effects` so the next dispatch starts clean and
records `batch_aborted` where a caller can still read it.
+31
View File
@@ -208,6 +208,37 @@ also has no `context/test-evidence/` and no JS roots, so the evidence half is
vacuously clean there and the cap is the only thing that can decide the exit
code — the isolation is what makes the assertion name one question.
### Two headings, one test
An orphan is a heading whose test nobody can find; the mirror image is a test
that **two** headings answer for, and it is invisible to every rule above —
each copy names a test that runs, so each copy passes. PR #92 shipped two
entries under one heading where the older described a fixture the test no
longer had and repeated a claim an adversarial review had measured false. A
reader meeting the older one first has no way to know a successor exists two
thousand lines down, so the stale copy reads as current. That is worse than an
orphan, which at least announces itself as broken.
It is a **refusal and not a warning**, on the precedent `improvise-rm4s` set for
the ambiguous leaf: the two are the same defect seen from opposite ends — one
name answered by two tests, and one test answered by two names — so they are
answered the same way. The refusal names the file and every line, in file order.
The key is the **resolved test, not the spelling**. `### f` beside
`### m::tests::f` is two headings for one test, and grouping by the string would
miss it precisely because qualification is the repair `rm4s` asks for; a heading
qualified in one file and bare in another would have been the first way this
came back. Resolution reuses the same leaf table the ambiguity rule uses, so a
leaf that is genuinely ambiguous canonicalises to itself and is reported as
ambiguous rather than as a duplicate — two *different* tests sharing a leaf are
not two entries for one test, and conflating them would file the wrong repair.
The check is over the whole directory rather than per file, because a heading
keys a test name and a test name is unique workspace-wide once qualified.
What it still cannot see, and neither can any of the others: an entry that
exists, names a live test, is the only one that does — and describes it
backwards.
## 3. The others
`scripts/kb` is the only writer of `prolog-sessions/kb.pl`; its own reasoning is
+110 -10
View File
@@ -1017,6 +1017,15 @@ Tier 2 moves, tier 3 does not. A shape change invalidates every in-flight
`CellData` batch through `layout_version` alone; it must not also masquerade as a
write to any record.
Fixture repaired under improvise-75ld: it asked for
`SetAxis { category: "Month", axis: Axis::Column }` against a fixture where
`on_category_added` had already seated `Month` on Column — so the "layout change"
was a no-op, and the test passed only because the tier was bumped off the
declaration. It now asserts the axis before and after, and asks for `Axis::Row`.
This is the failure that turned "reclassify three effects" into "stop reading the
tier off the classification". Injected fault: the same as
`both_shape_counters_move_for_every_reshaping_command` — fails naming `SetAxis`.
### a_loaded_document_is_dirty_because_its_path_still_names_the_old_one
**A loaded document is dirty, and that is a behaviour change with a reason**
@@ -3076,16 +3085,6 @@ that reshapes nothing fails loudly rather than proving nothing.
Injected fault: as above. Fails naming `SetAxis`, then `AddItem` once the earlier
cases are removed.
### a_layout_change_moves_the_layout_version_and_no_record
Pre-existing test, fixture repaired under improvise-75ld. It asked for
`SetAxis { category: "Month", axis: Axis::Column }` against a fixture where
`on_category_added` had already seated `Month` on Column — so the "layout change"
was a no-op, and the test passed only because the tier was bumped off the
declaration. It now asserts the axis before and after, and asks for `Axis::Row`.
This is the failure that turned "reclassify three effects" into "stop reading the
tier off the classification".
### a_configuration_change_that_draws_nothing_is_still_a_document_change
The third case the shape/config split creates, and the one that forced two
@@ -3496,3 +3495,104 @@ The fix is not local and none is chosen: `grid_in` takes `&App` and
`recompute_formulas` needs `&mut Model`, so `grid_in` cannot recompute for the
view it is projecting. Candidates are on the ticket.
### a_batch_that_leaves_records_settles_out_of_records_normal
improvise-4dpj, gap 1. `settle_normal_mode` matches `Normal | RecordsNormal`,
and replacing that with a one-way `AppMode::Normal` left the **entire workspace
suite passing**. The existing crossing test,
`a_batch_that_leaves_records_and_then_sets_normal_lands_in_normal`, drives the
layout both ways, but both of its branches end in `enter-mode normal`, so the
mode handed to the settle is `AppMode::Normal` in both directions. It
discriminates "settles at all" and "settles against the ending layout", and not
"settles from **either** mode".
This drives the production repro: from a records grid,
`apply_effects([SetAxis { "_Index", Axis::None }])` leaves
`layout.is_records_mode()` false with the mode still `RecordsNormal`, so the
`RecordsNormal` arm is live and not defensive. Both the records-ness of the
grid and the mode before the batch are asserted as preconditions, so a fixture
that stopped entering records mode fails loudly rather than proving nothing.
Injected fault: `if matches!(self.view_state.mode, AppMode::Normal)`. Fails by
name, `left: RecordsNormal, right: Normal`. It is the only test in the
workspace that fails.
### switching_views_across_records_ness_settles_against_the_view_switched_to
improvise-4dpj, gap 2 — raised by ChatGPT Codex against PR #91 and verified
correct on `main` before being tested. `SwitchViewAtCursor` emits
`[SwitchView(target), ChangeMode(Normal)]`, and this is a **different path**
from the one gap 1's siblings drive: that test toggles records mode explicitly,
where a view switch crosses records-ness only because it is auto-detected from
the axes (gotcha 1). Nothing in the batch says "records".
Driven in both directions, with the crossing asserted before and after so a
fixture that stopped crossing fails rather than passes. Injected fault: move
`settle_normal_mode()` back above `rebuild_layout()` at the end of
`apply_effects` — the exact regression PR #91's round 2 fixed. This test fails
by name; so does gap 1's, along with six pre-existing records-mode tests
(measured in review; an earlier draft of this entry said five).
**Scope note, stated because an overclaim is worse than a gap**: this drives
the *effects* `SwitchViewAtCursor` emits, not the view-panel key path that
composes them. Reaching the panel needs a `view_panel_cursor` on a `CmdContext`
built in `command/cmd/panel.rs`, which is another wave's ground this round. The
command's own coverage lives there; what is unexercised is the two joined.
### the_layout_geometry_cases_are_every_wire_effect_of_that_kind
improvise-26dm. `layout_geometry_effects_actually_change_document_config` was
guarded by `assert_eq!(cases.len(), 13)`, which catches a case being *deleted*
from the list and never one being *absent* from it — and the two are written
identically as an `assert_eq` over a number. The adversarial review of PR #78
added a fourteenth `pub struct` declaring `LayoutGeometry`, pointed at by
nothing, and the whole suite stayed green.
improvise-5ubs's mechanism cannot reach it: an effect is a struct implementing
a trait, so serde has no derived enumeration to diff against. `model_effects`
maps each `ModelCommand` variant back to the effect it came from (gotcha 38),
so the list is diffed against the `LayoutGeometry` effects reachable through
`ModelCommand`. The two sets are equal today, which is the evidence the
substitution lost nothing.
**The right-hand side is bounded by the derive, and that took a second round.**
Review of PR #96 injected the deletion of `ModelCommand::TogglePruneEmpty` from
`every_model_command()`: this was the **only** test in the workspace that
failed, and `every_model_command`'s doc comment claiming the list was
compiler-forced was wrong — `variant_name`'s exhaustive match forces an arm
*there*, not an entry in the list. ChatGPT Codex raised the same thing on the
PR. `the_enumeration_names_every_variant_the_derive_declares` closes it, so the
chain is derive → enumeration → `model_effects` → case list with every link
checked. The assertion here still names **both** directions, because a short
right-hand side reads as a spurious case on the left and invites deleting it —
degrading both sides together.
Injected fault: drop `TogglePruneEmpty` from `layout_geometry_cases()`. The old
count said `12 != 13`; this says
`right: [… "TogglePruneEmpty" …]` and **names the effect**.
**Residual, stated rather than closed**: this is total over the wire and not
over the effects. An effect declaring `LayoutGeometry` that no `ModelCommand`
names is still invisible — which is precisely PR #78's probe. Closing that
needs an enumeration of effects the compiler maintains, and none exists without
a proc macro or inventory-style registration.
### the_enumeration_names_every_variant_the_derive_declares
The bound the entry above rests on. Injection is Codex's own, run end to end: a
`ModelCommand::ProbeGeometry` whose effect declares `LayoutGeometry`, with the
`model_effects` and `variant_name` arms the compiler forces, omitted from
**both** lists. Before this test, the whole workspace stayed green. After it,
the failure is staged and each stage names the probe — first
`every_model_command() never constructs ["ProbeGeometry"]`, then, once the
enumeration is repaired and only the case list is short,
`the_layout_geometry_cases_are_every_wire_effect_of_that_kind` with
`ProbeGeometry` on the right. That staging is the point: the two tests answer
different questions and the injection separates them.
The name reader is shared, not copied: `reduce.rs`'s `effect_type_name` and its
own `the_effect_name_reader_reads_all_three_debug_shapes` are what this leans
on, because a second reader here would be a derivation done twice and could
drift while one copy was repaired. It reads the leading identifier off the
derived `Debug`, `Effect` being a trait object that `type_name` cannot reach.
+27
View File
@@ -192,3 +192,30 @@ reason for a file nobody can find — the same failure as a dead ticket id, whic
The first arm was filed as latent and was live when it was written:
`context/code/kb-tool.md` had been unpublished, unargued and unnoticed since it
was added.
### Duplicate evidence headings — `SELF_TEST_DUPLICATES`
improvise-hsoe. `--check-evidence` verified that a heading names a test that
**runs**, and a duplicate heading names one just as well, so both copies pass.
PR #92 shipped two entries under
`a_layout_change_moves_the_layout_version_and_no_record`, and two more pairs
sat in `tui.md` — one of them asserting that `THE_MEMBERSHIP_CORRECT_SPANS` was
"a live tripwire, do not remove it" some four hundred lines above the entry
recording that improvise-gd83 had removed it. A reader meeting the older copy
first takes it as current, which is worse than an orphan: an orphan announces
itself as broken.
The fault is injected by the six cases in `SELF_TEST_DUPLICATES`, and three of
them are the ones that would not get written first. Two *different* tests
sharing a leaf must come back **ambiguous and not duplicate**, because the
wrong diagnosis files the wrong repair. A prose heading must be free to repeat,
because it claims no test. And the bare and module-qualified spellings of one
test must collide — grouping by the string rather than by the resolved test
would have missed exactly the case improvise-rm4s's qualification repair
creates, which is the first way this would have come back.
The live tree was itself the first injection: before the three merges the check
exited 1 naming all three pairs with both line numbers; after them it exits 0.
The clean arms (two headings naming two tests, and prose repeated) are what
stop an always-refuse from satisfying the rest.
+40 -53
View File
@@ -87,22 +87,21 @@ Each header line is cut into columns and compared *exactly*. A `contains` would
be one-directional: it cannot see a label the browser wrongly blanked (the
filter would skip it) and it cannot see one painted under the wrong column.
**`THE_MEMBERSHIP_CORRECT_SPANS` is a deliberately-wrong assertion and a live
tripwire. Do not remove it.** The `assert_ne!` against it starts failing the day
improvise-gd83 is fixed, at which point the `assert_eq!` above it should become
that constant.
**improvise-gd83 is fixed and `THE_MEMBERSHIP_CORRECT_SPANS` is gone**, along
with the `assert_ne!` tripwire that named it. The value it predicted —
`[(Q1,2), (None,1), (Q1,2), (None,1)]` — is now what the test asserts, element
for element. `Sep` is ungrouped, so the run ends before it and resumes under
the second measure's own `Q1` header; a precondition asserts the fixture still
contains an ungrouped column, because without one the test cannot see a leaked
group at all.
The span assertion above it pins `[(Some("Q1"), 6)]` — Q1 covering all six
columns *including* the two under the ungrouped `Sep` — because
`expand_category` emits a header on entering a group and no boundary on leaving
one, so the nearest-preceding walk keeps answering Q1 (`AxisFrame::group_of`).
That is `GridLayout`'s rule and the TUI paints the same span; it is reproduced
rather than corrected, because correcting it changes what the TUI draws.
The membership-correct answer is spelled out rather than guessed, because a
wrong future expectation is worse than none: whoever picks up improvise-gd83
will read it and believe it. The frame here is **not** one Q1 run — `_Measure`
is the outer level, so the column axis is:
The superseded expectation — Q1 covering all six columns *including* the two
under the ungrouped `Sep`, because `expand_category` emitted a header on
entering a group and no boundary on leaving one, so the nearest-preceding walk
kept answering Q1 — is recorded here because it is what `AxisFrame::group_of`
answered before the fix, and the frame it was derived from still explains the
current value. The frame is **not** one Q1 run — `_Measure` is the outer level,
so the column axis is:
col_cats = ["_Measure", "Month"]
Group(Q1) [Revenue,Jan] [Revenue,Feb] [Revenue,Sep]
@@ -114,6 +113,23 @@ rather than after both. So membership-correct spans are **four** runs, not two
run between them. Hence
`[(Some("Q1"), 2), (None, 1), (Some("Q1"), 2), (None, 1)]`.
improvise-8qlr: the TUI arm was `group_line.contains("▼ Q1")`, which is
one-directional and passes while the TUI labels **every** column. The run rule
now lives once, as `AxisFrame::group_runs`/`group_run_heads`, and both ends
call it; the TUI's group line is compared cell for cell against spans derived
from the browser's own runs.
Fault injected exactly as that ticket specifies — forcing the TUI to emit the
group label on every column (`head = col_groups.get(ci)`, dropping the run-head
lookup). Before: `cargo test -p improvise` stayed green. After: this test fails
with `["▼ Q1","▼ Q1","","▼ Q1","▼ Q1",""]` against
`["▼ Q1","","","▼ Q1","",""]`.
One thing the shared rule does **not** flatten: the TUI labels its first
*visible* column even mid-run, because a scrolled grid would otherwise draw an
unlabelled group line. That is a scrolling concession, kept explicitly, and it
is why the comparison is run at `col_offset == 0`.
### neither_front_end_produces_a_slash_joined_label
A separate assertion because the agreement tests above would also hold if
@@ -165,6 +181,15 @@ so it stays as it is; the multi-level agreement is
`tests/grid_frame_agreement.rs`, which drives both front ends over a two-level,
grouped fixture.
Re-pinned for improvise-0o6x. `GROUPED_FRAME` moved by exactly one line: the
leading `▼ Co…` header now draws above `East`, and everything below shifts
down one. Nothing else in the buffer changed. That the group line, the column
headers, the separator, the data rows and the totals strip are byte-identical
is the evidence the change is the boundary condition it claims to be and not a
reshuffle of the draw loop. It also fails alongside
`a_group_header_first_on_its_axis_reaches_both_front_ends` when `body_from`'s
match is forced to `false`.
### two_row_categories_produce_cross_product_labels
Names the rendering the protocol must not carry: multi-level row headers show
@@ -509,44 +534,6 @@ nothing.
Fault injected by forcing `body_from`'s match to `false`: this test fails and
`the_whole_frame_is_unchanged` fails with it.
### the_whole_frame_is_unchanged
Re-pinned for improvise-0o6x. `GROUPED_FRAME` moved by exactly one line: the
leading `▼ Co…` header now draws above `East`, and everything below shifts
down one. Nothing else in the buffer changed. That the group line, the column
headers, the separator, the data rows and the totals strip are byte-identical
is the evidence the change is the boundary condition it claims to be and not a
reshuffle of the draw loop.
### the_two_front_ends_draw_the_same_column_header
Two changes, from two tickets.
improvise-gd83: `THE_MEMBERSHIP_CORRECT_SPANS` and its `assert_ne!` tripwire are
gone, and the value they predicted — `[(Q1,2), (None,1), (Q1,2), (None,1)]` —
is now what the test asserts, element for element. `Sep` is ungrouped, so the
run ends before it and resumes under the second measure's own `Q1` header. A
precondition asserts the fixture still contains an ungrouped column, because
without one the test cannot see a leaked group at all.
improvise-8qlr: the TUI arm was `group_line.contains("▼ Q1")`, which is
one-directional and passes while the TUI labels **every** column. The run rule
now lives once, as `AxisFrame::group_runs`/`group_run_heads`, and both ends
call it; the TUI's group line is compared cell for cell against spans derived
from the browser's own runs.
Fault injected exactly as the ticket specifies — forcing the TUI to emit the
group label on every column (`head = col_groups.get(ci)`, dropping the run-head
lookup). Before: `cargo test -p improvise` stayed green. After: this test fails
with `["▼ Q1","▼ Q1","","▼ Q1","▼ Q1",""]` against
`["▼ Q1","","","▼ Q1","",""]`.
One thing the shared rule does **not** flatten: the TUI labels its first
*visible* column even mid-run, because a scrolled grid would otherwise draw an
unlabelled group line. That is a scrolling concession, kept explicitly, and it
is why the comparison is run at `col_offset == 0`.
### two_abutting_runs_of_one_group_name_are_two_spans_at_both_ends
improvise-h0iv, end to end. The unit demonstration is in
+1
View File
@@ -21,4 +21,5 @@ unicode-width = "^0.2"
web-time = "1"
[dev-dependencies]
improvise-protocol = { path = "../improvise-protocol", features = ["test-support"] }
tempfile = "3"
+83
View File
@@ -2604,6 +2604,89 @@ mod tests {
}
}
/// The settle's `RecordsNormal` arm, driven from the mode it names. The
/// `:` crossing above enters the settle in `Normal` both ways — the evidence.
#[test]
fn a_batch_that_leaves_records_settles_out_of_records_normal() {
use crate::view::Axis;
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
assert!(app.layout.is_records_mode(), "setup: the grid is records");
assert_eq!(
app.view_state.mode,
AppMode::RecordsNormal,
"setup: the mode handed to the settle must be RecordsNormal"
);
app.apply_effects(vec![Box::new(super::super::effect::SetAxis {
category: "_Index".into(),
axis: Axis::None,
})]);
assert!(
!app.layout.is_records_mode(),
"setup: the batch has to leave records, or nothing crossed"
);
assert_eq!(
app.view_state.mode,
AppMode::Normal,
"a batch that left records stayed in RecordsNormal (improvise-4dpj)"
);
}
/// The batch `SwitchViewAtCursor` emits, both ways across records-ness:
/// records-ness is auto-detected from the axes, so a view switch moves it.
#[test]
fn switching_views_across_records_ness_settles_against_the_view_switched_to() {
for to_records in [false, true] {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
let mut app = App::new(wb, None);
// `R` creates and enters `_Records`; the pivot view it left is the
// other end of every crossing below.
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
let records_view = app.view_state.active_view.clone();
let (from, to) = match to_records {
true => ("Default", records_view.as_str()),
false => (records_view.as_str(), "Default"),
};
app.apply_effects(vec![Box::new(super::super::effect::SwitchView(
from.into(),
))]);
assert_eq!(
app.layout.is_records_mode(),
!to_records,
"setup: the switch has to CROSS, so it must start on the other side"
);
app.apply_effects(vec![
Box::new(super::super::effect::SwitchView(to.into())),
super::super::effect::change_mode(AppMode::Normal),
]);
assert_eq!(
app.layout.is_records_mode(),
to_records,
"setup: the switch has to land, or nothing crossed"
);
let expected = match to_records {
true => AppMode::RecordsNormal,
false => AppMode::Normal,
};
assert_eq!(
app.view_state.mode, expected,
"the batch settled against the view it LEFT, not the one it \
switched to (improvise-4dpj)"
);
}
}
/// A `:` line that really runs settles through `ReturnToNormalUnlessMoved`,
/// which an empty line never reaches. See the evidence.
#[test]
+39 -8
View File
@@ -1410,11 +1410,38 @@ mod tests {
}
}
/// Effects declaring `LayoutGeometry` must move `document_config` — the
/// production hash `model_state.dirty` is decided by (improvise-1xf4).
/// The case list below is COMPLETE over the wire vocabulary, which the
/// compiler maintains — improvise-26dm, `context/code/command.md`.
#[test]
fn layout_geometry_effects_actually_change_document_config() {
let cases: Vec<Box<dyn Effect>> = vec![
fn the_layout_geometry_cases_are_every_wire_effect_of_that_kind() {
let app = grouped_app();
let mut reachable: Vec<String> = crate::ui::reduce::tests::every_model_command()
.iter()
.flat_map(|cmd| crate::ui::reduce::model_effects(&app, cmd))
.filter(|e| e.kind() == EffectKind::Model(ModelChange::LayoutGeometry))
.map(|e| crate::ui::reduce::tests::effect_type_name(e.as_ref()))
.collect();
let mut listed: Vec<String> = layout_geometry_cases()
.iter()
.map(|e| crate::ui::reduce::tests::effect_type_name(e.as_ref()))
.collect();
reachable.sort_unstable();
reachable.dedup();
listed.sort_unstable();
listed.dedup();
assert_eq!(
listed, reachable,
"the LayoutGeometry case list and the wire vocabulary disagree. A name \
only on the LEFT means every_model_command() has a hole, not that the \
case is spurious; a name only on the RIGHT is an effect nothing here \
exercises. Read `context/code/command.md` before deleting either side"
);
}
/// The `LayoutGeometry` effects, named once so two tests can ask the same
/// list the same questions — improvise-26dm, `context/code/command.md`.
fn layout_geometry_cases() -> Vec<Box<dyn Effect>> {
vec![
Box::new(AddCategory("Region".into())),
Box::new(RemoveCategory("Month".into())),
Box::new(CreateView("Second".into())),
@@ -1443,10 +1470,14 @@ mod tests {
Box::new(CycleAxis("Type".into())),
Box::new(SetNumberFormat(",.3f".into())),
Box::new(TogglePruneEmpty),
];
// A COUNT, not a totality check: nothing forces a fourteenth here, and a
// probe variant proved it — improvise-5ubs, `context/code/command.md`.
assert_eq!(cases.len(), 13, "the LayoutGeometry effects were thirteen");
]
}
/// Effects declaring `LayoutGeometry` must move `document_config` — the
/// production hash `model_state.dirty` is decided by (improvise-1xf4).
#[test]
fn layout_geometry_effects_actually_change_document_config() {
let cases = layout_geometry_cases();
for effect in cases {
assert_eq!(
effect.kind(),
+24 -5
View File
@@ -273,7 +273,7 @@ pub fn reduce_key(app: &mut App, key: KeyEvent) -> Vec<Command> {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use crate::model::cell::CellValue;
use crate::ui::app::AppMode;
@@ -492,9 +492,9 @@ mod tests {
app
}
/// One value per `ModelCommand` variant. [`variant_name`] matches
/// exhaustively, so a new variant fails the build until it is listed here.
fn every_model_command() -> Vec<ModelCommand> {
/// One value per `ModelCommand` variant — by hand, and NOT compiler-forced:
/// [`variant_name`]'s exhaustive match only forces an arm there (PR #96).
pub(crate) fn every_model_command() -> Vec<ModelCommand> {
vec![
ModelCommand::SetCell {
cell: record_cell(&a_stored_cell()),
@@ -658,7 +658,7 @@ mod tests {
/// An effect's type name, off the leading identifier of its derived `Debug`.
/// `Effect` is a trait object, so `std::any::type_name` cannot reach it.
fn effect_type_name(effect: &dyn Effect) -> String {
pub(crate) fn effect_type_name(effect: &dyn Effect) -> String {
format!("{effect:?}")
.split(|c: char| !c.is_alphanumeric() && c != '_')
.next()
@@ -711,6 +711,25 @@ mod tests {
assert_eq!(names.len(), total, "a variant is listed twice: {names:?}");
}
/// ...and covers it WHOLLY, against serde's derive — the one enumeration the
/// compiler maintains (improvise-5ubs). `context/code/command.md`.
#[test]
fn the_enumeration_names_every_variant_the_derive_declares() {
let listed: Vec<String> = every_model_command()
.iter()
.map(|cmd| variant_name(cmd).to_string())
.collect();
let missed: Vec<String> = improvise_protocol::variants::declared::<ModelCommand>()
.into_iter()
.filter(|name| !listed.contains(name))
.collect();
assert!(
missed.is_empty(),
"every_model_command() never constructs {missed:?} — adding an arm to \
variant_name is compiler-forced, adding a VALUE to the list is not"
);
}
/// A model `Command` applied authoritatively lands where the keypress bound
/// to the same mutation lands, `Dirty` included.
#[test]
+7
View File
@@ -13,6 +13,13 @@ serde = { version = "1", features = ["derive"] }
# (`AxisFrame::header_widths`). A terminal column is a display cell, not a
# `char`, and the TUI has always measured it this way.
unicode-width = "^0.2"
# Only for `variants`, which is off unless a test build asks for it.
serde_json = { version = "1", optional = true }
[features]
# `variants` reaches OUT of this crate: improvise-command bounds its own
# ModelCommand enumeration with it. Off in every shipped build (improvise-26dm).
test-support = ["dep:serde_json"]
[dev-dependencies]
serde_json = "1"
+2 -2
View File
@@ -13,8 +13,8 @@ pub mod keymap;
mod reduce;
pub mod render;
pub mod resolve;
#[cfg(test)]
pub(crate) mod variants;
#[cfg(any(test, feature = "test-support"))]
pub mod variants;
pub mod viewmodel;
pub use basis::{Basis, BasisCache, Rejection};
+94 -9
View File
@@ -413,8 +413,33 @@ def js_files(roots):
yield os.path.join(dirpath, f)
def _duplicate_claims(claims, by_leaf, defined):
"""Two headings naming ONE test, keyed by the test they resolve to.
Resolved and not spelled, so `### f` beside `### m::tests::f` is caught
too: the heading is the key a reader looks a test up by, and two entries
under one key means the first one found is taken as current. See
context/code/tooling.md.
"""
def canonical(kind, name):
if kind == "js" or name in defined["rust"]:
return name
matches = by_leaf.get(name, set())
return next(iter(matches)) if len(matches) == 1 else name
grouped = {}
for (kind, name), where in claims.items():
grouped.setdefault((kind, canonical(kind, name)), []).extend(where)
def in_file_order(loc):
path, _, n = loc.rpartition(":")
return (path, int(n) if n.isdigit() else 0)
return {key: sorted(where, key=in_file_order)
for key, where in grouped.items() if len(where) > 1}
def evidence_verdict(claims, defined, citing):
"""(orphans, unstated, ambiguous), keyed by ("rust"|"js", name).
"""(orphans, unstated, ambiguous, duplicates), keyed by ("rust"|"js", name).
`orphans` is evidence naming a test nobody can find. `unstated` is the
reverse obligation (improvise-lyyc): a test that POINTS AT the evidence
@@ -422,8 +447,11 @@ def evidence_verdict(claims, defined, citing):
`ambiguous` is a bare leaf that two tests answer to -- a refusal and not a
warning, because a heading satisfied by a test in another module answering
another question is a pass while the gate does nothing.
`duplicates` is the mirror image: two headings answered by one test, where
every copy passes and the reader takes whichever comes first as current
(improvise-hsoe). Also a refusal, on the improvise-rm4s precedent.
Only that narrower reverse obligation, because most tests have no evidence
and should not be forced to. What none of the three can see: an entry can
and should not be forced to. What none of the four can see: an entry can
exist and still describe its subject backwards.
"""
by_leaf = {}
@@ -452,7 +480,7 @@ def evidence_verdict(claims, defined, citing):
for name in names:
if (kind, name) not in claimed:
unstated.setdefault((kind, name), []).extend(sorted(names[name]))
return orphans, unstated, ambiguous
return orphans, unstated, ambiguous, _duplicate_claims(claims, by_leaf, defined)
def check_evidence(roots, rust_listing=None, rust_ignored=None):
@@ -493,10 +521,10 @@ def check_evidence(roots, rust_listing=None, rust_ignored=None):
for name in c:
citing["js"].setdefault(name, []).append(path)
orphans, unstated, ambiguous = evidence_verdict(claims, defined, citing)
if not orphans and not unstated and not ambiguous:
orphans, unstated, ambiguous, duplicates = evidence_verdict(claims, defined, citing)
if not orphans and not unstated and not ambiguous and not duplicates:
print(f"test-evidence: {len(claims)} entries, all name a test that runs "
f"(names from {source}); "
f"and no test is named twice (names from {source}); "
f"{sum(len(v) for v in citing.values())} tests cite the file and all have one; "
f"{prose} prose headings claim nothing")
return 0
@@ -514,6 +542,11 @@ def check_evidence(roots, rust_listing=None, rust_ignored=None):
print(f" [{kind}] {name} ({', '.join(where)})", file=sys.stderr)
for path in sorted(by_leaf[name]):
print(f" -> {path}", file=sys.stderr)
if duplicates:
print(f"\n{len(duplicates)} tests are named by more than one test-evidence "
"heading; merge them or give each its own test:\n", file=sys.stderr)
for (kind, name), where in sorted(duplicates.items()):
print(f" [{kind}] {name} ({', '.join(where)})", file=sys.stderr)
if unstated:
print(f"\n{len(unstated)} tests cite {EVIDENCE_DIR} and have no entry there:\n",
file=sys.stderr)
@@ -910,15 +943,64 @@ SELF_TEST_AMBIGUITY = [
]
def _ambiguity_self_test():
# (name, evidence markdown, cargo listing, js source, the tests named twice).
#
# A heading that names a test that RUNS passes -- and a second heading naming
# the same one passes just as well, so an entry superseded by its successor
# reads as current to whoever meets it first (improvise-hsoe, found on PR #92).
# Every case here is an injection; the clean arms stop an always-fail passing.
SELF_TEST_DUPLICATES = [
("one heading twice in one file is refused",
"### a_real_test\n### a_real_test\n", "m::tests::a_real_test: test\n", "",
["m::tests::a_real_test"]),
("the qualified and bare spellings of one test are the same heading",
"### a_real_test\n### m::tests::a_real_test\n", "m::tests::a_real_test: test\n", "",
["m::tests::a_real_test"]),
# A BARE heading, because two qualified ones resolve by the exact-match arm
# and never reach the ambiguity guard this case is named for (review, PR #96).
("a bare heading over an ambiguous leaf is not a duplicate of a qualified one",
"### shared\n### m::tests::shared\n### n::tests::shared\n",
"m::tests::shared: test\nn::tests::shared: test\n", "", []),
("a JS heading twice is refused too",
'#### "a spec"\n#### "a spec"\n', "", 'test("a spec", () => {});\n', ["a spec"]),
("two headings naming two tests are not duplicates",
"### one\n### two\n", "m::one: test\nm::two: test\n", "", []),
("prose headings repeat freely -- they claim no test",
"### Helper - x\n### Helper - x\n", "", "", []),
]
def _duplicate_self_test():
failures = []
for name, md, listing, js, want in SELF_TEST_DUPLICATES:
claims = {}
for kind, cname, n in evidence_claims(md):
if kind != "prose":
claims.setdefault((kind, cname), []).append(f"fixture:{n}")
jd, _ = js_tests(js)
defined = {"rust": cargo_test_names(listing), "js": jd}
*_, duplicates = evidence_verdict(claims, defined, {"rust": {}, "js": {}})
got = sorted(k[1] for k in duplicates)
if got != sorted(want):
failures.append(f" DUPLICATES: {name}: expected {sorted(want)}, saw {got}")
return failures
def _ambiguity_self_test():
failures = _duplicate_self_test()
for name, md, listing, want_o, want_a in SELF_TEST_AMBIGUITY:
claims = {}
for kind, cname, n in evidence_claims(md):
if kind != "prose":
claims.setdefault((kind, cname), []).append(f"fixture:{n}")
defined = {"rust": cargo_test_names(listing), "js": set()}
orphans, _, ambiguous = evidence_verdict(claims, defined, {"rust": {}, "js": {}})
orphans, _, ambiguous, _dupes = evidence_verdict(
claims, defined, {"rust": {}, "js": {}})
got_o = sorted(k[1] for k in orphans)
got_a = sorted(k[1] for k in ambiguous)
if got_o != sorted(want_o):
@@ -953,7 +1035,9 @@ def _evidence_self_test():
defined = {"rust": cargo_test_names(listing), "js": jd}
citing = {"rust": {k: ["fixture.rs"] for k in rc},
"js": {k: ["fixture.js"] for k in jc}}
orphans, unstated, ambiguous = evidence_verdict(claims, defined, citing)
orphans, unstated, ambiguous, duplicates = evidence_verdict(claims, defined, citing)
if duplicates:
failures.append(f" DUPLICATES: {name}: unexpected {sorted(k[1] for k in duplicates)}")
got_o = sorted(k[1] for k in orphans)
got_u = sorted(k[1] for k in unstated)
if got_o != sorted(want_orphans):
@@ -1037,6 +1121,7 @@ def self_test():
f"{len(SELF_TEST_EVIDENCE)} evidence verdicts, "
f"{len(SELF_TEST_LISTING) + len(SELF_TEST_HEADINGS)} listing readings, "
f"{len(SELF_TEST_AMBIGUITY)} name resolutions, "
f"{len(SELF_TEST_DUPLICATES)} duplicate-heading verdicts, "
f"{len(SELF_TEST_DISPATCH)} invocations answer the cap")
return 0