Skip to content

test: frontend chart guards never construct a Chart #383

Description

@FlyM1ss

Frontend chart tests never construct a Chart — headless Chart.js recipe


1. The problem

test_frontend_board_frame.py reads the shipping dashboard/frontend/js/leaderboard.js as
text, brace-matches named functions out of it (_extract_function, :29), concatenates them
with the BOARD_* constant lines, and evals the result under node -e (_run_node, :60).
That is a genuinely good harness for what it covers: the extracted functions are the shipping
ones, so a rename or deletion reddens the suite instead of leaving a stale copy passing, and the
pure geometry (boardFrameLayout, boardStackLabels, boardVisibleEndpoints,
boardLabelBlockWidth) gets real behavioural coverage. Keep all of it.

What it structurally cannot reach is everything that only exists once Chart.js is running.
No test in the repo constructs a Chart. So every claim about composition — that beforeLayout
is a hook Chart.js actually calls, that writing chart.options.layout.padding.right moves
chartArea, that the reserved gutter is not hoverable, that the drawn label block matches the
measured floor, that a draw hook does not throw mid-frame and take the whole chart down — is
verified only by asserting that a substring is present in the source. For example
test_the_gutter_is_reserved_in_beforelayout_and_never_in_the_domain (:308) asserts the literal
string "beforeLayout(chart)" appears in the factory. That assertion passes on a plugin that is
never registered on any chart, and it passes on a Chart.js major bump that renames the hook.

The concrete instance is in this branch's own history. The bug fixed by
fix(leaderboard): stop the endpoint label stack overflowing the canvas clamped the label stack to
chartArea with two whole-stack shifts that cancelled exactly, and drew the last label 5px past the
canvas bottom on the Leaderboard tab and 10.4px past it on screen 0 — sliced through the middle, on
both surfaces, at 1280/1440/1920. Every source-shape guard in the module stayed green through it.
It was caught by a human opening a browser. The fix added boardStackLabels unit coverage, which
closes that one hole; the class is still open, and the next defect in it will also ship green.

2. The recipe

Chart.js's UMD build selects its platform at construction time via _detectPlatform(), which falls
back to BasicPlatform when _isDomSupported() is false. Under plain node window is undefined,
so that fallback is automatic — I did not force it and never touched Chart.platforms.
BasicPlatform.acquireContext(item) is essentially item.getContext('2d'), so any object with a
getContext method is an acceptable canvas. That is the whole trick.

  • Chart.js version run against: 4.4.0, fetched as
    https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js — the same version and build the
    app loads at dashboard/frontend/app.html:20 (the app loads the .min variant under an SRI pin).
  • No jsdom. No node-canvas. No npm install. Node 22 and one 205KB file.

The reusable core (~40 lines)

// --- load the shipping source and extract by name (same idea as the pytest harness) ---
const fs = require('fs');
const SRC = fs.readFileSync('dashboard/frontend/js/leaderboard.js', 'utf8');
function extract(name){
  const marker = `function ${name}(`; const start = SRC.indexOf(marker);
  let i = SRC.indexOf('{', SRC.indexOf(')', start)); let d = 0;
  for(;;i++){ if(SRC[i]==='{')d++; else if(SRC[i]==='}'){ d--; if(d===0) return SRC.slice(start,i+1); } }
}
const consts = SRC.split('\n').filter(l => /^const BOARD_[A-Z_]+ = .+;$/.test(l)).join('\n');
const names = ['shortName','hexToRgba','boardSeriesColor','boardPillTextColor','boardLabelBlockWidth',
  'boardFrameLayout','boardVisibleEndpoints','boardSignedPercent','boardDefaultValueText',
  'boardRoundRect','boardStackLabels','createEndpointLabelPlugin','createAxisArrowPlugin'];
eval(consts + '\n' + names.map(extract).join('\n'));

// --- Proxy 2D context: unknown property -> no-op fn; records the draw calls we care about ---
const ops = [];
function makeCtx(canvas){
  const store = { canvas, font:'', fillStyle:'', strokeStyle:'', lineWidth:1, textBaseline:'',
    textAlign:'', globalAlpha:1, lineJoin:'', lineCap:'', lineDashOffset:0, direction:'ltr',
    filter:'none', globalCompositeOperation:'source-over', shadowBlur:0, shadowColor:'',
    imageSmoothingEnabled:true };
  let cur = null;
  const fns = {
    measureText: (t) => ({ width: String(t).length * 6.2 }),   // FAKE metric — see Limitations
    getLineDash: () => [], createLinearGradient: () => ({ addColorStop(){} }),
    fillText: (t,x,y) => ops.push({op:'fillText',t,x,y}),
    arc: (x,y,r) => { cur = {op:'arc',x,y,r}; },
    fill: () => { if(cur){ ops.push(cur); cur = null; } },
    moveTo: (x,y) => ops.push({op:'moveTo',x,y}),
    lineTo: (x,y) => ops.push({op:'lineTo',x,y}),
    beginPath: () => {},
  };
  return new Proxy(store, { get(t,p){ if(p in fns) return fns[p]; if(p in t) return t[p]; return ()=>{}; },
                            set(t,p,v){ t[p]=v; return true; }, has(){ return true; } });
}

