Skip to content

Testing

Jace edited this page Aug 30, 2026 · 3 revisions

Testing

Everything here drives a real browser against the dev server, so start that first.

bun run play          # serves localhost:5175, leave it running
bun run check:stress  # 46 cases through one element
bun run check:flow    # the words around a value
bun run check:churn   # interruption: what a second update must not break
bun run check:versus  # parity against the upstream this forked from
bun run check:docs    # the behavioural claims the documentation makes

STRESS_URL and its siblings point the harnesses at another build, a deployed one for instance. The check: commands assert. /studio does not: it is hand-driven, and it draws the mark rather than the roll.

The dev pages /versus, /edges, /suite and /look are served by bun run play but are not built into the deployed site. The checks drive them against the dev server.

bun run check:stress

Forty-six cases through one element. Grouped numbers and currency, signs, emoji, CJK, RTL, zero-width joiners, values long enough to wrap, values sitting inside an inline wrapper, values with a neighbour hard against either side.

It asserts what breaks quietly. No glyph may overlap a neighbour by more than half a pixel, a kept glyph has to land where the settled layout puts it, and the box has to end up the size of the text it holds.

bun run check:flow

The wrapping side. A word that keeps its line has to slide along it, a word that changes line has to hand off between ghosts, the leaving copies drawn for it, rather than fly, and the block has to hold its height while a value inside it resizes. The homepage's live sentence gets its own case, because a counter ticking mid-paragraph is the most likely thing to shuffle a line the reader is not watching.

A settled host, the element the value is rendered in, wears nothing a transition put on it. The harness hammers a value across widths inside a paragraph and asserts as much afterwards, with display, margin, shrink-clip and inline width all back where they started, and that the paragraph never gains a line box while the hammering runs. The width transition survives across updates, so its teardown is only ever reached by the animation finishing, and that is the thing worth guarding.

bun run check:churn

Interruption. Fifteen transitions redraw only what changed. Eighteen kept-run position checks. A rapid 0.230.40 still has the 2 rolling when the 4 arrives. That case exists to stop overlapping rolls being "fixed" by cancelling them. Twelve overlapping WordsNumbersWords cycles keep all eight glyphs of Creative visible.

bun run check:docs

Guards the behavioural claims the documentation makes, which are the ones that go quietly false. Eight of them: that scrittochange fires twice on a real change and not at all on an unchanged value, that the value setter does not animate while update() does, that the box animations live on the host rather than in the shadow tree, that a formatted number keeps the glyphs that did not change, that an offscreen element updates without animating, that the options apply as set, and that prefers-reduced-motion updates the value without motion. The last one runs in its own emulated context.

A signature check would catch none of this. Every identifier the docs name exists and is spelled correctly, and the documentation was still wrong about what happens when a value is unchanged. Names going stale is not the failure mode. Behavioural claims going stale is.

/studio

Not automated: this is the mark builder. Two slabs, each a square given a thickness and turned in 3D, drawn as projected polygons rather than as a CSS transform, so what is on screen is the file. Turn, tilt and roll are per slab, and the square, the thickness, the gap, the camera's distance and the shading are shared. Sixteen starting points sit under the stage, and Shuffle draws a fresh pair from the ranges that read as a mark rather than a mess.

The camera sits on +z looking back at the origin, so a point nearer the lens is magnified by camera / (camera - z) and a face whose normal turns away from it is dropped before anything is drawn. Faces are sorted back to front and tinted against a fixed light, which is what makes a slab read as solid rather than as an outline.

Export gives the SVG, a PNG at up to 12×, or the SVG on the clipboard. Both come from the same generator, the viewBox is measured off the ink with the padding you set, and nothing is drawn behind it, so the PNG is transparent.

/edges

