mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
test(desktop): slow-scroll characterization leg (2-4px/frame momentum-decay)
Drives the low-velocity + momentum-tail regime Tyler reports the residual in, which the constant-12px/32ms gate does not cover. Characterization only — logs the reversal distribution and still-frame count, no pass/fail ceiling, since the slow-regime floor is what we are measuring. Keeps liveness + stale-dist guards. Honesty bound documented in-file: Playwright mouse.wheel is synthetic and cannot reproduce a real trackpad's compositor-coalesced momentum, so a green run means 'the correction is not a large felt jump at low synthetic velocity', not 'reproduces everything Tyler feels'. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
Tyler Longwell
parent
e7243887e4
commit
26418e92f5
@@ -0,0 +1,263 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
/**
|
||||
* Slow-scroll characterization leg — Tyler's felt-regime coverage.
|
||||
*
|
||||
* The W4a gate (`upscroll-raf-correction.perf.ts`) drives a CONSTANT 12px/32ms
|
||||
* (~375px/s) upscroll. That is not the regime Tyler reports the residual in: a
|
||||
* slow deliberate trackpad gesture moves 2-4px/frame and decays through a
|
||||
* momentum tail, and a mid-history correction that is invisible under momentum
|
||||
* is 5-7 frames of motion at 2-3px/frame (Eva's characterization, thread
|
||||
* event 2de99f4d). This leg drives that regime and CHARACTERIZES the reversal
|
||||
* distribution — it does NOT gate a pass/fail ceiling, because the slow-regime
|
||||
* floor is exactly what we're measuring. Liveness + stale-`dist` guard only.
|
||||
*
|
||||
* HONESTY BOUND (flagged in-thread before build): Playwright `mouse.wheel` is a
|
||||
* synthetic discrete event. It faithfully reproduces "the correction WRITE as a
|
||||
* felt jump" (correction magnitude vs per-frame rendered row motion at low
|
||||
* velocity — measured here) but it does NOT reproduce a real trackpad's
|
||||
* compositor-coalesced momentum, so the WebKit `dScroll=0.0` coalesced still
|
||||
* frame is a real-device phenomenon this fixture cannot manufacture. A green
|
||||
* run here means "the correction is not a large felt jump in a low-velocity
|
||||
* synthetic gesture" — NOT "reproduces everything Tyler feels."
|
||||
*/
|
||||
|
||||
// Momentum-decay drive: initial slow velocity decaying toward a tail. Each
|
||||
// wheel event is small (2-4px early, ~1px in the tail) so a correction landing
|
||||
// mid-gesture is many frames of the eye's motion, not one.
|
||||
const WHEEL_START_DELTA = 4; // px/event at gesture start (slow deliberate)
|
||||
const WHEEL_TAIL_DELTA = 1; // px/event in the momentum tail
|
||||
const DECAY_PER_EVENT = 0.98; // geometric decay toward the tail
|
||||
const WHEEL_PERIOD_MS = 16; // one event per frame (60fps trackpad cadence)
|
||||
const DURATION_MS = 12_000;
|
||||
const SAFE_MARGIN = 100;
|
||||
// Same reversal definition as the gate: row moving against the scroll by more
|
||||
// than staircase noise. Upscroll → rowMove normally >= 0, so a genuine
|
||||
// against-direction move is < -REVERSAL_PX.
|
||||
const REVERSAL_PX = 3;
|
||||
// Must equal `ANCHOR_BUILD_STAMP` in `useAnchoredScroll.ts` — stale-`dist`
|
||||
// guard (see the gate fixture). Bump BOTH together per experiment.
|
||||
const EXPECTED_BUILD_STAMP = "w4a-ungated-ro-2";
|
||||
|
||||
type Frame = {
|
||||
t: number;
|
||||
top: number | null;
|
||||
scrollTop: number;
|
||||
mounted: number;
|
||||
rowId: string | null;
|
||||
};
|
||||
|
||||
test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.log("PAGE ERROR:", m.text());
|
||||
});
|
||||
page.on("pageerror", (e) => console.log("PAGE EXCEPTION:", e.message));
|
||||
await page.addInitScript(() => {
|
||||
(
|
||||
globalThis as unknown as { __ANCHOR_PROBE__: unknown[] }
|
||||
).__ANCHOR_PROBE__ = [];
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
await page.getByTestId("channel-jitter-corpus").click();
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector(
|
||||
'[data-testid="message-timeline"]',
|
||||
) as HTMLDivElement | null;
|
||||
return !!el && el.scrollHeight > el.clientHeight + 1000;
|
||||
});
|
||||
|
||||
await timeline.evaluate((element) => {
|
||||
const el = element as HTMLDivElement;
|
||||
el.style.overflowAnchor = "none";
|
||||
el.scrollTop = el.scrollHeight;
|
||||
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
await timeline.hover();
|
||||
|
||||
// Per-frame sampler (identical to the gate: one clock, same row-tracking) so
|
||||
// the two legs' distributions are directly comparable.
|
||||
await timeline.evaluate((element, margin: number) => {
|
||||
const el = element as HTMLDivElement;
|
||||
const w = window as unknown as {
|
||||
__PROBE__: { frames: Frame[]; stop: boolean };
|
||||
};
|
||||
type Frame = {
|
||||
t: number;
|
||||
top: number | null;
|
||||
scrollTop: number;
|
||||
mounted: number;
|
||||
rowId: string | null;
|
||||
};
|
||||
w.__PROBE__ = { frames: [], stop: false };
|
||||
let trackedId: string | null = null;
|
||||
const pick = (): string | null => {
|
||||
const box = el.getBoundingClientRect();
|
||||
for (const row of el.querySelectorAll<HTMLElement>("[data-message-id]")) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
if (rect.top > box.top + margin && rect.bottom < box.bottom - margin) {
|
||||
return row.dataset.messageId ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const tick = (t: number) => {
|
||||
if (w.__PROBE__.stop) return;
|
||||
const mounted = el.querySelectorAll("[data-message-id]").length;
|
||||
let top: number | null = null;
|
||||
if (trackedId) {
|
||||
const row = el.querySelector<HTMLElement>(
|
||||
`[data-message-id="${CSS.escape(trackedId)}"]`,
|
||||
);
|
||||
if (row) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
const box = el.getBoundingClientRect();
|
||||
const inBand =
|
||||
rect.top > box.top + margin && rect.bottom < box.bottom - margin;
|
||||
top = inBand ? rect.top : null;
|
||||
}
|
||||
}
|
||||
if (top === null) trackedId = pick();
|
||||
w.__PROBE__.frames.push({
|
||||
t,
|
||||
top,
|
||||
scrollTop: el.scrollTop,
|
||||
mounted,
|
||||
rowId: trackedId,
|
||||
});
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}, SAFE_MARGIN);
|
||||
|
||||
// Momentum-decay drive: velocity decays geometrically from START toward TAIL,
|
||||
// so the gesture spends most of its time in the 1-2px/frame regime where a
|
||||
// correction is felt. Fractional deltas accumulate a remainder so sub-pixel
|
||||
// velocity still produces integer wheel steps at the right average rate.
|
||||
const started = Date.now();
|
||||
let delta = WHEEL_START_DELTA;
|
||||
let remainder = 0;
|
||||
while (Date.now() - started < DURATION_MS) {
|
||||
remainder += delta;
|
||||
const step = Math.max(1, Math.round(remainder));
|
||||
remainder -= step;
|
||||
await page.mouse.wheel(0, -step);
|
||||
await page.waitForTimeout(WHEEL_PERIOD_MS);
|
||||
delta = Math.max(WHEEL_TAIL_DELTA, delta * DECAY_PER_EVENT);
|
||||
}
|
||||
|
||||
const frames: Frame[] = await timeline.evaluate((_el) => {
|
||||
const w = window as unknown as {
|
||||
__PROBE__: { frames: Frame[]; stop: boolean };
|
||||
};
|
||||
type Frame = {
|
||||
t: number;
|
||||
top: number | null;
|
||||
scrollTop: number;
|
||||
mounted: number;
|
||||
rowId: string | null;
|
||||
};
|
||||
w.__PROBE__.stop = true;
|
||||
return w.__PROBE__.frames;
|
||||
});
|
||||
|
||||
const { corrections, buildStamp } = await page.evaluate(() => {
|
||||
const g = globalThis as unknown as {
|
||||
__ANCHOR_PROBE__?: Array<{
|
||||
source: "raf" | "ro";
|
||||
wouldFire: boolean;
|
||||
residual: number;
|
||||
}>;
|
||||
__ANCHOR_BUILD_STAMP__?: string;
|
||||
};
|
||||
return {
|
||||
corrections: g.__ANCHOR_PROBE__ ?? [],
|
||||
buildStamp: g.__ANCHOR_BUILD_STAMP__ ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
// Score same-row frame pairs. A reversal is rowMove <= -REVERSAL_PX. Pair each
|
||||
// with the per-frame rendered scroll delta (dScroll) so we can see whether a
|
||||
// reversal lands on a near-still frame (the felt case) or rides momentum.
|
||||
let scored = 0;
|
||||
let reanchors = 0;
|
||||
const reversals: Array<{
|
||||
i: number;
|
||||
rowMove: number;
|
||||
dScroll: number;
|
||||
rowId: string | null;
|
||||
}> = [];
|
||||
for (let i = 1; i < frames.length; i += 1) {
|
||||
const a = frames[i - 1];
|
||||
const b = frames[i];
|
||||
if (
|
||||
a.top === null ||
|
||||
b.top === null ||
|
||||
a.rowId === null ||
|
||||
a.rowId !== b.rowId
|
||||
) {
|
||||
reanchors += 1;
|
||||
continue;
|
||||
}
|
||||
scored += 1;
|
||||
const rowMove = b.top - a.top;
|
||||
const dScroll = b.scrollTop - a.scrollTop;
|
||||
if (rowMove <= -REVERSAL_PX) {
|
||||
reversals.push({ i, rowMove, dScroll, rowId: b.rowId });
|
||||
}
|
||||
}
|
||||
|
||||
const maxReversalPx =
|
||||
reversals.length === 0
|
||||
? 0
|
||||
: Math.max(...reversals.map((r) => Math.abs(r.rowMove)));
|
||||
// A reversal on a near-still frame (|dScroll| < REVERSAL_PX) is the felt case:
|
||||
// the eye is barely moving, so a backward row snap is maximally visible.
|
||||
const stillFrameReversals = reversals.filter(
|
||||
(r) => Math.abs(r.dScroll) < REVERSAL_PX,
|
||||
);
|
||||
|
||||
/* eslint-disable no-console */
|
||||
console.log("\n=== W4a SLOW-SCROLL CHARACTERIZATION ===");
|
||||
console.log(`engine: ${browserName}`);
|
||||
console.log(`build stamp: ${buildStamp ?? "(absent)"}`);
|
||||
console.log(`frames sampled: ${frames.length}`);
|
||||
console.log(`frame-pairs scored: ${scored}`);
|
||||
console.log(`re-anchor/skip frames: ${reanchors}`);
|
||||
console.log(`reversal frames: ${reversals.length}`);
|
||||
console.log(` of which still-frame: ${stillFrameReversals.length}`);
|
||||
console.log(`max reversal px: ${maxReversalPx.toFixed(1)}`);
|
||||
for (const r of reversals
|
||||
.slice()
|
||||
.sort((x, y) => x.rowMove - y.rowMove)
|
||||
.slice(0, 12)) {
|
||||
console.log(
|
||||
` frame ${r.i} rowMove=${r.rowMove.toFixed(1)} dScroll=${r.dScroll.toFixed(1)} row=${r.rowId}`,
|
||||
);
|
||||
}
|
||||
console.log("========================================\n");
|
||||
/* eslint-enable no-console */
|
||||
|
||||
// Sanity: the actuation actually produced a scored slow upscroll.
|
||||
expect(scored).toBeGreaterThan(50);
|
||||
// Stale-`dist` guard — a slow-regime characterization on a stale bundle would
|
||||
// mislead exactly like a stale gate run. Assert the experiment's stamp ran.
|
||||
expect(buildStamp).toBe(EXPECTED_BUILD_STAMP);
|
||||
// Liveness: at least one mid-history correction fired, else the corpus
|
||||
// realized nothing and the distribution above is vacuous.
|
||||
const anyFired = corrections.some((c) => c.wouldFire);
|
||||
expect(anyFired).toBe(true);
|
||||
// Characterization only — no reversal ceiling asserted. The distribution and
|
||||
// still-frame count above are the deliverable; the slow-regime floor is what
|
||||
// we are measuring, not gating.
|
||||
});
|
||||
Reference in New Issue
Block a user