Testing & Invariants
A test here is not a check that the code runs. It is the invariant, the one if-then a piece of code exists to hold, written out for this implementation so that it can fail. The class carries the behavior, the contract carries the claim, the test binds the two, and a checker refuses the repository when any one of the three is missing.
A test is the invariant, written for this implementation.
This page is the method, with the virtual scroller and its five companion classes as the worked example: nine colocated spec files, sixty-three tests, two contracts, all read by the same checker that holds the engine's own contract.
Three tiers, by what the claim is about
Every claim about a class falls into one of three tiers, and the tier decides what the test needs. The class layout makes the first cut for you: a static has no instance this and cannot hold state, so it is stateless by construction.
- Pure statics. A function of its arguments, no DOM. The range math of the selection, the speed ramp, the chunker, the pad's split and settle rules. The test calls the static through the namespace and asserts the return. Nothing is mounted, nothing is stubbed.
- Hosted instances. A class with cells, a constructor, and an owner. The scroller's window walk, the selection's drag, the touch gesture's hold, the marquee's seeding. The test constructs the instance with a plain object for its owner, stubs the two or three DOM readers the class rests on, and drives it by calling its methods.
- Browser probes. Claims about what the reader sees: a wheel lerp, a blank frame, a chip that appears. jsdom cannot see these. They run in a real browser through the component sweep, and the spec file names them as not covered.
The tier is a property of the claim, not of the file. One spec file usually holds all three: statics at the top, hosted tests below, and a line in its header saying which claims went to the browser.
What ivue gives a test
The same rules that make a class readable make it testable, because both come from the class declaring everything it does.
Statics are reachable through the namespace. The pure logic sits on X.Class as static members, so a spec calls it with plain values and no setup. The selection's range math is proven this way in seven cases that never touch a node.
const Logic = VirtualScrollerSelection.Class;
expect(Logic.normalize(at(5, 3), at(2, 9))).toEqual({ start: at(2, 9), end: at(5, 3) });Cells are refs, derivations read live. A ref-getter returns the same cached ref every time, so a test writes instance.speed.value = 50 and reads the plain getter instance.creepMsPerPx on the next line. There is no render to wait for, because a plain getter is a native getter.
An owner interface is a plain object. A hosted capability receives what it needs through a small interface, never the host class. The padding class needs four fields; the selection needs eight; the touch gesture needs three methods and a flag. A spec builds that object with vi.fn() where it wants to observe calls, and the capability cannot tell the difference.
const owner = { halfPaddingQuantity: 3, scrollVelocity: 40, scrollGap: 800, estimatedItemSize: 40 };
const padding = new VirtualScrollerPadding.Class(owner);
expect(padding.pad(0)).toEqual({ before: 23, after: 18 });A subclass is the test double. When a class reads the DOM through seam getters, a test subclass overrides the seams, the same shape the horizontal scroller uses to change axis. The scroller spec pins the container size to one ref and re-exposes the protected seams it wants to assert. Nothing is monkey-patched; the double is a class in the same standard as the class under test.
class $Probe extends (VirtualScroller.$Class as typeof VirtualScroller.$Class)<Row> {
get frameSize() {
return ref(100);
}
override get containerSize() {
return this.frameSize;
}
probeTransform(px: number) {
return this.transformFor(px);
}
}A static knob works the same way. The demo's row count is a static, so the spec subclasses it to a thousand rows and runs the same class over a smaller list.
A constructor is setup code, so host it. A constructor that calls onMounted or a plain watch lands those in the component that constructs the instance. A bare new in a test has no component, so the hooks warn and drop. The harness mounts a throwaway component whose setup is the factory, and unmount runs the teardown the class declares.
// hosted.ts — run a class constructor INSIDE a component's setup.
//
// An ivue constructor is setup code: `onMounted`, `onBeforeUnmount` and a
// plain `watch` land in the component that constructs the instance. A bare
// `new X.Class()` in a test has no component, so the hooks warn and drop,
// and a spec that reads "mounted" state would be proving nothing. This
// harness mounts a throwaway component whose setup is the factory, so
// every hook registers against a real instance and unmount runs the
// teardown the class declares — the same lifecycle the SFC gives it.
//
// Use it for any class whose constructor touches the lifecycle. A class
// with no hooks (a pure Static class, a hosted capability constructed by
// its owner) is constructed directly.
import { mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
export function hosted<T>(factory: () => T): { instance: T; unmount: () => void } {
let instance!: T;
const wrapper = mount(
defineComponent({
setup() {
instance = factory();
return () => h('div');
}
})
);
return { instance, unmount: () => wrapper.unmount() };
}A class with no hooks is constructed directly. The selection and the pad are; the scroller, the item and the marquee are hosted.
Stub at the seams, not around them. jsdom lays nothing out and has no elementFromPoint, no caret API, no canvas context. The selection spec replaces exactly those three readers with arithmetic over a fixed row height and character width, and keeps everything else real: the text nodes, the tree walker, the Selection object. That is why its strongest test is a round trip, every offset of a three-node row going from DOM to text and back.
Fake the clock for holds and settles, and the frame for loops. The long press is a timer advance; the pad's settle window is a timer advance; the autoscroll is a queue of frame callbacks the test drains by hand with explicit timestamps. A wait in a test is a defect looking for a slower machine.
The spec discipline
The rules below come from Invar, the terminal IDE agents built on ivue under one written standard, where every one of them was bought by a real failure. They are stated here as they apply to a class.
The header is the constitution. A spec file opens with a generator header before its imports. The formal register names the goal, links the contract records the file proves, states each local claim as an if-then on the class's symbol, and lists what would be impossible if the claims held. The described register says what the formal lines cannot: why this shape, what a fresh session must not simplify away, and what is not covered here by kind.
/*
=== GENERATOR ===
Goal: Size the rows mounted beyond the visible window from the motion itself, so a flick never shows canvas and a resting list never carries a flick's pad.
[The pad covers the lerp gap exactly](virtual-scroller.invariants.md#the-pad-covers-the-lerp-gap-exactly)
// domain-invariant: $VirtualScrollerPadding — If a pad is split, then the lookahead rows sit on the end the content moves toward and the gap rows on the end it comes from; at rest both ends carry the base.
Impossible if true: A pad that shrinks on the first frame of a flick's decay.
=== GENERATOR-DESCRIBED ===
The owner is a plain object of the four fields the pad reads; the walk
is a call to pad() with an explicit clock, so the hysteresis is a
sequence of readings, not a wait.
*/Every test carries its claim, and every claim has a test. The annotation directly above a test names the header line it proves, and the test name states the property as a sentence. A claim with no test is unproven; a test whose claim is not in the header is an unexplained assertion. The checker holds both directions.
Impossibilities are negative tests by construction. Each Impossible if true line gets a test that approaches the forbidden state and asserts the refusal. The scroller's spec plants a non-finite scroll position and asserts the last position stands; the chunker's walks every cut and asserts the character after it starts a word.
Born red. A check is trusted after it has failed on the defect it claims to catch. For a pure static the red arm is permanent: the impossibility test is the violating fixture. Where red requires editing shipped code, the defect is planted, watched red on the value, and removed. Each of the nine spec files on this page was planted once before it was committed.
Assert the observable the reader would point at. The selection spec asserts the range and the copied text, not the internal cell that held them. The item spec counts emits on mount and unmount, not calls to a method. A model-only assertion goes green while the screen is broken.
Enumerate the zero states first. An empty wrapper, a list of one, a list that shrinks to nothing and regrows, a caret past the end of the text. The states come from the surface, not from the last failure.
Kind-match the verification. A gesture claim needs an input event dispatched on a node; a persistence claim needs a second launch; a performance claim needs a paired measurement; a visual claim needs a browser. Sixty-three green tests verify no visual claim, which is why the flick probe and the touch chip live in the sweep.
Name what is not covered. One line, in the described register. The scroller's header says the wheel lerp, the creep integrator and the converge loop went to the browser. Silent partial coverage reads as total.
Specs grow only while defects stay flat. Add a test when it proves a component or caveat not yet proven. When a defect appears, the first question is which claim had no spec. If none was missing, the claim itself is wrong: refine the header first, then write the test.
The contract, and the checker that holds it
A claim that a second file depends on graduates from a header line to a record in a contract, a file named <subsystem>.invariants.md beside the code. A record is one if-then with its scope, the mechanism that makes it hold, the evidence, what is impossible if it holds, and a copy-paste verification. The contract's generator section lists every record as a gear and states the mechanism they form.
Code points back. An annotation at each enforcement point names the record verbatim:
// invariant: The scroll position lands inside the scrollable range (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
if (!Number.isFinite(position)) return;The checker reads all three homes and refuses drift in any direction: a record no annotation references, an annotation whose record was renamed, a header claim with no test, a test with no claim, a link whose anchor does not resolve.
node .claude/skills/invariants/scripts/check_invariants.mjs --all --refsThe two contracts on this page are virtual-scroller.invariants.md and text-marquee.invariants.md, each beside the classes it governs and shown in full as a source tab on the virtual scroller and horizontal scroller pages. The first holds twenty-three records; five of them are reality-based, the browser and the compositor deciding, and eighteen are chosen, the subsystem's own disciplines standing on those five.
The worked example
The padding class is the whole method in one short file: statics with a permanent red arm, a hosted instance with an owner double, a fake clock, and a header that binds every test to a record.
/*
=== GENERATOR ===
Goal: Size the rows mounted beyond the visible window from the motion itself, so a flick never shows canvas and a resting list never carries a flick's pad.
[The transform lerps to the target over many frames](virtual-scroller.invariants.md#the-transform-lerps-to-the-target-over-many-frames)
[The pad covers the lerp gap exactly](virtual-scroller.invariants.md#the-pad-covers-the-lerp-gap-exactly)
[Lenis is read inside the walk never tracked](virtual-scroller.invariants.md#lenis-is-read-inside-the-walk-never-tracked)
[A pad never outlives its flick](virtual-scroller.invariants.md#a-pad-never-outlives-its-flick)
[A hosted capability reaches its owner through an interface](virtual-scroller.invariants.md#a-hosted-capability-reaches-its-owner-through-an-interface)
// domain-invariant: $VirtualScrollerPadding — If the content moves at a speed, then the rows ahead cover the distance it travels in the lookahead, rounded up and capped, and a crawl counts as still.
// domain-invariant: $VirtualScrollerPadding — If a pad is split, then the lookahead rows sit on the end the content moves toward and the gap rows on the end it comes from; at rest both ends carry the base.
// domain-invariant: $VirtualScrollerPadding — If a new reading arrives, then a higher level — rows ahead or rows behind — raises the held one at once, a lower one never shrinks either while the content moves, rest shrinks both after the settle window, and a reversal turns the direction and keeps the levels.
Impossible if true: A pad that shrinks on the first frame of a flick's decay.
Impossible if true: Gap rows trimmed while the lerp still travels.
=== GENERATOR-DESCRIBED ===
The owner is a plain object of the four fields the pad reads; the walk
is a call to pad() with an explicit clock, so the hysteresis is a
sequence of readings, not a wait. Timers are faked for the settle
re-walk, and the settled version is read directly — in the scroller it
is read inside the window walk, which is what makes the bump rerun it.
*/
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { VirtualScrollerPadding } from './VirtualScrollerPadding';
const Logic = VirtualScrollerPadding.Class;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// domain-invariant: $VirtualScrollerPadding — If the content moves at a speed, then the rows ahead cover the distance it travels in the lookahead, rounded up and capped, and a crawl counts as still.
test('rows ahead cover the distance the content travels in the lookahead, rounded up and capped, and a crawl is still', () => {
// Speeds are px per MILLISECOND, so the same reading means the same pad on
// a 60 Hz display and a 120 Hz one. 40 px per 16.7 ms frame is 2.395 px/ms,
// which over the 250 ms lookahead is ≈ 599 px; 40 px rows → 15 rows.
const perFrame = (px: number) => px / 16.7;
expect(Logic.rowsAhead(perFrame(40), 40)).toBe(15);
expect(Logic.rowsAhead(perFrame(-40), 40)).toBe(15);
expect(Logic.rowsAhead(perFrame(0.2), 40)).toBe(0);
expect(Logic.rowsAhead(10_000, 40)).toBe(Logic.MAX_ROWS_AHEAD);
expect(Logic.rowsAhead(perFrame(40), 0)).toBe(0);
expect(Logic.directionOf(perFrame(3))).toBe(1);
expect(Logic.directionOf(perFrame(-3))).toBe(-1);
expect(Logic.directionOf(perFrame(0.1))).toBe(0);
});
// invariant: The pad covers the lerp gap exactly (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
// invariant: The transform lerps to the target over many frames (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
test('rows behind cover the lerp gap exactly, rounded up and capped', () => {
expect(Logic.rowsBehind(848, 56)).toBe(16);
expect(Logic.rowsBehind(-848, 56)).toBe(16);
expect(Logic.rowsBehind(0, 56)).toBe(0);
// the lerp's settle band: a sub-pixel gap is rest, not one more row
expect(Logic.rowsBehind(0.4, 56)).toBe(0);
expect(Logic.rowsBehind(1, 56)).toBe(1);
expect(Logic.rowsBehind(1_000_000, 56)).toBe(Logic.MAX_ROWS_GAP);
});
// domain-invariant: $VirtualScrollerPadding — If a pad is split, then the lookahead rows sit on the end the content moves toward and the gap rows on the end it comes from; at rest both ends carry the base.
test('the split puts the lookahead rows ahead of the motion and the gap rows behind it', () => {
expect(Logic.split(3, 12, 16, 1)).toEqual({ before: 19, after: 15 });
expect(Logic.split(3, 12, 16, -1)).toEqual({ before: 15, after: 19 });
expect(Logic.split(3, 12, 16, 0)).toEqual({ before: 3, after: 3 });
});
// domain-invariant: $VirtualScrollerPadding — If a new reading arrives, then a higher level — rows ahead or rows behind — raises the held one at once, a lower one never shrinks either while the content moves, rest shrinks both after the settle window, and a reversal turns the direction and keeps the levels.
// impossible-if-true: $VirtualScrollerPadding — A pad that shrinks on the first frame of a flick's decay.
// impossible-if-true: $VirtualScrollerPadding — Gap rows trimmed while the lerp still travels.
test('settle grows at once, holds through the decay, shrinks at rest after the settle window, and keeps its rows through a turn', () => {
const start = { ahead: 0, behind: 0, gapPx: 0, direction: 0 as const, since: 0 };
const grown = Logic.settle(start, 10, 20, 800, 1, 100);
expect(grown).toEqual({ ahead: 10, behind: 20, gapPx: 800, direction: 1, since: 100 });
// Lower readings while the content still moves keep the held level —
// however long the decay tail runs — so no burst of unmounts lands mid-glide.
// The gap rows are held the same way: the lerp closing its gap trims nothing.
expect(Logic.settle(grown, 4, 12, 480, 1, 101)).toBe(grown);
expect(Logic.settle(grown, 4, 2, 80, 1, 100 + Logic.SETTLE_MS - 1)).toBe(grown);
expect(Logic.settle(grown, 4, 0, 0, 1, 100 + Logic.SETTLE_MS * 5)).toBe(grown);
expect(Logic.settle(grown, 0, 1, 20, 1, 100 + Logic.SETTLE_MS * 5)).toBe(grown);
// One side growing raises that side and keeps the others' levels.
expect(Logic.settle(grown, 12, 5, 200, 1, 120)).toEqual({
ahead: 12,
behind: 20,
gapPx: 800,
direction: 1,
since: 120
});
// Rest inside the window still holds; rest once the window has passed releases everything.
expect(Logic.settle(grown, 0, 0, 0, 0, 100 + Logic.SETTLE_MS - 1)).toBe(grown);
expect(Logic.settle(grown, 0, 0, 0, 0, 100 + Logic.SETTLE_MS)).toEqual({
ahead: 0,
behind: 0,
gapPx: 0,
direction: 1,
since: 100 + Logic.SETTLE_MS
});
// A reversal turns the direction and keeps the levels — unmounting the rows held the
// old way would land on the very frame the finger reversed; rest releases them.
expect(Logic.settle(grown, 2, 3, 120, -1, 150)).toEqual({
ahead: 10,
behind: 20,
gapPx: 800,
direction: -1,
since: 150
});
});
// invariant: A hosted capability reaches its owner through an interface (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
// invariant: Lenis is read inside the walk never tracked (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
test('pad() holds the gap rows and the lookahead across a decaying tail and releases both at rest, reading the owner each call', () => {
const owner = {
halfPaddingQuantity: 3,
scrollVelocity: 40 / 16.7, // px per ms: the 40 px/frame this was tuned at
scrollGap: 800,
estimatedItemSize: 40
};
const padding = new Logic(owner);
// Flick: 20 rows of gap behind the target-anchored window, 15 of lookahead beyond it.
expect(padding.pad(0)).toEqual({ before: 23, after: 18 });
// The lerp converges: neither the gap rows nor the lookahead shrink mid-glide.
owner.scrollVelocity = 8 / 16.7;
owner.scrollGap = 80;
expect(padding.pad(100)).toEqual({ before: 23, after: 18 });
// Still moving past the window: both held, nothing unmounts in the tail.
owner.scrollGap = 0;
expect(padding.pad(100 + Logic.SETTLE_MS)).toEqual({ before: 23, after: 18 });
// At rest: both drop to the base, in one walk.
owner.scrollVelocity = 0;
expect(padding.pad(200 + Logic.SETTLE_MS)).toEqual({ before: 3, after: 3 });
// A flick back, from rest: everything mirrors.
owner.scrollVelocity = -40 / 16.7;
owner.scrollGap = -800;
expect(padding.pad(1000)).toEqual({ before: 18, after: 23 });
expect(padding.rowsAhead).toBe(15);
expect(padding.rowsBehind).toBe(20);
// the held gap in px sits on the end side of a flick back, nothing on the start side
expect(padding.gapEndPx).toBe(800);
expect(padding.gapStartPx).toBe(0);
expect(padding.before).toBe(18);
expect(padding.after).toBe(23);
padding.dispose();
});
// invariant: A pad never outlives its flick (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
test('a walk that pads beyond the base arms one more walk after the settle window; a base walk arms nothing; dispose cancels', () => {
const owner = {
halfPaddingQuantity: 3,
scrollVelocity: 40 / 16.7,
scrollGap: 0,
estimatedItemSize: 40
};
const padding = new Logic(owner);
expect(padding.settledVersion.value).toBe(0);
padding.pad(0);
vi.advanceTimersByTime(Logic.SETTLE_MS + 49);
expect(padding.settledVersion.value).toBe(0);
vi.advanceTimersByTime(1);
expect(padding.settledVersion.value).toBe(1);
// At rest the walk pads the base only, so no timer is armed.
owner.scrollVelocity = 0;
padding.pad(10_000);
vi.advanceTimersByTime(Logic.SETTLE_MS + 100);
expect(padding.settledVersion.value).toBe(1);
// A flick, then dispose before the window: the bump never comes.
owner.scrollVelocity = 40 / 16.7;
padding.pad(20_000);
padding.dispose();
vi.advanceTimersByTime(Logic.SETTLE_MS + 100);
expect(padding.settledVersion.value).toBe(1);
});// VirtualScrollerPadding.ts — adaptive render padding, hosted by the
// scroller: the rows mounted BEYOND the visible window, sized by how fast
// the content is moving.
//
// A fixed pad is the wrong shape. Sit still and six spare rows are six
// rows too many; flick, and the content moves a whole viewport in a few
// frames. Two things then need covering, and they are different:
//
// - THE LERP GAP. The window walk is anchored at the scroll TARGET, the
// destination of the wheel lerp, while the transform travels there
// over many frames. Between the two, the viewport shows rows that sit
// BEHIND the mounted window — exactly the ones nobody mounted. The
// gap is target minus animated, in px, known exactly every frame; in
// rows it is the pad on the trailing side of the window. No guess.
// - THE LOOKAHEAD. Beyond the target, the next flick lands before the
// next window does. Rows ahead of the motion, sized by velocity over
// a lookahead time, are mounted early, held with hysteresis so the
// decay tail of a flick does not unmount what the next one needs.
//
// Two layers:
// - pure statics: gap → rows behind, velocity → rows ahead, the split
// of a pad across the two ends by direction, and the settle rule. No
// DOM, no state; the spec covers them.
// - the instance: a plain holder (nothing renders it), and one call the
// window walk makes per evaluation. Velocity is READ there, never
// tracked: the walk already reruns on every position change, and a
// reactive velocity would rerun it for no new information. The one
// cell is `settledVersion`, bumped by a timer once the flick is over,
// so the walk runs one last time and the pad shrinks back — without
// it, a flick that stops the creep would leave its pad mounted.
//
// The hysteresis is what keeps the window from thrashing. A pad grows the
// frame the velocity or the gap does; it holds for as long as the content
// moves and shrinks only at rest, once SETTLE_MS has passed since it last
// grew, so the decay tail of a flick never unmounts a burst of rows
// mid-glide (a visible hitch on a phone) and keeps what the next flick
// needs. The gap rows are held the same way: exact per frame they would
// be trimmed on every walk of the tail — a chunk of unmounts, each with
// its own layout, every settle window while the content still crawls.
import { ref } from 'vue';
import { Reactive } from '../../ivue';
import { Static } from '../../Static';
class $VirtualScrollerPadding {
/* Knobs */
/** How far ahead in time the pad covers: the distance the content
* travels in this many ms is the distance the pad spans. */
static readonly LOOKAHEAD_MS = 250;
/** The most rows a pad ever adds ahead — a wild flick mounts this many, not hundreds. */
static readonly MAX_ROWS_AHEAD = 60;
/** The most rows the lerp gap ever adds behind — a jump beyond this shows canvas for a frame. */
static readonly MAX_ROWS_GAP = 160;
/** How long the velocity must stay below the held pad before the pad shrinks. */
static readonly SETTLE_MS = 300;
/** Below this speed the content counts as still, in px per MILLISECOND —
* the 0.5 px per frame this was tuned at, over a 60 Hz frame. Stated per
* ms because a per-frame threshold means a different real speed on every
* refresh rate, and Android runs at 90 and 120 where iOS mostly runs 60. */
static readonly STILL_PX_PER_MS = 0.5 / 16.7;
/** Below this lerp gap (px) the content counts as landed — the lerp's own settle band. */
static readonly STILL_GAP_PX = 0.5;
/* Pure decisions — the spec covers these */
/**
* Rows the content travels in LOOKAHEAD_MS at `pxPerFrame`, rounded up
* and capped: the pad that keeps the leading edge covered.
*/
static rowsAhead(pxPerMs: number, rowSize: number): number {
if (rowSize <= 0) return 0;
const speed = Math.abs(pxPerMs);
if (speed < this.STILL_PX_PER_MS) return 0;
const distance = speed * this.LOOKAHEAD_MS;
return Math.min(this.MAX_ROWS_AHEAD, Math.ceil(distance / rowSize));
}
/** Rows between the animated position and the target: the trailing
* pad that keeps the viewport covered while the lerp travels. */
// invariant: The pad covers the lerp gap exactly (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
// invariant: The transform lerps to the target over many frames (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
static rowsBehind(gapPx: number, rowSize: number): number {
if (rowSize <= 0) return 0;
// a sub-pixel gap is the lerp's settle band: at rest, no rows
if (Math.abs(gapPx) < this.STILL_GAP_PX) return 0;
return Math.min(this.MAX_ROWS_GAP, Math.ceil(Math.abs(gapPx) / rowSize));
}
/** Which way the content moves: 1 forward (down / right), -1 back, 0 still. */
static directionOf(pxPerMs: number): -1 | 0 | 1 {
if (Math.abs(pxPerMs) < this.STILL_PX_PER_MS) return 0;
return pxPerMs > 0 ? 1 : -1;
}
/**
* The pad on each end: the base on both; the lookahead rows on the end
* the content moves TOWARD (scrolling forward, new rows enter at the
* end); the gap rows on the end it comes FROM (the window sits at the
* target, the viewport trails it).
*/
static split(
base: number,
ahead: number,
behind: number,
direction: -1 | 0 | 1
): VirtualScrollerPadding.Pad {
if (direction > 0) return { before: base + behind, after: base + ahead };
if (direction < 0) return { before: base + ahead, after: base + behind };
return { before: base, after: base };
}
/**
* The held level after a new reading, with hysteresis: a higher reading
* — rows ahead or rows behind — raises its level at once; a lower one
* never shrinks either while the content still moves — the decay tail
* of a flick is when a burst of unmounts would be seen as a hitch — and
* rest shrinks both once the settle window has passed since the last
* growth; a direction change turns the direction and keeps the levels —
* the rows held the old way release at rest with the rest, never on the
* reversal's own frame.
*/
static settle(
held: VirtualScrollerPadding.Held,
ahead: number,
behind: number,
gapPx: number,
direction: -1 | 0 | 1,
now: number
): VirtualScrollerPadding.Held {
// a reversal turns the direction and keeps the levels: the rows held
// the old way unmount at rest like any other — dropping them here put
// a burst of unmounts on the very frame the finger reversed
const turned = direction !== 0 && held.direction !== 0 && direction !== held.direction;
const grew = ahead > held.ahead || behind > held.behind || gapPx > held.gapPx;
if (turned || grew) {
return {
ahead: Math.max(ahead, held.ahead),
behind: Math.max(behind, held.behind),
gapPx: Math.max(gapPx, held.gapPx),
direction: direction || held.direction,
since: now
};
}
const still = ahead === 0 && behind === 0;
const padded = held.ahead > 0 || held.behind > 0 || held.gapPx > 0;
if (still && padded && now - held.since >= this.SETTLE_MS) {
return { ahead: 0, behind: 0, gapPx: 0, direction: held.direction, since: now };
}
return held;
}
/* The instance — one pad per scroller */
// invariant: A hosted capability reaches its owner through an interface (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
constructor(public owner: VirtualScrollerPadding.Owner) {}
/** The one cast per class: instance code reads its own statics here. */
protected get self() {
return this.constructor as typeof $VirtualScrollerPadding;
}
// MUTABLE STATE — bumped once the flick has settled. The walk reads it
// through pad(), so the bump is what runs the walk one last time.
// invariant: A pad never outlives its flick (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
// invariant: Lenis is read inside the walk never tracked (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
get settledVersion() {
return ref(0);
}
/** The held level — plain, not reactive: nothing renders it, and the
* window walk that reads it already reruns on every scroll position. */
protected readonly held: VirtualScrollerPadding.Held = {
ahead: 0,
behind: 0,
gapPx: 0,
direction: 0,
since: 0
};
/** The pad the last walk used, for anyone who wants to show it. */
protected readonly last: VirtualScrollerPadding.Pad = { before: 0, after: 0 };
/** The settle timer: armed by every walk that pads beyond the base. */
protected readonly settle = { timer: null as ReturnType<typeof setTimeout> | null };
/** Rows the last walk mounted ahead of the motion, beyond the base. */
get rowsAhead() {
return this.held.ahead;
}
/** Rows the last walk held behind the target, over the lerp gap. */
get rowsBehind() {
return this.held.behind;
}
/** The furthest a lerp gap is worth ANIMATING across: the row cap in
* pixels of the estimate. A landing reads it and refuses to glide
* farther, because there is no honest animation over content nobody
* mounts. The walk does NOT clamp to it — the gap the walk covers comes
* from a gesture, whose reach is already bounded by its own inertia, and
* clamping the walk could only ever take coverage away from a reader
* mid-flick. */
get coverableGapPx(): number {
return this.self.MAX_ROWS_GAP * this.owner.estimatedItemSize;
}
/** The held gap in px on the START side: scrolling forward the animated
* position is before the target, and the walk reaches back to it. */
get gapStartPx() {
return this.held.direction > 0 ? this.held.gapPx : 0;
}
/** The held gap in px on the END side: scrolling back the animated
* position is past the target, and the walk reaches on to it. */
get gapEndPx() {
return this.held.direction < 0 ? this.held.gapPx : 0;
}
get before() {
return this.last.before;
}
get after() {
return this.last.after;
}
/**
* The pad for this evaluation of the window: the scroller calls it once
* per walk. The gap rows and the lookahead rows both go through the
* held level: they grow at once and release together at rest. The
* direction is the gap's when there is one (the lerp says where the
* content is going), the velocity's otherwise.
*/
pad(now = performance.now()): VirtualScrollerPadding.Pad {
const self = this.self;
this.settledVersion.value;
const rowSize = this.owner.estimatedItemSize;
const velocity = this.owner.scrollVelocity;
const gap = this.owner.scrollGap;
const behind = self.rowsBehind(gap, rowSize);
const ahead = self.rowsAhead(velocity, rowSize);
const gapPx = Math.abs(gap) < self.STILL_GAP_PX ? 0 : Math.abs(gap);
const direction = behind > 0 ? self.directionOf(gap) : self.directionOf(velocity);
Object.assign(this.held, self.settle(this.held, ahead, behind, gapPx, direction, now));
const pad = self.split(
this.owner.halfPaddingQuantity,
this.held.ahead,
this.held.behind,
this.held.direction || direction
);
this.last.before = pad.before;
this.last.after = pad.after;
const base = this.owner.halfPaddingQuantity;
if (pad.before > base || pad.after > base) this.armSettle();
return pad;
}
/** One more walk after the settle window, so a pad never outlives its flick. */
// invariant: A pad never outlives its flick (examples/playground/src/examples/virtual-scroller/virtual-scroller.invariants.md)
protected armSettle() {
if (this.settle.timer !== null) clearTimeout(this.settle.timer);
this.settle.timer = setTimeout(() => this.onSettled(), this.self.SETTLE_MS + 50);
}
onSettled() {
this.settle.timer = null;
this.settledVersion.value++;
}
dispose() {
if (this.settle.timer !== null) clearTimeout(this.settle.timer);
this.settle.timer = null;
}
}
export namespace VirtualScrollerPadding {
export const $Class = Static($VirtualScrollerPadding); // anchor — it declares statics
export let Class = Reactive($Class); // reactive — the scroller hosts one
export type Instance = typeof Class.Instance;
/** Rows mounted beyond the visible window on each end. */
export interface Pad {
before: number;
after: number;
}
/** The held level: rows ahead of the motion, rows behind the target over
* the lerp gap, the direction they face, and when the level last grew. */
export interface Held {
ahead: number;
behind: number;
/** the lerp gap in px, held: the walk's pixel extension over the rows between */
gapPx: number;
direction: -1 | 0 | 1;
since: number;
}
/** What the pad needs from the scroller that hosts it. */
export interface Owner {
/** The base pad on each end — the paddingQuantity prop, halved. */
readonly halfPaddingQuantity: number;
/** The content's speed in px per MILLISECOND, signed: positive forward.
* Per ms rather than per frame so a 120 Hz display sizes the same pad a
* 60 Hz one does for the same motion. */
readonly scrollVelocity: number;
/** The lerp gap: target minus animated position, in px, signed the same way. */
readonly scrollGap: number;
/** The size assumed for an unmeasured row along the axis. */
readonly estimatedItemSize: number;
}
}The other eight spec files follow the same shape and sit beside their classes:
| spec | tier | what it stubs |
|---|---|---|
VirtualScroller.test.ts | hosted | a Probe subclass pins the container size and exposes the seams |
HorizontalVirtualScroller.test.ts | hosted | the same Probe over the subclass; asserts every seam names x |
VirtualScrollerItem.test.ts | hosted | an element and a parent with two rect readers |
VirtualScrollerSelection.test.ts | statics + instance | elementFromPoint, the caret API, rects; an owner object |
VirtualScrollerSelectionTouch.test.ts | instance | fake timers, a faked touch point, a range's client rects; touch events on real nodes, on a handle, one detached |
VirtualScrollerPadding.test.ts | statics + instance | an owner object; fake timers |
VirtualScrollerExample.test.ts | instance | a static override to a thousand rows; a scroller object |
TextChunker.test.ts | statics | the canvas context, once as null and once as arithmetic |
TextMarquee.test.ts | hosted | a scroller object; the canvas context |
The visual claims run in the component sweep: the drag past the bottom edge, the touch long press and its chip, and the flick that must never show canvas.
The cost
A spec file is longer than a test file, because the header states what the tests prove and the annotations repeat it above each one. That is the price, and it is what lets a checker rather than a reviewer notice that a claim lost its test. The other cost is discipline at the boundary: a claim that grows a second dependent must move into the contract, or the checker cannot see it.
A test is the invariant, written for this implementation.
See it running
- Virtual Scroller: 1M Items — the class, its companions, and their specs as source tabs.
- Horizontal Scroller: 1M Items — the strip and the text marquee, with the chunker's specs, a pure Static class end to end.
- The Invariants Behind ivue — the engine's own contract, held by the same checker.