8f3a9768b7
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
164 lines
7.3 KiB
JavaScript
164 lines
7.3 KiB
JavaScript
// The grid paths no shipped document reaches: two categories on an axis, a
|
|
// group spanning its members, a collapsed one, and the inline group row that
|
|
// carries no cells. This is the first test anywhere that runs improvise-anr9's
|
|
// header-level code in a browser (improvise-plcx).
|
|
|
|
import { test, expect } from "@playwright/test";
|
|
|
|
/// `harness/fixtures/grouped.improv`, served beside the shipped demo by
|
|
/// `harness/build.sh`. A seed that 404s is NOT an error -- `web/worker.js`
|
|
/// answers a missing seed with an EMPTY model -- so everything below asserts
|
|
/// labels the fixture really carries. "A grid is drawn" would pass on the
|
|
/// empty document a typo in the filename produces.
|
|
/// The first row that carries DATA. Not `tbody tr` first: since improvise-0o6x
|
|
/// a group header first on its axis is drawn, and `Coastal` is exactly that in
|
|
/// `grouped.improv` -- so the first row is a header alone, with no `td` at all.
|
|
const firstDataRow = (page) =>
|
|
page
|
|
.locator("#grid tbody tr")
|
|
.filter({ hasNot: page.locator("th.group") })
|
|
.first();
|
|
|
|
async function openGrouped(page, doc) {
|
|
await page.goto(`/app/index.html?doc=${doc}&seed=grouped.improv`);
|
|
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
|
await expect(page.locator("#view-name")).toHaveText("Grouped");
|
|
// The head, waited for with a RETRYING assertion before anything below
|
|
// reads the DOM in one shot: a grid painted from the first projection and
|
|
// reframed by the viewport it then announces would otherwise be read
|
|
// half-drawn, and a one-shot read cannot wait.
|
|
await expect(page.locator("#grid thead tr")).toHaveCount(3);
|
|
await expect(page.locator("#grid thead th.group").first()).toHaveText("▼ Q1");
|
|
await expect(firstDataRow(page).locator("th").last()).toHaveText("Main");
|
|
await page.locator("#grid").focus();
|
|
}
|
|
|
|
/// The head as lines of text, group line included: `thead > tr` in order, each
|
|
/// as the labels it draws with the corner dropped. Read in one pass, because
|
|
/// what these tests are about is the RELATION between the lines.
|
|
const headLines = (page) =>
|
|
page.locator("#grid thead").evaluate((head) =>
|
|
[...head.rows].map((tr) => ({
|
|
group: tr.cells[0].className === "group" || tr.cells[1]?.className === "group",
|
|
cells: [...tr.cells].slice(1).map((th) => ({
|
|
text: th.textContent,
|
|
span: th.colSpan,
|
|
})),
|
|
cornerSpan: tr.cells[0].colSpan,
|
|
})),
|
|
);
|
|
|
|
test.describe("a grouped, multi-level document", () => {
|
|
// The defect this is aimed at is the one improvise-anr9 fixed: a label tuple
|
|
// slash-joined into one string. That draws the RIGHT number of columns with
|
|
// the right words in them, so only the SHAPE of the head can see it.
|
|
test("two categories on an axis are two header lines, not one slash-joined one", async ({
|
|
page,
|
|
}) => {
|
|
await openGrouped(page, "grouped-levels.improv");
|
|
const lines = await headLines(page);
|
|
|
|
// One group line and one line per column level: Month over Channel.
|
|
expect(lines.length).toBe(3);
|
|
expect(lines.map((l) => l.group)).toEqual([true, false, false]);
|
|
|
|
// Two row categories, so the corner spans two — and the row headers below
|
|
// must agree with it, or the table is ragged.
|
|
expect(lines.every((l) => l.cornerSpan === 2)).toBe(true);
|
|
await expect(firstDataRow(page).locator("th")).toHaveCount(2);
|
|
|
|
// The level lines. The blanks are repeat suppression, decided in Rust by
|
|
// `AxisFrame::header_labels` — NOT an absent label, and not the page's
|
|
// idea. A slash-join would put "Jan/Web" on ONE line and none of this
|
|
// would hold; so would a page that drew only the innermost level.
|
|
expect(lines[1].cells.map((c) => c.text)).toEqual(["Jan", "", "Feb", "", "Mar", ""]);
|
|
expect(lines[2].cells.map((c) => c.text)).toEqual([
|
|
"Web",
|
|
"Shop",
|
|
"Web",
|
|
"Shop",
|
|
"Web",
|
|
"Shop",
|
|
]);
|
|
// Stated as well as implied: a joined label would satisfy a length check.
|
|
expect(lines.flatMap((l) => l.cells).every((c) => !c.text.includes("/"))).toBe(true);
|
|
|
|
// The row labels are the same rule on the other axis: North is drawn
|
|
// once and its second store's line is blank.
|
|
const rowHeads = await page
|
|
.locator("#grid tbody tr")
|
|
.evaluateAll((rows) =>
|
|
rows
|
|
.filter((tr) => tr.cells[0].className !== "group")
|
|
.map((tr) => [...tr.cells].filter((c) => c.tagName === "TH").map((c) => c.textContent)),
|
|
);
|
|
expect(rowHeads.slice(0, 4)).toEqual([
|
|
["North", "Main"],
|
|
["", "Annex"],
|
|
["South", "Main"],
|
|
["", "Annex"],
|
|
]);
|
|
});
|
|
|
|
// A column group header spans exactly the columns under it. The span is
|
|
// `col_groups`' run length, and a wrong one is not a wrong word: it is a
|
|
// table whose header sits over the wrong data.
|
|
test("a column group spans its members, and the spans tile the columns", async ({
|
|
page,
|
|
}) => {
|
|
await openGrouped(page, "grouped-spans.improv");
|
|
const lines = await headLines(page);
|
|
const groups = lines[0].cells;
|
|
|
|
// Q1 holds Jan and Feb, each split by Channel: 2 x 2. Q2 holds Mar: 1 x 2.
|
|
expect(groups.map((c) => c.text)).toEqual(["▼ Q1", "▼ Q2"]);
|
|
expect(groups.map((c) => c.span)).toEqual([4, 2]);
|
|
|
|
// And they tile: every column is under exactly one group header. A span
|
|
// that were merely "big enough" passes the pair above and fails here.
|
|
const columns = lines[2].cells.length;
|
|
expect(groups.reduce((n, c) => n + c.span, 0)).toBe(columns);
|
|
await expect(firstDataRow(page).locator("td")).toHaveCount(columns);
|
|
});
|
|
|
|
// `RenderRow::Group` carries no cells, so the page CANNOT draw one with
|
|
// cells — which is worth asserting from outside, because the DOM is where
|
|
// that guarantee is cashed and the table goes ragged if it is lost.
|
|
test("an inline group row is a header alone, and its indicator reports the collapse", async ({
|
|
page,
|
|
}) => {
|
|
await openGrouped(page, "grouped-collapse.improv");
|
|
|
|
const groupRows = page.locator("#grid tbody tr", {
|
|
has: page.locator("th.group"),
|
|
});
|
|
// Three, not two: this WAS two, because `Coastal` is the frame's FIRST
|
|
// entry and a leading group header was dropped on the way to the page.
|
|
// improvise-0o6x fixed it, and this line is the record of when.
|
|
await expect(groupRows).toHaveCount(3);
|
|
await expect(groupRows).toHaveText([
|
|
"▼ Coastal",
|
|
"▼ Inland",
|
|
"▶ Upland",
|
|
]);
|
|
// The glyph is `indicator(collapsed)`: a page that hard-coded either one
|
|
// passes on one of these rows and fails on the other.
|
|
expect(await groupRows.locator("td").count()).toBe(0);
|
|
await expect(groupRows.first().locator("th")).toHaveCount(1);
|
|
|
|
// The collapse is the document's, and it must have REMOVED rows: Upland's
|
|
// two regions are absent while Inland's two are here. Without this, a
|
|
// page that drew the indicator and ignored the frame would pass.
|
|
const labels = await page.locator("#grid tbody tr th[scope=row]").allTextContents();
|
|
expect(labels).toContain("East");
|
|
expect(labels).toContain("West");
|
|
expect(labels).not.toContain("Ridge");
|
|
expect(labels).not.toContain("Valley");
|
|
// 4 regions visible x 2 stores. Stated as a count as well, because a row
|
|
// drawn with empty labels would satisfy the four assertions above.
|
|
await expect(page.locator("#grid tbody tr").filter({ hasNot: page.locator("th.group") })).toHaveCount(
|
|
8,
|
|
);
|
|
});
|
|
});
|