// --- stub canvas: NOTE we do not manage canvas.width ourselves (see Limitations) ---
function makeCanvas(w,h){
  const c = { width:w, height:h, clientWidth:w, clientHeight:h, style:{},
    getAttribute:()=>null, setAttribute(){}, addEventListener(){}, removeEventListener(){},
    getBoundingClientRect:()=>({left:0,top:0,right:w,bottom:h,width:w,height:h}) };
  c.getContext = () => (c._ctx || (c._ctx = makeCtx(c)));
  return c;
}

const Chart = require('./chart.umd.js');   // UMD default-exports the Chart constructor

Construct and draw:

const chart = new Chart(makeCanvas(1120, 268), {
  type: 'line',
  data: { labels, datasets },
  plugins: [createAxisArrowPlugin(), createEndpointLabelPlugin({})],
  options: { responsive:false, animation:false, maintainAspectRatio:false,
             plugins:{ legend:{display:false}, tooltip:{enabled:false} } },
});
chart.draw();
// now readable: chart.chartArea, chart.width/height, chart.$boardFrame, and `ops`

The full worked example I ran is probe2.js (9 scenarios: tab at 1280/1440/1920 with 12 clustered
endpoints, home at 1440, phone 390, the 1440x600 short case, a spread board, 1 series, 2 tied
series). Filter recorded ops to the gutter with o.x >= chart.chartArea.right — otherwise you also
capture Chart.js's own axis tick labels, which I initially did and which made every minY read 0.

What this found that reading could not

  • chart.options.layout.padding.right = … on a chart with no layout in its config (screen 0's
    case) resolves through Chart.js's resolver proxy. A plain reading says you are about to mutate
    Chart.defaults.layout. You are not — the proxy's set trap materialises options.layout.padding
    on the chart's own user scope. Verified: Chart.defaults.layout byte-identical before/after, and a
    plain chart built afterwards is unaffected.
  • chartArea.right = 720 on a 1200px canvas with a 480px gutter → the hover gate's premise
    (resolveHoverTarget rejects x > area.right) survives.
  • Screen 0's nearest tooltip returns 0 items anywhere in the gutter
    (chart.getElementsAtEventForMode), confirming a claim that was until then only a docstring.
  • The gutter tracks resize in both directions with no accumulation (1600→700→1600 ⇒ 640→280→640).

3. The one assertion worth writing first

@pytest.mark.parametrize(
    "width,height,series,surface",
    [
        (1120, 268, 12, "Leaderboard tab @1440"),
        (1600, 268, 12, "Leaderboard tab @1920"),
        (960, 268, 12, "Leaderboard tab @1280"),
        (560, 211, 9, "screen 0 @1440"),
        (560, 132, 9, "screen 0 @1440x600"),
        (330, 132, 9, "screen 0 @390"),
    ],
)
def test_no_gutter_draw_call_lands_outside_the_canvas(width, height, series, surface):
    """THE REGRESSION, end to end through a real Chart.js layout.

    The unit tests below pin `boardStackLabels` against RECORDED anchors. This pins the
    same invariant against anchors Chart.js actually computed, so it also covers the
    parts no pure function sees: that `beforeLayout` is a hook Chart.js calls, that
    writing `layout.padding.right` moves `chartArea`, and that the measured label block
    matches the block the draw hook paints.
    """
    out = _run_chart_node(width, height, series)
    if not out["drawLabels"]:
        assert out["gutterDrawCalls"] == 0, f"{surface}: arrow-only must draw no labels"
        return
    assert out["topEdge"] >= 0, f"{surface}: label {out['topEdge']}px above the canvas"
    assert out["bottomEdge"] <= height, f"{surface}: label ends at {out['bottomEdge']} on {height}px"
    assert out["maxX"] <= width, f"{surface}: label runs {out['maxX'] - width}px past the right edge"

Where it sits. Beside test_no_label_is_laid_out_past_either_canvas_edge (:621), not instead
of it — the unit test is faster and points at the failing helper, which a composition test cannot.
What it does subsume over time is the two recorded fixtures _HOME_1440/_TAB_1440 (:609-615).
Those are hand-recorded from one browser session at one viewport and will go stale silently; real
layout regenerates equivalent anchors at every parametrised size for free. Keep the fixtures while
they are fresh, and let this test be the reason not to add a third one.

Adoption cost, stated plainly. The test needs chart.umd.js on disk — CI installs
requirements.txt only and there is no npm step. Vendor it next to the existing wire fixtures
(dashboard/backend/tests/fixtures/, which already holds four), pytest.skip when it is absent
exactly as _run_node already skips when node is absent, and add a guard asserting the vendored
version string matches the chart.js@X.Y.Z pinned at app.html:20
— otherwise the harness drifts
from what ships, which is precisely the "testing a copy that no longer runs" failure this module's
own docstring warns about.

