From fd1383e4bdb97531d789b6ae00143c47b7cc9121 Mon Sep 17 00:00:00 2001 From: quality Date: Wed, 7 Oct 2026 17:44:19 -0400 Subject: [PATCH] test(coverage): reach MetricsDashboard's trends fallback with an overlay add op The trend grid at src/components/MetricsDashboard/index.js line 76 renders through `Object.entries(lifecycle?.trends || {})`. It is the fifth of the file's five empty-collection fallbacks and the only one no build reached: the variant overlay could not clear `referenceArchitectureLifecycle.trends` while it also had to append a point onto `trends.submissions.values` for the single-point Sparkline arm, because `set` runs before `append`. The constraint was the overlay vocabulary, not the build count. `set`, `append` and `setWhere` all require the key they name to be present, so an overlay could reshape a record the real data carried but never introduce one under a keyed collection. `submissions` is the only trend with no values, so giving it a point traded the no-values arm for the single-point one. `add` introduces a key whose parent object exists and whose leaf does not -- the fail-loud rule pointing the other way, so a regenerated data file that grows the same key breaks the build instead of having its record silently overwritten by the fixture. The ordinary coverage build now `add`s a trend beside the real two, rendering all three Sparkline arms on the one /metrics page, and the variant build clears `trends` with the four collections it already cleared. Verified at 03cfcfe by rendering both builds: the real /metrics carries one `No trend data yet` placeholder, the five-point `publications` sparkline and a lone point at `points=50,4`; the variant /metrics renders the lifecycle panel with no sparkline, no placeholder and no trend label at all. The full unit suite is green at 2030 tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: quality --- tests/e2e-data-fixtures.test.mjs | 86 ++++++++++++ tests/e2e/fixtures/data-variants/metrics.json | 11 +- tests/e2e/fixtures/data/metrics.json | 14 ++ .../metrics-empty-collections-variant.spec.js | 66 +++++---- tests/e2e/metrics-sparkline-variant.spec.js | 130 ------------------ tests/e2e/metrics-sparkline.spec.js | 126 +++++++++++++++++ tests/tools/e2e-data-fixtures.cjs | 51 +++++-- 7 files changed, 311 insertions(+), 173 deletions(-) create mode 100644 tests/e2e/fixtures/data/metrics.json delete mode 100644 tests/e2e/metrics-sparkline-variant.spec.js create mode 100644 tests/e2e/metrics-sparkline.spec.js diff --git a/tests/e2e-data-fixtures.test.mjs b/tests/e2e-data-fixtures.test.mjs index c3027a89..50f7c273 100644 --- a/tests/e2e-data-fixtures.test.mjs +++ b/tests/e2e-data-fixtures.test.mjs @@ -55,6 +55,73 @@ test('an overlay appends to an existing array', () => { ]); }); +// `add` is the one operation whose leaf must be absent rather than present: +// it exists so an overlay can introduce a record under a keyed collection, +// which `set`, `append` and `setWhere` all refuse to do. +test('an overlay adds a key the real data does not carry', () => { + const patched = applyOverlay( + { lifecycle: { trends: { real: { values: [] } } } }, + overlay({ add: { 'lifecycle.trends.fixture': { values: [1] } } }), + 'fixture', + ); + assert.deepEqual(patched.lifecycle.trends, { + real: { values: [] }, + fixture: { values: [1] }, + }); +}); + +// The fail-loud guarantee, pointing the other way from `set`'s: a regenerated +// data file that grows the same key must break the build rather than have the +// record it grew silently replaced by the fixture. +test('adding a key the real data already carries is rejected', () => { + assert.throws( + () => + applyOverlay( + { lifecycle: { trends: { fixture: { values: [] } } } }, + overlay({ add: { 'lifecycle.trends.fixture': { values: [1] } } }), + 'fixture', + ), + /cannot add "lifecycle\.trends\.fixture"; the real data already carries it/, + ); +}); + +test('adding through a missing parent is rejected', () => { + assert.throws( + () => + applyOverlay( + { lifecycle: {} }, + overlay({ add: { 'lifecycle.trends.fixture': {} } }), + 'fixture', + ), + /"lifecycle\.trends" is missing from the real data/, + ); +}); + +test('an added value is cloned rather than shared with the overlay', () => { + const value = { values: [] }; + const patched = applyOverlay( + { trends: {} }, + overlay({ add: { 'trends.fixture': value } }), + 'fixture', + ); + value.values.push('mutated after the fact'); + assert.deepEqual(patched.trends.fixture, { values: [] }); +}); + +// `set` runs before `add`, and `add` before `append`, so one overlay can add +// an array and then push onto it. +test('an overlay can append to the array it added', () => { + const patched = applyOverlay( + { trends: {} }, + overlay({ + add: { 'trends.fixture': { values: [] } }, + append: { 'trends.fixture.values': [{ date: '2026-01' }] }, + }), + 'fixture', + ); + assert.deepEqual(patched.trends.fixture.values, [{ date: '2026-01' }]); +}); + test('the real document is not mutated', () => { const data = { groups: [{ slug: 'real' }] }; applyOverlay(data, overlay({ append: { groups: [{ slug: 'added' }] } }), 'f'); @@ -434,6 +501,25 @@ test('every committed overlay still applies to its data file', () => { (data) => data.some((entry) => entry.industries.length > 0), 'no catalog entry carries an industry, so the populated arm stopped rendering', ], + [ + 'metrics.json', + (data) => + Object.values(data.referenceArchitectureLifecycle.trends).filter( + (trend) => trend.values.length === 1, + ).length === 1, + 'the ordinary coverage build must carry exactly one single-point trend, or Sparkline\u2019s centring arm has nothing to render', + ], + [ + 'metrics.json', + (data) => + Object.values(data.referenceArchitectureLifecycle.trends).some( + (trend) => trend.values.length === 0, + ) && + Object.values(data.referenceArchitectureLifecycle.trends).some( + (trend) => trend.values.length > 1, + ), + 'the added trend displaced a real one instead of rendering beside it, so the arms it was added next to stopped rendering', + ], ]; for (const [name, holds, why] of cases) { const data = loadSiteData(name, { E2E_COVERAGE: '1' }); diff --git a/tests/e2e/fixtures/data-variants/metrics.json b/tests/e2e/fixtures/data-variants/metrics.json index b1d4ac58..a585a333 100644 --- a/tests/e2e/fixtures/data-variants/metrics.json +++ b/tests/e2e/fixtures/data-variants/metrics.json @@ -1,18 +1,11 @@ { - "description": "Clears the document-level generatedAt so ReferenceArchitectures renders the ': null' arm of its syncDate ternary (src/components/ReferenceArchitectures/index.js line 23) and the '' arm beside it at line 33. Four other components read generatedAt, so clearing it moves five pages at once -- which is exactly why it belongs to the variant build rather than to the ordinary coverage build the real pages are measured from. The appended point gives the 'submissions' trend exactly one value, so MetricsDashboard's Sparkline takes the 'values.length === 1 ? 50' arm of its x-coordinate ternary (src/components/MetricsDashboard/index.js line 28). That arm has no reachable shape in the checked-in data: 'submissions' carries zero values and renders 'No trend data yet' instead, 'publications' carries five and takes the arm beside it, and those are the only two trends. Appending the point in the ordinary coverage build would not add a case -- it would trade the empty-values arm for this one, since 'submissions' is the only trend that reaches it. The variant build keeps both: the real /metrics page still renders 'No trend data yet'. The same overlay clears four collections MetricsDashboard reads through an empty fallback: referenceArchitectureLifecycle.cards and referenceArchitectureLifecycle.omitted reach the `|| []` arms at src/components/MetricsDashboard/index.js lines 65 and 85, and series and breakdowns reach the `|| {}` arms at lines 124 and 174. All four are document-level collections on the one file the /metrics page reads, so emptying them in the ordinary coverage build would trade every rendered card, disclosure item, line chart and bar chart for the fallback rather than add a case; they belong to the variant build beside the real one. They are ordinary shapes rather than defensive dead code -- metrics.json is regenerated by `npm run collect:metrics`, and a collector run that cannot reach a source emits the document without that collection. referenceArchitectureLifecycle.trends is deliberately left alone: the append below pushes onto trends.submissions.values, `set` is applied before `append`, and clearing trends would make that append fail the build. The `|| {}` arm at line 76 therefore stays out of reach of this overlay.", + "description": "Clears the document-level generatedAt so ReferenceArchitectures renders the ': null' arm of its syncDate ternary (src/components/ReferenceArchitectures/index.js line 23) and the '' arm beside it at line 33. Four other components read generatedAt, so clearing it moves five pages at once -- which is exactly why it belongs to the variant build rather than to the ordinary coverage build the real pages are measured from. The same overlay clears the five collections MetricsDashboard reads through an empty fallback: referenceArchitectureLifecycle.cards and referenceArchitectureLifecycle.omitted reach the `|| []` arms at src/components/MetricsDashboard/index.js lines 65 and 85, referenceArchitectureLifecycle.trends reaches the `|| {}` arm at line 76, and series and breakdowns reach the `|| {}` arms at lines 124 and 174. All five are document-level collections on the one file the /metrics page reads, so emptying them in the ordinary coverage build would trade every rendered card, trend, disclosure item, line chart and bar chart for the fallback rather than add a case; they belong to the variant build beside the real one. They are ordinary shapes rather than defensive dead code -- metrics.json is regenerated by `npm run collect:metrics`, and a collector run that cannot reach a source emits the document without that collection. trends is cleared here rather than left alone because the single-point sparkline arm it used to conflict with now lives in tests/e2e/fixtures/data/metrics.json, which `add`s a trend beside the real two in the ordinary build instead of appending a point onto the only trend that also reaches the no-values arm.", "set": { "generatedAt": null, "referenceArchitectureLifecycle.cards": null, "referenceArchitectureLifecycle.omitted": null, + "referenceArchitectureLifecycle.trends": null, "series": null, "breakdowns": null - }, - "append": { - "referenceArchitectureLifecycle.trends.submissions.values": [ - { - "date": "2026-01", - "value": 1 - } - ] } } diff --git a/tests/e2e/fixtures/data/metrics.json b/tests/e2e/fixtures/data/metrics.json new file mode 100644 index 00000000..a04a0ced --- /dev/null +++ b/tests/e2e/fixtures/data/metrics.json @@ -0,0 +1,14 @@ +{ + "description": "Adds one lifecycle trend carrying exactly one value so Sparkline takes the `values.length === 1 ? 50` arm of its x-coordinate ternary (src/components/MetricsDashboard/index.js line 28), which centres a lone point because the expression beside it divides by `values.length - 1`. The checked-in data defines exactly two trends: `submissions` carries zero values and returns \"No trend data yet\" before the ternary is reached, and `publications` carries five and takes the arm beside it, so no shipped shape reaches it. A trend holding one point is an ordinary shape -- `submissions` becomes one the first month a single submission is recorded. This is `add` rather than `append` onto `trends.submissions.values` on purpose: `submissions` is the only trend that reaches the no-values arm, so giving it a point would trade that arm for this one rather than add a case, which is why this arm previously needed the variant build. Adding a trend beside it is additive in the sense tests/e2e/data-fixtures.spec.js means -- both real trends keep rendering exactly as they did -- so all three Sparkline arms render on the one real /metrics page in one build, and the variant overlay is freed to clear `referenceArchitectureLifecycle.trends` outright for the `|| {}` fallback at line 76. The key must be absent from the real data or the build fails, so a collector run that grows a trend of this name breaks here instead of having it silently overwritten.", + "add": { + "referenceArchitectureLifecycle.trends.coverageFixture": { + "label": "Coverage fixture trend with a single point", + "values": [ + { + "date": "2026-01", + "value": 1 + } + ] + } + } +} diff --git a/tests/e2e/metrics-empty-collections-variant.spec.js b/tests/e2e/metrics-empty-collections-variant.spec.js index a9d4f7f6..9ef5cc33 100644 --- a/tests/e2e/metrics-empty-collections-variant.spec.js +++ b/tests/e2e/metrics-empty-collections-variant.spec.js @@ -1,41 +1,45 @@ -// End-to-end coverage for the four empty-collection fallbacks in +// End-to-end coverage for the five empty-collection fallbacks in // src/components/MetricsDashboard/index.js, which need a build of their own. // -// The component reads data/metrics.json at module scope and maps over four +// The component reads data/metrics.json at module scope and maps over five // document-level collections through a fallback that supplies an empty one: // // line 65 `(lifecycle?.cards || []).map(...)` — the lifecycle cards; +// line 76 `Object.entries(lifecycle?.trends || {})` — the trend grid; // line 85 `(lifecycle?.omitted || []).map(...)` — the "what is not yet // measurable" disclosure items; // line 124 `Object.entries(metricsData.series || {})` — the line charts; // line 174 `Object.entries(metricsData.breakdowns || {})` — the bar charts. // -// None of the four fallbacks is reachable against the data the site ships: the -// checked-in document always carries four cards, three omitted indicators, two -// series and two breakdowns. They are ordinary shapes rather than defensive -// dead code — metrics.json is regenerated by `npm run collect:metrics`, and a -// collector run that cannot reach one of its sources emits the document -// without that collection. +// None of the five fallbacks is reachable against the data the site ships: the +// checked-in document always carries four cards, two trends, three omitted +// indicators, two series and two breakdowns. They are ordinary shapes rather +// than defensive dead code — metrics.json is regenerated by `npm run +// collect:metrics`, and a collector run that cannot reach one of its sources +// emits the document without that collection. // // This is not a case tests/e2e/data-fixtures.spec.js can take. That file // covers the branches an *additive* overlay reaches, where an extra record // renders beside the real ones and nothing already covered stops rendering. -// These four are the opposite: emptying the collections in the ordinary -// coverage build would trade every rendered card, disclosure item, line chart -// and bar chart for the fallback, so the arms beside them would go uncovered -// instead. They belong to the variant build for the same reason +// These five are the opposite: emptying the collections in the ordinary +// coverage build would trade every rendered card, trend, disclosure item, line +// chart and bar chart for the fallback, so the arms beside them would go +// uncovered instead. They belong to the variant build for the same reason // AwardsTimeline's provenance paragraph does — see the preamble of // tests/e2e/data-variants.spec.js for the mechanism. `npm run // build:e2e:coverage` compiles a second site under /e2e-coverage-variant/ with // tests/e2e/fixtures/data-variants/** layered on, one `docusaurus serve` // offers both, and the report unions what each build reached. // -// The fifth collection, `referenceArchitectureLifecycle.trends` at line 76, is -// deliberately not cleared. The same overlay appends a point to -// `trends.submissions.values` for tests/e2e/metrics-sparkline-variant.spec.js, -// `set` is applied before `append` in tests/tools/e2e-data-fixtures.cjs, and -// clearing trends would make that append fail the build. Reaching line 76 -// needs a second variant build, not a second entry in this overlay. +// `referenceArchitectureLifecycle.trends` at line 76 used to be the one +// collection this overlay could not clear. The same overlay appended a point +// to `trends.submissions.values` for the single-point sparkline arm, `set` is +// applied before `append` in tests/tools/e2e-data-fixtures.cjs, and clearing +// trends would have made that append fail the build. That conflict is gone: +// the single-point trend is now `add`ed beside the real two in the ordinary +// build (tests/e2e/fixtures/data/metrics.json, asserted by +// tests/e2e/metrics-sparkline.spec.js), so nothing in the variant build needs +// trends to survive and line 76 is cleared here with the rest. // // The real route is asserted alongside the variant one. On its own, an // assertion that the variant page renders no charts passes just as well when @@ -43,7 +47,7 @@ // route is what makes the pair evidence that the arms *switched* rather than // that one page happens to look a certain way. // -// What this does NOT promise: that the four regions leave the e2e report's +// What this does NOT promise: that the five regions leave the e2e report's // uncovered list. The browser demonstrably takes the fallbacks — the // assertions below only pass if it did — but // tests/tools/e2e-coverage-report.mjs keys a region on its exact original @@ -80,10 +84,11 @@ const chartSection = (page, id) => describeCoverage('a metrics document missing its collections', () => { test('the real page has every collection to render', async ({ page }) => { - // The premise of the whole file: against the data the site ships, all four + // The premise of the whole file: against the data the site ships, all five // collections are non-empty, so no fallback is reachable here. const lifecycle = coverageData.referenceArchitectureLifecycle; expect(lifecycle.cards.length).toBeGreaterThan(0); + expect(Object.keys(lifecycle.trends).length).toBeGreaterThan(0); expect(lifecycle.omitted.length).toBeGreaterThan(0); expect(Object.keys(coverageData.series).length).toBeGreaterThan(0); expect(Object.keys(coverageData.breakdowns).length).toBeGreaterThan(0); @@ -97,6 +102,10 @@ describeCoverage('a metrics document missing its collections', () => { await expect(section).toContainText(card.label); } + for (const trend of Object.values(lifecycle.trends)) { + await expect(section).toContainText(trend.label); + } + // The items are counted rather than matched on text: the disclosure is // collapsed by default, and tests/e2e/metrics-dashboard.spec.js already // owns the open/closed behaviour. A count still distinguishes the mapped @@ -118,6 +127,7 @@ describeCoverage('a metrics document missing its collections', () => { }) => { const lifecycle = variantData.referenceArchitectureLifecycle; expect(lifecycle.cards).toBeNull(); + expect(lifecycle.trends).toBeNull(); expect(lifecycle.omitted).toBeNull(); expect(variantData.series).toBeNull(); expect(variantData.breakdowns).toBeNull(); @@ -136,15 +146,19 @@ describeCoverage('a metrics document missing its collections', () => { }), ).toBeVisible(); - // `trends` is the one lifecycle collection this overlay leaves alone, so - // the section keeps rendering sparklines while the cards beside them are - // gone — the empty arms are the cleared collections, not the whole panel. - for (const trend of Object.values(lifecycle.trends)) { - await expect(section).toContainText(trend.label); + // `trends` is cleared too, so the grid renders nothing: no sparkline, no + // "No trend data yet" placeholder either, since that arm lives inside + // Sparkline and Sparkline is never reached. The labels are checked against + // the *real* document because the variant one no longer carries them. + const realLifecycle = coverageData.referenceArchitectureLifecycle; + for (const trend of Object.values(realLifecycle.trends)) { + await expect(section).not.toContainText(trend.label); } + await expect(section.getByRole('img')).toHaveCount(0); + await expect(section.getByText('No trend data yet')).toHaveCount(0); // Counted rather than matched on text: the card labels are prefixes of the - // trend labels this overlay keeps ("Open submissions" against "Open + // trend labels the real document carries ("Open submissions" against "Open // submissions by month"), so a per-label absence assertion would be // satisfied by the wrong element. await expect(section.locator(CARD)).toHaveCount(0); diff --git a/tests/e2e/metrics-sparkline-variant.spec.js b/tests/e2e/metrics-sparkline-variant.spec.js deleted file mode 100644 index f98c11eb..00000000 --- a/tests/e2e/metrics-sparkline-variant.spec.js +++ /dev/null @@ -1,130 +0,0 @@ -// End-to-end coverage for the one Sparkline arm that needs a build of its own. -// -// src/components/MetricsDashboard/index.js line 28 picks a sparkline point's -// x-coordinate: -// -// `${values.length === 1 ? 50 : (index / (values.length - 1)) * 100},...` -// -// The `50` arm centres a lone point, because the expression beside it divides -// by `values.length - 1` and would be a division by zero. Nothing in the -// checked-in data reaches it. `data/metrics.json` defines exactly two -// lifecycle trends: `submissions` carries zero values, so Sparkline returns -// "No trend data yet" before reaching the ternary at all, and `publications` -// carries five, which takes the arm beside it. A trend holding exactly one -// point is an ordinary shape -- `submissions` becomes one the first month a -// single submission is recorded -- and it is the shape neither trend has. -// -// This is not a case tests/e2e/data-fixtures.spec.js can take. That file -// covers the branches an *additive* overlay reaches, where the extra record -// renders alongside the real ones and nothing already covered stops -// rendering. Appending a point to `submissions` in the ordinary coverage build -// is not additive in that sense: `submissions` is the only trend that reaches -// the empty-values arm, so giving it a point trades that arm for this one -// rather than adding a case. It belongs to the variant build for the same -// reason AwardsTimeline's provenance paragraph does -- see the preamble of -// tests/e2e/data-variants.spec.js for the mechanism. `npm run -// build:e2e:coverage` compiles a second site under /e2e-coverage-variant/ -// with tests/e2e/fixtures/data-variants/** layered on, one `docusaurus serve` -// offers both, and the report unions what each build reached. -// -// The real route is asserted as well as the variant one. An assertion that -// the variant page centres a lone point passes just as well when the real page -// has quietly stopped rendering trends at all; pairing the two is what makes -// this evidence that the arm *switched* rather than that one page happens to -// look a certain way. -import { test, expect } from '../tools/e2e-coverage.cjs'; -import { loadSiteData } from '../tools/e2e-data-fixtures.cjs'; - -const METRICS_PATH = '/metrics'; -const VARIANT_BASE = '/e2e-coverage-variant'; - -// The variant site is only built by the coverage run; outside it the base -// path does not exist and the ordinary page still has an empty trend. -const describeCoverage = - process.env.E2E_COVERAGE === '1' ? test.describe : test.describe.skip; - -const COVERAGE_ENV = { E2E_COVERAGE: '1' }; -const VARIANT_ENV = { E2E_COVERAGE: '1', E2E_COVERAGE_VARIANT: '1' }; - -// Read the trends through the overlay rather than hard-coding a label or a -// number, so an edited overlay fails here instead of leaving a test that -// asserts nothing. -const trendsIn = (env) => - loadSiteData('metrics.json', env).referenceArchitectureLifecycle.trends; - -const coverageTrends = trendsIn(COVERAGE_ENV); -const variantTrends = trendsIn(VARIANT_ENV); - -// The id is fixed by the overlay, which must name a trend that already exists -// in data/metrics.json; `npm run build:e2e:coverage` fails if it does not. -const SINGLE_POINT_ID = 'submissions'; - -const NO_TREND = 'No trend data yet'; - -/** - * The aria-label Sparkline announces for a trend, which is how a sparkline is - * addressed here: the carries role="img" and no other accessible name. - */ -const trendLabel = (trend) => - `Trend from ${trend.values[0].date} to ${trend.values.at(-1).date}`; - -describeCoverage('a lifecycle trend with a single data point', () => { - test('the real page has no single-point trend to centre', async ({ - page, - }) => { - // The premise of the whole file: against the data the site ships, one - // trend is empty and the other is not single-point, so the `50` arm is - // unreachable here. - expect(coverageTrends[SINGLE_POINT_ID].values).toHaveLength(0); - expect( - Object.values(coverageTrends).filter( - (trend) => trend.values.length === 1, - ), - ).toHaveLength(0); - - await page.goto(METRICS_PATH); - - const trends = page.locator('section[aria-labelledby="lifecycle-title"]'); - await expect(trends.getByText(NO_TREND)).toBeVisible(); - }); - - test('the variant page centres the lone point at x=50', async ({ page }) => { - const trend = variantTrends[SINGLE_POINT_ID]; - expect(trend.values).toHaveLength(1); - - // The other trend keeps more than one point, so the arm beside the one - // under test stays reachable in this same build and the variant does not - // simply trade one uncovered region for another. - const multiPoint = Object.entries(variantTrends).filter( - ([id, other]) => id !== SINGLE_POINT_ID && other.values.length > 1, - ); - expect(multiPoint.length).toBeGreaterThan(0); - - await page.goto(`${VARIANT_BASE}${METRICS_PATH}`); - - // The lifecycle section still renders the rest of itself: this is a lone - // point being centred, not the page failing to build. - const lifecycle = page.locator( - 'section[aria-labelledby="lifecycle-title"]', - ); - await expect(lifecycle).toBeVisible(); - await expect(lifecycle.getByText(NO_TREND)).toHaveCount(0); - - const sparkline = lifecycle.getByRole('img', { - name: trendLabel(trend), - exact: true, - }); - await expect(sparkline).toBeVisible(); - - // `points` is one coordinate pair, so there is no separator in it, and its - // x is the centre of the 0..100 viewBox rather than an index-derived - // offset. The y is the component's own formula over the overlaid value, - // which ties the assertion to the data instead of to a literal. - const max = Math.max(trend.values[0].value, 1); - const y = 24 - (trend.values[0].value / max) * 20; - await expect(sparkline.locator('polyline')).toHaveAttribute( - 'points', - `50,${y}`, - ); - }); -}); diff --git a/tests/e2e/metrics-sparkline.spec.js b/tests/e2e/metrics-sparkline.spec.js new file mode 100644 index 00000000..49996593 --- /dev/null +++ b/tests/e2e/metrics-sparkline.spec.js @@ -0,0 +1,126 @@ +// End-to-end coverage for the Sparkline arm that centres a lone point. +// +// src/components/MetricsDashboard/index.js line 28 picks a sparkline point's +// x-coordinate: +// +// `${values.length === 1 ? 50 : (index / (values.length - 1)) * 100},...` +// +// The `50` arm centres a lone point, because the expression beside it divides +// by `values.length - 1` and would be a division by zero. Nothing in the +// checked-in data reaches it. `data/metrics.json` defines exactly two +// lifecycle trends: `submissions` carries zero values, so Sparkline returns +// "No trend data yet" before reaching the ternary at all, and `publications` +// carries five, which takes the arm beside it. A trend holding exactly one +// point is an ordinary shape -- `submissions` becomes one the first month a +// single submission is recorded -- and it is the shape neither trend has. +// +// This *is* a case tests/e2e/data-fixtures.spec.js's mechanism can take, and +// it did not used to be. The trend grid is keyed +// (`Object.entries(lifecycle?.trends || {})`), and until +// tests/tools/e2e-data-fixtures.cjs grew an `add` operation an overlay could +// only reshape a key the real document already carried. The single-point arm +// could therefore only be made by appending a point onto `submissions` -- the +// one trend that also reaches the no-values arm -- which trades one arm for +// the other rather than adding a case, so it was given the variant build. +// `add` introduces a trend *beside* the real two instead: both keep rendering +// exactly as they did, and all three Sparkline arms -- no values, one value, +// many values -- render together on the one real /metrics page in one build. +// +// Keeping it out of the variant build is what lets +// tests/e2e/metrics-empty-collections-variant.spec.js clear +// `referenceArchitectureLifecycle.trends` there for the `|| {}` fallback at +// line 76, which no build could reach while the append had to stay. +// +// All three arms are asserted together rather than the single-point one alone. +// An assertion that one sparkline centres its point passes just as well when +// the page has quietly stopped rendering the other trends; pairing them is +// what makes this evidence that the arm was *selected* rather than that the +// grid happens to hold one thing. +import { test, expect } from '../tools/e2e-coverage.cjs'; +import { loadSiteData } from '../tools/e2e-data-fixtures.cjs'; + +const METRICS_PATH = '/metrics'; +const LIFECYCLE = 'section[aria-labelledby="lifecycle-title"]'; + +// The overlay is applied by the E2E_COVERAGE=1 build only, so outside the +// coverage run there is no added trend to assert against. +const describeCoverage = + process.env.E2E_COVERAGE === '1' ? test.describe : test.describe.skip; + +const COVERAGE_ENV = { E2E_COVERAGE: '1' }; + +// Read the trends through the overlay rather than hard-coding a label or a +// number, so an edited overlay fails here instead of leaving a test that +// asserts nothing. +const trends = loadSiteData('metrics.json', COVERAGE_ENV) + .referenceArchitectureLifecycle.trends; + +const entriesWith = (predicate) => + Object.entries(trends).filter(([, trend]) => predicate(trend.values)); + +const NO_TREND = 'No trend data yet'; + +/** + * The aria-label Sparkline announces for a trend, which is how a sparkline is + * addressed here: the carries role="img" and no other accessible name. + */ +const trendLabel = (trend) => + `Trend from ${trend.values[0].date} to ${trend.values.at(-1).date}`; + +/** The component's own y formula, so the assertion is tied to the data. */ +const pointY = (point, values) => + 24 - (point.value / Math.max(...values.map((v) => v.value), 1)) * 20; + +describeCoverage('lifecycle trend sparklines', () => { + test('a lone point is centred while the arms beside it still render', async ({ + page, + }) => { + const single = entriesWith((values) => values.length === 1); + const empty = entriesWith((values) => values.length === 0); + const many = entriesWith((values) => values.length > 1); + + // Exactly one trend holds the shape under test: more than one would mean + // the overlay had displaced a real trend rather than added one. + expect(single).toHaveLength(1); + // The other two arms are present in the same document, which is what makes + // this a case added beside the real data rather than traded for it. + expect(empty.length).toBeGreaterThan(0); + expect(many.length).toBeGreaterThan(0); + + await page.goto(METRICS_PATH); + const section = page.locator(LIFECYCLE); + await expect(section).toBeVisible(); + + // The empty arm: Sparkline returns before the ternary is reached. + await expect(section.getByText(NO_TREND)).toHaveCount(empty.length); + + // The many-value arm: more than one coordinate pair, so `points` carries a + // separator and the first x is the index-derived 0 rather than the centre. + for (const [, trend] of many) { + const sparkline = section.getByRole('img', { + name: trendLabel(trend), + exact: true, + }); + await expect(sparkline).toBeVisible(); + const points = await sparkline.locator('polyline').getAttribute('points'); + expect(points.split(' ')).toHaveLength(trend.values.length); + expect(points.split(' ')[0]).toBe( + `0,${pointY(trend.values[0], trend.values)}`, + ); + } + + // The single-value arm: one coordinate pair, so there is no separator in + // `points`, and its x is the centre of the 0..100 viewBox rather than an + // index-derived offset. + const [, lone] = single[0]; + const sparkline = section.getByRole('img', { + name: trendLabel(lone), + exact: true, + }); + await expect(sparkline).toBeVisible(); + await expect(sparkline.locator('polyline')).toHaveAttribute( + 'points', + `50,${pointY(lone.values[0], lone.values)}`, + ); + }); +}); diff --git a/tests/tools/e2e-data-fixtures.cjs b/tests/tools/e2e-data-fixtures.cjs index 0b23515d..03922253 100644 --- a/tests/tools/e2e-data-fixtures.cjs +++ b/tests/tools/e2e-data-fixtures.cjs @@ -48,15 +48,34 @@ // { // "description": "why this overlay exists", // "set": { "verifiedAt": null }, +// "add": { "lifecycle.trends.coverageFixture": { ... } }, // "append": { "people.staff": [ { ... } ] }, // "setWhere": { "id=allianz": { "industries": [] } } // } // // `set` replaces the value at a dotted path that must already be present. -// `append` pushes onto an array that must already be present. `setWhere` -// patches fields of the one record of a top-level JSON array whose selector -// field holds the named value. All three are optional; `description` is -// required so the next reader knows which branch the overlay is holding open. +// `add` introduces a key that must be absent, under a parent object that must +// be present. `append` pushes onto an array that must already be present. +// `setWhere` patches fields of the one record of a top-level JSON array whose +// selector field holds the named value. All four are optional; `description` +// is required so the next reader knows which branch the overlay is holding +// open. +// +// `add` exists because `set`, `append` and `setWhere` all require the key they +// name to be present, so an overlay could only ever reshape a record the real +// data already carries -- never introduce one under a keyed collection. That +// turned any branch keyed on a collection's *presence* into a build of its +// own: MetricsDashboard's lifecycle trends are addressed by key, and the one +// trend carrying no values is also the only one that could be given a single +// value, so covering the single-value sparkline arm meant trading the +// no-values arm for it unless the trend grid could grow a record. `add` keeps +// the fail-loud guarantee pointing the other way: the parent must exist, and +// the leaf must *not*, so a regenerated data file that grows the same key +// breaks the build instead of silently having its record overwritten by the +// fixture. +// +// Operations are applied in the order `set`, `add`, `append`, `setWhere`, so +// one overlay can add an array and then push onto it. // // `setWhere` exists because `set` and `append` both walk dotted paths from a // root object, so a data file whose root is an array -- data/architectures/ @@ -84,7 +103,13 @@ const VARIANT_FIXTURE_DIR = path.join( 'data-variants', ); -const OVERLAY_KEYS = new Set(['description', 'set', 'append', 'setWhere']); +const OVERLAY_KEYS = new Set([ + 'description', + 'set', + 'add', + 'append', + 'setWhere', +]); /** * Maps a file under data/ to the overlay that patches it, or null when the @@ -202,9 +227,11 @@ function recordOf(document, selector, label) { /** * Applies one overlay document to parsed data, returning a new document. * - * Every path the overlay names must already exist: an overlay that no longer - * matches the data it patches is a broken fixture, and failing here is what - * keeps a regenerated data file from silently taking the coverage with it. + * Every path the overlay names must already exist, except an `add` leaf, which + * must not: an overlay that no longer matches the data it patches is a broken + * fixture, and failing here is what keeps a regenerated data file from + * silently taking the coverage with it -- or, for `add`, from silently having + * the record it grew overwritten by the fixture. * * @param {unknown} data parsed contents of the real data file * @param {unknown} overlay parsed contents of the overlay file @@ -232,6 +259,14 @@ function applyOverlay(data, overlay, label) { ); parent[leaf] = value; } + for (const [dottedPath, value] of Object.entries(overlay.add || {})) { + const [parent, leaf] = parentOf(patched, dottedPath, label); + if (Object.prototype.hasOwnProperty.call(parent, leaf)) + throw new Error( + `${label}: cannot add "${dottedPath}"; the real data already carries it, so the fixture would overwrite real data instead of adding a shape beside it`, + ); + parent[leaf] = structuredClone(value); + } for (const [dottedPath, entries] of Object.entries(overlay.append || {})) { if (!Array.isArray(entries)) throw new Error(`${label}: "append.${dottedPath}" must be an array`);