Twenty-one awkward cases in a grid, most on their own clock and never stopping: the widest glyph this font stack renders against the narrowest, a width re-aimed every 90ms against a 590ms roll, digits crossing a group separator, sign flips, decimals arriving, empty and back, whitespace only, ZWJ emoji and skin tones, combining marks, right-to-left, mixed scripts, Thai and Devanagari clusters, sixty characters against one, a prefix and suffix kept either side, a value in running copy inside a flow, three hosts sharing a line, an overshooting spring over 1.4s, a value counting at 16ms, and two changes 8ms apart.

The bounce card climbs, and feeding it random numbers is not a simplification. trend: 0 reads a roll's direction off the value, so unrelated random numbers send every roll the opposite way to the last, which reads as jitter and has nothing to do with the spring. Measured as reversals in an entering glyph's vertical travel, where an overshoot is legitimately one: 2.76 per glyph on random values against 1.24 climbing, and turning the bounce off makes it worse, not better, at 3.05. The flipping direction is its own card, on purpose.

Every card audits itself whenever it goes idle, which is the point of the page: the value on screen is the value it was handed, the host is wearing none of the inline styles a width transition puts on it (display, width, text-indent, margin-inline-end, flex-shrink, text-align, data-shrink-clip), and no glyph has settled outside the box the value is measured to occupy. Against the host's own box, not the card's: a long value with no spaces in it is meant to run past a narrow card. A dot goes red and stays red with what it caught, and a banner catches anything thrown or rejected.

Storm runs every card at once for ten seconds. Measured over one: 21 cards, 4,519 changes, nothing caught, no errors.

bun run check:versus

Holds this fork against the upstream it forked. /versus already runs both engines on one timer with the same constants pushed into both, so this drives that page at three cadences, 90ms and 40ms and 20ms against a 590ms roll, and compares what a reader sees rather than what the code intends.

It records with the frame limiter off, at around 100fps, then measures three things per cadence: ink per half straight off the recorded frames, how wide that ink spreads, and how long a ghost lives from the moment its animation is created to the moment it leaves the DOM. Ink and spread are guarded both ways, since carrying less than upstream is a divergence too, at 1.03x and 1.04x, and a glyph's life within 90ms.

Ink is summed brightness over the background, not a count of pixels past a cutoff. A cutoff reads 1.05x off a difference in how the brightness is distributed while the ink itself is level, and nothing in these frames ever reaches a lift of 150 out of 255, so a cutoff sits in the tail.

Measured, stable across runs: ink 0.998x at every cadence, spread 1.000x, a glyph living 590ms here against 591ms upstream.

/versus

Upstream numeric-text and this fork in one figure, on one timer, with every constant written into both engines' CONFIG so the only thing differing is the code. Upstream is vendored unmodified at apps/website/src/vendor/numeric-text and registers <numeric-text>, so the two tags coexist; nothing else on the site imports it.

Freeze pauses every animation in both shadow roots, catching each where it stands rather than rewinding to a shared zero. At these cadences a dozen rolls are in flight at different ages, and that spread is the thing worth looking at. Scrub then walks the whole scene forward together, and the readout counts ink still on screen grouped by the slot it sits in.

Forward only, because backward cannot be accurate. A glyph that has finished is released, and rewinding cannot bring it back, so the two sides would show different pasts of the same value.

This page is the case against hurry. Measured at 90ms and 40ms cadences the two engines match to within a frame, with lifetimes 607 against 607ms, ghosts on screen 248 against 236ms, entrances 317 against 301ms and nothing cut short on either side, and both stack ghosts in the same slot rather than swapping or deleting them. Every apparent difference between them traces back to that option running the fork's ghosts at up to 6×.

One engine difference remains: direction. Upstream reads a grouped value with parseFloat, so 5,229 → 5,236 is 5 → 5 and rolls downward; the fork parses the whole number and rises.

Case sweep

bun research/run-cases.ts runs the documented value changes and writes research/cases.json: what is kept, how far it travels, how the box resizes. It is the source for Measured cases. Re-run it after any change to the matcher.

Clone this wiki locally