From 09ada9c1b5eaac1395527d1733533d66bf2b9b5c Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 16:34:24 -0700 Subject: [PATCH] test(harness): a grouped, multi-level fixture, and the first browser run of anr9 web/demo.improv has no groups and one category per visible axis, deliberately (gotcha 46), so the multi-level header, the group span, the collapse indicator and the inline group row -- exactly the code improvise-anr9 added -- had never been rendered anywhere a test could see (improvise-plcx). harness/fixtures/grouped.improv is served beside the shipped demo by harness/build.sh; web/demo.improv is untouched. Two categories on each visible axis, three row groups with one collapsed, two column groups. A missing seed is NOT an error -- web/worker.js answers a 404 with an empty model -- so every assertion is about labels the fixture really carries, never that a grid exists. Five injections, five distinct failures, patched in harness/serve so no rebuild stands between the fault and the run: slash-joining the level tuples; colSpan = 1 on the group headers; a stray cell on the inline group row; indicator(false) hard-coded; and the collapse removed from the document. One thing the ticket did not predict: the row frame's FIRST group header is never drawn, so the spec asserts two group rows where the document has three, with improvise-0o6x named on the line. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH --- .gitignore | 3 + harness/build.sh | 8 ++ harness/fixtures/grouped.improv | 182 ++++++++++++++++++++++++++++++++ harness/tests/grouped.spec.js | 144 +++++++++++++++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 harness/fixtures/grouped.improv create mode 100644 harness/tests/grouped.spec.js diff --git a/.gitignore b/.gitignore index 18c841f5..f5ca20fe 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ bench/*.txt # The document the static site ships and seeds a fresh browser from # (improvise-bck). A shipped asset, not somebody's working file. !web/*.improv +# The harness's own documents. A test fixture, not somebody's working file: +# the grouped, multi-level model no shipped document is (improvise-plcx). +!harness/fixtures/*.improv # Beads / Dolt files (added by bd init) .dolt/ diff --git a/harness/build.sh b/harness/build.sh index 87044493..f4e21ffa 100755 --- a/harness/build.sh +++ b/harness/build.sh @@ -16,6 +16,14 @@ serve="$root/harness/serve" "$root/web/build.sh" >/dev/null "$root/web/assemble.sh" "$serve" harness >/dev/null +# The harness's own documents, beside the shipped one. `web/demo.improv` is +# the front page and stays as it is (gotcha 46); a grouped, multi-level model +# is the only way to reach the header-level, group-span and collapse paths +# `improvise-anr9` added (improvise-plcx). A seed that 404s is NOT an error -- +# worker.js answers it with an empty model -- so every spec over these asserts +# real labels rather than that a grid exists. +cp -f "$root"/harness/fixtures/*.improv "$serve/app/" + wasm-pack build "$root/harness/opfs-contract" \ --target web --out-dir "$root/harness/www/opfs/pkg" diff --git a/harness/fixtures/grouped.improv b/harness/fixtures/grouped.improv new file mode 100644 index 00000000..164bce39 --- /dev/null +++ b/harness/fixtures/grouped.improv @@ -0,0 +1,182 @@ +v2025-04-09 +# Harness grouped fixture +Initial View: Grouped + +## View: Grouped +Region: row +Store: row +Month: column +Channel: column +_Measure: page, Revenue +_Index: none +_Dim: none +collapsed: Region/Upland +format: ,.0 + +## Category: Region +- North[Coastal] +- South[Coastal] +- East[Inland] +- West[Inland] +- Ridge[Upland] +- Valley[Upland] + +## Category: Store +- Main, Annex + +## Category: Month +- Jan[Q1] +- Feb[Q1] +- Mar[Q2] + +## Category: Channel +- Web, Shop + +## Category: _Measure +- Revenue, Cost + +## Data +Region=North, Store=Main, Month=Jan, Channel=Web, _Measure=Revenue = 1000 +Region=North, Store=Main, Month=Jan, Channel=Web, _Measure=Cost = 333 +Region=North, Store=Main, Month=Jan, Channel=Shop, _Measure=Revenue = 1040 +Region=North, Store=Main, Month=Jan, Channel=Shop, _Measure=Cost = 346 +Region=North, Store=Main, Month=Feb, Channel=Web, _Measure=Revenue = 1110 +Region=North, Store=Main, Month=Feb, Channel=Web, _Measure=Cost = 370 +Region=North, Store=Main, Month=Feb, Channel=Shop, _Measure=Revenue = 1150 +Region=North, Store=Main, Month=Feb, Channel=Shop, _Measure=Cost = 383 +Region=North, Store=Main, Month=Mar, Channel=Web, _Measure=Revenue = 1220 +Region=North, Store=Main, Month=Mar, Channel=Web, _Measure=Cost = 406 +Region=North, Store=Main, Month=Mar, Channel=Shop, _Measure=Revenue = 1260 +Region=North, Store=Main, Month=Mar, Channel=Shop, _Measure=Cost = 420 +Region=North, Store=Annex, Month=Jan, Channel=Web, _Measure=Revenue = 1300 +Region=North, Store=Annex, Month=Jan, Channel=Web, _Measure=Cost = 433 +Region=North, Store=Annex, Month=Jan, Channel=Shop, _Measure=Revenue = 1340 +Region=North, Store=Annex, Month=Jan, Channel=Shop, _Measure=Cost = 446 +Region=North, Store=Annex, Month=Feb, Channel=Web, _Measure=Revenue = 1410 +Region=North, Store=Annex, Month=Feb, Channel=Web, _Measure=Cost = 470 +Region=North, Store=Annex, Month=Feb, Channel=Shop, _Measure=Revenue = 1450 +Region=North, Store=Annex, Month=Feb, Channel=Shop, _Measure=Cost = 483 +Region=North, Store=Annex, Month=Mar, Channel=Web, _Measure=Revenue = 1520 +Region=North, Store=Annex, Month=Mar, Channel=Web, _Measure=Cost = 506 +Region=North, Store=Annex, Month=Mar, Channel=Shop, _Measure=Revenue = 1560 +Region=North, Store=Annex, Month=Mar, Channel=Shop, _Measure=Cost = 520 +Region=South, Store=Main, Month=Jan, Channel=Web, _Measure=Revenue = 1700 +Region=South, Store=Main, Month=Jan, Channel=Web, _Measure=Cost = 566 +Region=South, Store=Main, Month=Jan, Channel=Shop, _Measure=Revenue = 1740 +Region=South, Store=Main, Month=Jan, Channel=Shop, _Measure=Cost = 580 +Region=South, Store=Main, Month=Feb, Channel=Web, _Measure=Revenue = 1810 +Region=South, Store=Main, Month=Feb, Channel=Web, _Measure=Cost = 603 +Region=South, Store=Main, Month=Feb, Channel=Shop, _Measure=Revenue = 1850 +Region=South, Store=Main, Month=Feb, Channel=Shop, _Measure=Cost = 616 +Region=South, Store=Main, Month=Mar, Channel=Web, _Measure=Revenue = 1920 +Region=South, Store=Main, Month=Mar, Channel=Web, _Measure=Cost = 640 +Region=South, Store=Main, Month=Mar, Channel=Shop, _Measure=Revenue = 1960 +Region=South, Store=Main, Month=Mar, Channel=Shop, _Measure=Cost = 653 +Region=South, Store=Annex, Month=Jan, Channel=Web, _Measure=Revenue = 2000 +Region=South, Store=Annex, Month=Jan, Channel=Web, _Measure=Cost = 666 +Region=South, Store=Annex, Month=Jan, Channel=Shop, _Measure=Revenue = 2040 +Region=South, Store=Annex, Month=Jan, Channel=Shop, _Measure=Cost = 680 +Region=South, Store=Annex, Month=Feb, Channel=Web, _Measure=Revenue = 2110 +Region=South, Store=Annex, Month=Feb, Channel=Web, _Measure=Cost = 703 +Region=South, Store=Annex, Month=Feb, Channel=Shop, _Measure=Revenue = 2150 +Region=South, Store=Annex, Month=Feb, Channel=Shop, _Measure=Cost = 716 +Region=South, Store=Annex, Month=Mar, Channel=Web, _Measure=Revenue = 2220 +Region=South, Store=Annex, Month=Mar, Channel=Web, _Measure=Cost = 740 +Region=South, Store=Annex, Month=Mar, Channel=Shop, _Measure=Revenue = 2260 +Region=South, Store=Annex, Month=Mar, Channel=Shop, _Measure=Cost = 753 +Region=East, Store=Main, Month=Jan, Channel=Web, _Measure=Revenue = 2400 +Region=East, Store=Main, Month=Jan, Channel=Web, _Measure=Cost = 800 +Region=East, Store=Main, Month=Jan, Channel=Shop, _Measure=Revenue = 2440 +Region=East, Store=Main, Month=Jan, Channel=Shop, _Measure=Cost = 813 +Region=East, Store=Main, Month=Feb, Channel=Web, _Measure=Revenue = 2510 +Region=East, Store=Main, Month=Feb, Channel=Web, _Measure=Cost = 836 +Region=East, Store=Main, Month=Feb, Channel=Shop, _Measure=Revenue = 2550 +Region=East, Store=Main, Month=Feb, Channel=Shop, _Measure=Cost = 850 +Region=East, Store=Main, Month=Mar, Channel=Web, _Measure=Revenue = 2620 +Region=East, Store=Main, Month=Mar, Channel=Web, _Measure=Cost = 873 +Region=East, Store=Main, Month=Mar, Channel=Shop, _Measure=Revenue = 2660 +Region=East, Store=Main, Month=Mar, Channel=Shop, _Measure=Cost = 886 +Region=East, Store=Annex, Month=Jan, Channel=Web, _Measure=Revenue = 2700 +Region=East, Store=Annex, Month=Jan, Channel=Web, _Measure=Cost = 900 +Region=East, Store=Annex, Month=Jan, Channel=Shop, _Measure=Revenue = 2740 +Region=East, Store=Annex, Month=Jan, Channel=Shop, _Measure=Cost = 913 +Region=East, Store=Annex, Month=Feb, Channel=Web, _Measure=Revenue = 2810 +Region=East, Store=Annex, Month=Feb, Channel=Web, _Measure=Cost = 936 +Region=East, Store=Annex, Month=Feb, Channel=Shop, _Measure=Revenue = 2850 +Region=East, Store=Annex, Month=Feb, Channel=Shop, _Measure=Cost = 950 +Region=East, Store=Annex, Month=Mar, Channel=Web, _Measure=Revenue = 2920 +Region=East, Store=Annex, Month=Mar, Channel=Web, _Measure=Cost = 973 +Region=East, Store=Annex, Month=Mar, Channel=Shop, _Measure=Revenue = 2960 +Region=East, Store=Annex, Month=Mar, Channel=Shop, _Measure=Cost = 986 +Region=West, Store=Main, Month=Jan, Channel=Web, _Measure=Revenue = 3100 +Region=West, Store=Main, Month=Jan, Channel=Web, _Measure=Cost = 1033 +Region=West, Store=Main, Month=Jan, Channel=Shop, _Measure=Revenue = 3140 +Region=West, Store=Main, Month=Jan, Channel=Shop, _Measure=Cost = 1046 +Region=West, Store=Main, Month=Feb, Channel=Web, _Measure=Revenue = 3210 +Region=West, Store=Main, Month=Feb, Channel=Web, _Measure=Cost = 1070 +Region=West, Store=Main, Month=Feb, Channel=Shop, _Measure=Revenue = 3250 +Region=West, Store=Main, Month=Feb, Channel=Shop, _Measure=Cost = 1083 +Region=West, Store=Main, Month=Mar, Channel=Web, _Measure=Revenue = 3320 +Region=West, Store=Main, Month=Mar, Channel=Web, _Measure=Cost = 1106 +Region=West, Store=Main, Month=Mar, Channel=Shop, _Measure=Revenue = 3360 +Region=West, Store=Main, Month=Mar, Channel=Shop, _Measure=Cost = 1120 +Region=West, Store=Annex, Month=Jan, Channel=Web, _Measure=Revenue = 3400 +Region=West, Store=Annex, Month=Jan, Channel=Web, _Measure=Cost = 1133 +Region=West, Store=Annex, Month=Jan, Channel=Shop, _Measure=Revenue = 3440 +Region=West, Store=Annex, Month=Jan, Channel=Shop, _Measure=Cost = 1146 +Region=West, Store=Annex, Month=Feb, Channel=Web, _Measure=Revenue = 3510 +Region=West, Store=Annex, Month=Feb, Channel=Web, _Measure=Cost = 1170 +Region=West, Store=Annex, Month=Feb, Channel=Shop, _Measure=Revenue = 3550 +Region=West, Store=Annex, Month=Feb, Channel=Shop, _Measure=Cost = 1183 +Region=West, Store=Annex, Month=Mar, Channel=Web, _Measure=Revenue = 3620 +Region=West, Store=Annex, Month=Mar, Channel=Web, _Measure=Cost = 1206 +Region=West, Store=Annex, Month=Mar, Channel=Shop, _Measure=Revenue = 3660 +Region=West, Store=Annex, Month=Mar, Channel=Shop, _Measure=Cost = 1220 +Region=Ridge, Store=Main, Month=Jan, Channel=Web, _Measure=Revenue = 3800 +Region=Ridge, Store=Main, Month=Jan, Channel=Web, _Measure=Cost = 1266 +Region=Ridge, Store=Main, Month=Jan, Channel=Shop, _Measure=Revenue = 3840 +Region=Ridge, Store=Main, Month=Jan, Channel=Shop, _Measure=Cost = 1280 +Region=Ridge, Store=Main, Month=Feb, Channel=Web, _Measure=Revenue = 3910 +Region=Ridge, Store=Main, Month=Feb, Channel=Web, _Measure=Cost = 1303 +Region=Ridge, Store=Main, Month=Feb, Channel=Shop, _Measure=Revenue = 3950 +Region=Ridge, Store=Main, Month=Feb, Channel=Shop, _Measure=Cost = 1316 +Region=Ridge, Store=Main, Month=Mar, Channel=Web, _Measure=Revenue = 4020 +Region=Ridge, Store=Main, Month=Mar, Channel=Web, _Measure=Cost = 1340 +Region=Ridge, Store=Main, Month=Mar, Channel=Shop, _Measure=Revenue = 4060 +Region=Ridge, Store=Main, Month=Mar, Channel=Shop, _Measure=Cost = 1353 +Region=Ridge, Store=Annex, Month=Jan, Channel=Web, _Measure=Revenue = 4100 +Region=Ridge, Store=Annex, Month=Jan, Channel=Web, _Measure=Cost = 1366 +Region=Ridge, Store=Annex, Month=Jan, Channel=Shop, _Measure=Revenue = 4140 +Region=Ridge, Store=Annex, Month=Jan, Channel=Shop, _Measure=Cost = 1380 +Region=Ridge, Store=Annex, Month=Feb, Channel=Web, _Measure=Revenue = 4210 +Region=Ridge, Store=Annex, Month=Feb, Channel=Web, _Measure=Cost = 1403 +Region=Ridge, Store=Annex, Month=Feb, Channel=Shop, _Measure=Revenue = 4250 +Region=Ridge, Store=Annex, Month=Feb, Channel=Shop, _Measure=Cost = 1416 +Region=Ridge, Store=Annex, Month=Mar, Channel=Web, _Measure=Revenue = 4320 +Region=Ridge, Store=Annex, Month=Mar, Channel=Web, _Measure=Cost = 1440 +Region=Ridge, Store=Annex, Month=Mar, Channel=Shop, _Measure=Revenue = 4360 +Region=Ridge, Store=Annex, Month=Mar, Channel=Shop, _Measure=Cost = 1453 +Region=Valley, Store=Main, Month=Jan, Channel=Web, _Measure=Revenue = 4500 +Region=Valley, Store=Main, Month=Jan, Channel=Web, _Measure=Cost = 1500 +Region=Valley, Store=Main, Month=Jan, Channel=Shop, _Measure=Revenue = 4540 +Region=Valley, Store=Main, Month=Jan, Channel=Shop, _Measure=Cost = 1513 +Region=Valley, Store=Main, Month=Feb, Channel=Web, _Measure=Revenue = 4610 +Region=Valley, Store=Main, Month=Feb, Channel=Web, _Measure=Cost = 1536 +Region=Valley, Store=Main, Month=Feb, Channel=Shop, _Measure=Revenue = 4650 +Region=Valley, Store=Main, Month=Feb, Channel=Shop, _Measure=Cost = 1550 +Region=Valley, Store=Main, Month=Mar, Channel=Web, _Measure=Revenue = 4720 +Region=Valley, Store=Main, Month=Mar, Channel=Web, _Measure=Cost = 1573 +Region=Valley, Store=Main, Month=Mar, Channel=Shop, _Measure=Revenue = 4760 +Region=Valley, Store=Main, Month=Mar, Channel=Shop, _Measure=Cost = 1586 +Region=Valley, Store=Annex, Month=Jan, Channel=Web, _Measure=Revenue = 4800 +Region=Valley, Store=Annex, Month=Jan, Channel=Web, _Measure=Cost = 1600 +Region=Valley, Store=Annex, Month=Jan, Channel=Shop, _Measure=Revenue = 4840 +Region=Valley, Store=Annex, Month=Jan, Channel=Shop, _Measure=Cost = 1613 +Region=Valley, Store=Annex, Month=Feb, Channel=Web, _Measure=Revenue = 4910 +Region=Valley, Store=Annex, Month=Feb, Channel=Web, _Measure=Cost = 1636 +Region=Valley, Store=Annex, Month=Feb, Channel=Shop, _Measure=Revenue = 4950 +Region=Valley, Store=Annex, Month=Feb, Channel=Shop, _Measure=Cost = 1650 +Region=Valley, Store=Annex, Month=Mar, Channel=Web, _Measure=Revenue = 5020 +Region=Valley, Store=Annex, Month=Mar, Channel=Web, _Measure=Cost = 1673 +Region=Valley, Store=Annex, Month=Mar, Channel=Shop, _Measure=Revenue = 5060 +Region=Valley, Store=Annex, Month=Mar, Channel=Shop, _Measure=Cost = 1686 diff --git a/harness/tests/grouped.spec.js b/harness/tests/grouped.spec.js new file mode 100644 index 00000000..f5ac2c86 --- /dev/null +++ b/harness/tests/grouped.spec.js @@ -0,0 +1,144 @@ +// 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. +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"); + 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(page.locator("#grid tbody tr").first().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(page.locator("#grid tbody tr").first().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"), + }); + // Two, not three: `Coastal` is the frame's FIRST entry and a leading group + // header is dropped before it reaches the page — improvise-0o6x, open. If + // this count becomes 3, that bug is fixed and this line is the record of + // when. It is not an endorsement. + await expect(groupRows).toHaveCount(2); + await expect(groupRows).toHaveText(["▼ 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, + ); + }); +});