4. Why it generalises — and where it stops

PR C carries over completely. Same two Chart.js surfaces, same frame; the live-period work
touches these charts directly.

PR B does not carry the harness. The landing hero is Recharts inside React, which renders SVG
through the React tree rather than painting to a 2D context — there is no getContext seam to stub,
and the equivalent needs @testing-library/react + jsdom and the Vite/React toolchain in
dashboard/landing/. That is a materially more expensive test than this one, and worth costing
separately rather than assuming this recipe transfers.

What does carry to PR B is the part that matters most:

  • The assertion shape — "no rail element is laid out past the container edge", read off computed
    geometry rather than matched in source. For Recharts that means rendering and reading the x/y
    of the emitted <text>/<circle> nodes.
  • The principle — geometry gets verified through a real layout pass. PR B mirrors this frame's
    arithmetic into a second stack, so it inherits the same failure mode (measured-vs-drawn drift)
    with none of this branch's pure-function coverage unless it is written deliberately.

What explicitly does not carry: the layout.padding mechanism and the window.* export seam are
Chart.js/classic-script specific. Recharts reserves space with margin on the chart component, so
"the gutter is padding, never the scale domain" has to be re-established there on its own terms, not
assumed. Note also that the shipped landing bundle is hand-patched rather than a clean Vite output,
so a PR B test must be explicit about whether it tests the source or the artefact.

5. Limitations — do not oversell this

  1. measureText is fake (6.2px/char). Nothing gated on real font metrics is faithfully
    reproduced, so the harness can assert the width arithmetic is self-consistent, never that a real
    Inter 600 11px label actually fits. Real-metric checks still need a browser.
  2. Nothing is rasterised. It records where draw calls land, not what they look like. Overlap,
    occlusion, colour, contrast and anti-aliasing are invisible — two labels painted at the same y
    are both "in bounds". This does not replace the browser gate; it replaces the browser gate's
    regression role.
  3. save()/restore() are no-ops in this stub, so canvas state leaking between draws (a
    setLineDash never cleared, a fillStyle left set) is a real defect class this cannot see. Model
    those two if you ever want that coverage.
  4. The recorded arc op keeps only the last arc before fill() — a path with several arcs is
    under-counted. Fine for this frame (one arc per fill); check before reusing.
  5. textAlign/textBaseline are recorded but not applied, so a recorded (x,y) is the text
    anchor, not the rendered box. My bounds assertion approximates the box by adding PILL/2 and an
    estimated text width — good enough to catch a 10px overhang, not a 1px one.
  6. A chart.resize() trap that cost me a false finding. If you set canvas.width yourself before
    calling resize(), Chart.js's retinaScale() sees the canvas already at the target device size,
    returns false, and _resize() returns early without re-running layout — so the gutter appears
    frozen. It is not: let Chart.js own canvas.width and it tracks correctly. I initially read this
    as a resize bug in the feature and had to disambiguate it.
  7. The extract-by-name list is manual. 98d6d3c extracted a new boardSignedPercent helper and
    my probes broke until it was added to names. Worth noting that this fails loudly, with a
    ReferenceError naming the missing function — the opposite of the source-shape guards' failure
    mode, and an argument for the technique rather than against it.
  8. BasicPlatform means no event plumbing. Interaction is reachable only through direct API calls
    (chart.getElementsAtEventForMode, chart.isPointInArea), not by dispatching real pointer events.
    The tab's custom pointermove gate cannot be driven this way.

Provenance of the numbers

Everything above was measured at commit 42f24c4 on the PR A branch. A later
commit on that branch turns the 3:2 gutter fraction into a ceiling rather
than the gutter width, so the illustrative figures (480px gutter,
chartArea.right = 720, 1600→700→1600 ⇒ 640→280→640) no longer reproduce
exactly. The findings they support are unaffected — the hover gate's premise,
the Chart.defaults.layout non-mutation, and the zero-elements-in-the-gutter
result are all structural.

One addition worth folding into the version guard

app.html:20 pins Chart.js under Subresource Integrity:

chart.js@4.4.0/dist/chart.umd.min.js
integrity="sha384-e6nUZLBkQ86NJ6TVVKAeSaK8jWa3NhkYWZFomE39AvDbQWeie9PlQqM3pmYW5d1g"

So the vendored test copy can be checked against more than a version string —
though note the pin covers the .min build while the harness wants the
unminified one, so the hash cannot be reused directly. Either vendor the .min
variant and assert its SHA-384 equals the integrity attribute byte for byte
(strongest, and self-updating pressure: bumping one forces the other), or vendor
the unminified build and assert the chart.js@X.Y.Z version substring matches.
The first is worth the small extra effort — a version string can be edited to
match while the file underneath is something else entirely.


Surfaced by the whole-branch review of #382.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions