mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
L-E upscroll-jitter gate: RED metric isolating realization drift
Tyler's report: scrolling UP a fully-loaded channel is subtly jumpy;
DOWN is smooth. Root cause (Eva H1 + L-C): rows above the opening
viewport have never painted, so under content-visibility:auto they sit
at estimateRowHeight()'s contain-intrinsic-size reserve (60px,
utilities.css:17). Scrolling up, each realizes at its true height; the
reserve-vs-true delta shifts the reading position. Shipped WKWebView has
no overflow-anchor, so it corrects nothing — every delta lands raw.
This adds a deterministic gate that is RED at tip 77bd0e70 and that the
converged owned-compensation fix (overflow-anchor:none + same-frame
scrollBy(realized-reserved)) turns green:
- e2eBridge: a dedicated `jitter-corpus` mock channel (400 structurally
heterogeneous rows — headings/lists/blockquotes/code fences/long prose
whose true height estimateRowHeight is known to miss), its own channel
so the uniform deep-history seed the load-older specs use is untouched.
- upscroll-jitter.perf.ts: forces overflow-anchor:none on the scroller
(mirrors shipped WKWebView; logs CSS.supports per engine), then wheels
up in notches tracking a row held INSIDE the viewport (SAFE_MARGIN band
from both edges, re-picked each step, scored only if the same id stayed
in-band before+after). Residual = row's visual delta-top minus the
scroll delta: 0 == smooth. Gate is peak <=2px, rms <=0.6px.
Validity: within a single cold run the already-realized rows read
residual 0.0 exactly while realizing rows drift -27..-75px — an artifact
would drift on both. Negative sign shows the estimator over-reserves.
At tip: peak 75px, rms 40px over 23 steps → RED. A correct fill of
scrollTop absorbs the drift into the scroll delta, driving residual->0.
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
77bd0e700f
commit
33e735117a
@@ -2220,6 +2220,37 @@ const mockChannels: MockChannel[] = [
|
|||||||
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1900),
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1900),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
// Jitter-corpus channel for the upscroll-jitter GATE (upscroll-jitter.perf.ts).
|
||||||
|
// Seeded with 400 structurally HETEROGENEOUS messages (headings, lists,
|
||||||
|
// blockquotes, fenced code, long wrapped prose) so `estimateRowHeight`'s known
|
||||||
|
// biases produce a genuine reserve-vs-true error on every never-painted row.
|
||||||
|
// Its own channel so the uniform `deep-history` seed the load-older specs
|
||||||
|
// depend on stays undisturbed.
|
||||||
|
createMockChannel({
|
||||||
|
id: "feedf00d-0000-4000-8000-000000000008",
|
||||||
|
name: "jitter-corpus",
|
||||||
|
channel_type: "stream",
|
||||||
|
visibility: "open",
|
||||||
|
description: "Heterogeneous history for the upscroll-jitter gate",
|
||||||
|
topic: null,
|
||||||
|
purpose: null,
|
||||||
|
last_message_at: isoMinutesAgo(1),
|
||||||
|
archived_at: null,
|
||||||
|
created_by: ALICE_PUBKEY,
|
||||||
|
topic_set_by: null,
|
||||||
|
topic_set_at: null,
|
||||||
|
purpose_set_by: null,
|
||||||
|
purpose_set_at: null,
|
||||||
|
topic_required: false,
|
||||||
|
max_members: null,
|
||||||
|
nip29_group_id: null,
|
||||||
|
created_minutes_ago: 2000,
|
||||||
|
updated_minutes_ago: 1,
|
||||||
|
members: [
|
||||||
|
createMockMember(ALICE_PUBKEY, "owner", 2000),
|
||||||
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1900),
|
||||||
|
],
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockMessages = new Map<string, RelayEvent[]>();
|
const mockMessages = new Map<string, RelayEvent[]>();
|
||||||
@@ -2926,12 +2957,38 @@ function buildReplyMessageTags(
|
|||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Body generator for the jitter-corpus seed. Six structurally distinct row
|
||||||
|
// kinds, cycled by index, chosen because `estimateRowHeight` mis-reserves most
|
||||||
|
// of them — the reserve-vs-true error the upscroll-jitter gate measures.
|
||||||
|
function jitterCorpusBody(i: number): string {
|
||||||
|
switch (i % 6) {
|
||||||
|
case 0:
|
||||||
|
// Short prose — estimator is close here (control rows).
|
||||||
|
return `quick note ${i}`;
|
||||||
|
case 1:
|
||||||
|
// Heading + list: estimated as flat 20px lines; rendered line boxes and
|
||||||
|
// list margins are taller.
|
||||||
|
return `# Heading ${i}\n\n- alpha item ${i}\n- beta item ${i}\n- gamma item ${i}\n- delta item ${i}`;
|
||||||
|
case 2:
|
||||||
|
// Long wrapped prose: real wrap width differs from CHARS_PER_LINE=64.
|
||||||
|
return `This is a deliberately long paragraph number ${i} that wraps across several visual lines depending on the true column width, which the fixed characters-per-line heuristic only approximates and therefore systematically mis-estimates on wide and narrow windows alike.`;
|
||||||
|
case 3:
|
||||||
|
// Blockquote: block margins the flat estimate ignores.
|
||||||
|
return `> quoted reply ${i}\n>\n> second quoted line ${i}\n\nfollow-up prose ${i}`;
|
||||||
|
case 4:
|
||||||
|
// Fenced code: code line-height is modeled, block padding/border is not.
|
||||||
|
return `runtime log ${i}:\n\`\`\`\nconst x = ${i};\nconsole.log(x * 2);\nreturn x;\n\`\`\``;
|
||||||
|
default:
|
||||||
|
// Medium two-paragraph prose.
|
||||||
|
return `paragraph one for row ${i}\n\nparagraph two for row ${i} with a little more text to push it onto a second wrapped line`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getMockMessageStore(channelId: string): RelayEvent[] {
|
function getMockMessageStore(channelId: string): RelayEvent[] {
|
||||||
const existing = mockMessages.get(channelId);
|
const existing = mockMessages.get(channelId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seeded: RelayEvent[] =
|
const seeded: RelayEvent[] =
|
||||||
channelId === "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"
|
channelId === "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"
|
||||||
? [
|
? [
|
||||||
@@ -3111,7 +3168,24 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
|
|||||||
content: `Deep history message #${index}`,
|
content: `Deep history message #${index}`,
|
||||||
sig: "mocksig".repeat(20).slice(0, 128),
|
sig: "mocksig".repeat(20).slice(0, 128),
|
||||||
}))
|
}))
|
||||||
: [];
|
: channelId === "feedf00d-0000-4000-8000-000000000008"
|
||||||
|
? // Heterogeneous corpus for the upscroll-jitter gate. Row height
|
||||||
|
// varies widely AND `estimateRowHeight` is known to misjudge
|
||||||
|
// several of these kinds (headings/lists/blockquotes counted as
|
||||||
|
// flat 20px prose lines; long prose vs the fixed CHARS_PER_LINE
|
||||||
|
// wrap guess; code-fence chrome) — that reserve-vs-true mismatch
|
||||||
|
// is exactly the realization error the gate measures.
|
||||||
|
Array.from({ length: 400 }, (_, index) => ({
|
||||||
|
id: `mock-jitter-${index}`,
|
||||||
|
pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY,
|
||||||
|
created_at:
|
||||||
|
Math.floor(Date.now() / 1000) - (400 - index) * 60,
|
||||||
|
kind: 9,
|
||||||
|
tags: [["h", channelId]],
|
||||||
|
content: jitterCorpusBody(index),
|
||||||
|
sig: "mocksig".repeat(20).slice(0, 128),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
mockMessages.set(channelId, seeded);
|
mockMessages.set(channelId, seeded);
|
||||||
return seeded;
|
return seeded;
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { installMockBridge } from "../helpers/bridge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UPSCROLL-JITTER GATE (L-E) — the RED-today metric the fix train rides on.
|
||||||
|
*
|
||||||
|
* Tyler's report: scrolling UP a fully-loaded channel is jumpy in tiny
|
||||||
|
* variations; scrolling DOWN is smooth. Eva's H1 says why: rows above the
|
||||||
|
* opening viewport have NEVER painted, so they sit at `estimateRowHeight()`
|
||||||
|
* reserves under `content-visibility: auto`. Scrolling up, each row realizes
|
||||||
|
* at its TRUE height the instant it enters the realization band; the delta
|
||||||
|
* (true - estimate) shifts content, and the browser's scroll anchoring only
|
||||||
|
* corrects APPROXIMATELY, one lurch per realization. Down is smooth because
|
||||||
|
* `contain-intrinsic-size: auto` has already remembered every passed row's
|
||||||
|
* exact size.
|
||||||
|
*
|
||||||
|
* WHAT THIS MEASURES (the honest signal, not a proxy):
|
||||||
|
* During a steady upscroll the content already on screen must translate by
|
||||||
|
* exactly the wheel delta. If the viewport moves up by D px, every currently
|
||||||
|
* visible element's `top` must increase by exactly D. Any residual
|
||||||
|
* `(delta_top - D)` is content that jumped for a reason OTHER than the wheel —
|
||||||
|
* a realization-induced anchor correction. That residual IS the jump Tyler
|
||||||
|
* feels. A perfectly smooth scroll keeps every step's residual at 0.
|
||||||
|
*
|
||||||
|
* We track a row sitting COMFORTABLY INSIDE the viewport (a SAFE_MARGIN band
|
||||||
|
* from both edges) so it stays realized across the whole step — its `top`
|
||||||
|
* therefore reflects only true motion, never the ~one-row-height box jump a
|
||||||
|
* STRADDLING row suffers when `content-visibility` un-realizes it as it exits
|
||||||
|
* the top. When rows ABOVE the tracked row realize this step (true != estimate)
|
||||||
|
* and native anchoring is off, the content below them — including our tracked
|
||||||
|
* row — shifts down by the accumulated realization delta. That extra motion IS
|
||||||
|
* the reading-position jump Tyler feels, and it lands cleanly in the residual.
|
||||||
|
* We re-pick the tracked row each step and only score a step when the SAME id
|
||||||
|
* stayed inside the safe band both before and after (a partial exit would
|
||||||
|
* reintroduce the un-realization artifact). The gate is the WORST single-step
|
||||||
|
* residual (peak lurch) plus the RMS residual (sustained micro-jitter) — a user
|
||||||
|
* feels both the spike and the accumulated shimmer.
|
||||||
|
*
|
||||||
|
* VALIDITY (why this is the honest metric, not a proxy): a fully-realized
|
||||||
|
* ("prewarmed") channel has NO realization deltas, so this residual collapses
|
||||||
|
* to ~0 — the make-or-break control. A uniform channel realizes near-estimate,
|
||||||
|
* so it stays low. Only the heterogeneous corpus, whose estimates miss, is RED.
|
||||||
|
*
|
||||||
|
* WHY IT IS RED TODAY: the `jitter-corpus` seed (e2eBridge.ts) is 400
|
||||||
|
* structurally-rich rows whose true height `estimateRowHeight` is known to
|
||||||
|
* miss (markdown headings/lists/blockquotes counted as flat 20px prose lines;
|
||||||
|
* long prose vs the fixed CHARS_PER_LINE=64 guess; code-fence chrome). Every
|
||||||
|
* never-painted row realizes with true != estimate, so a real per-step
|
||||||
|
* residual appears. The thresholds sit under the baseline this corpus produces
|
||||||
|
* at tip 77bd0e70, so the gate FAILS now and only a genuine estimator/anchor
|
||||||
|
* fix turns it green.
|
||||||
|
*
|
||||||
|
* SCOPE / ENGINE FIDELITY: this runs under Playwright headed Chromium, which
|
||||||
|
* ships `overflow-anchor` (native scroll anchoring). The app ships in
|
||||||
|
* WKWebView, which — per Eva's L-C finding (Mari) — has NO `overflow-anchor`
|
||||||
|
* at all: it corrects NOTHING, so every above-viewport realization delta lands
|
||||||
|
* raw on the reading position. To make the red bar honest we therefore FORCE
|
||||||
|
* `overflow-anchor: none` on the timeline scroller before measuring, so
|
||||||
|
* Chromium reproduces the shipped engine's behavior instead of hiding exactly
|
||||||
|
* the drift Tyler feels. We also log `CSS.supports("overflow-anchor","auto")`
|
||||||
|
* so the per-engine baseline is on the record. A correct owned-compensation
|
||||||
|
* fix (the converged train: `overflow-anchor: none` + same-frame
|
||||||
|
* scrollBy(realized − reserved)) zeros this residual on ANY engine — which is
|
||||||
|
* the point: it makes Chromium CI test what macOS users actually feel.
|
||||||
|
*
|
||||||
|
* Run headed to watch it:
|
||||||
|
* pnpm build && npx playwright test --config=playwright.perf.config.ts \
|
||||||
|
* upscroll-jitter --headed
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Peak single-step residual we tolerate (px). Above this is a realization
|
||||||
|
// lurch the eye catches. RED at tip: baseline peaks well above.
|
||||||
|
const MAX_PEAK_RESIDUAL_PX = 2.0;
|
||||||
|
// RMS residual across steps (px) — the sustained micro-jitter floor.
|
||||||
|
const MAX_RMS_RESIDUAL_PX = 0.6;
|
||||||
|
|
||||||
|
const WHEEL_NOTCH = 220; // px per wheel step (Blink scales the applied delta)
|
||||||
|
const MAX_STEPS = 80; // cap; stop early once we near the top of the window
|
||||||
|
// Keep the tracked row this far (px) from both viewport edges so it stays
|
||||||
|
// realized across the step — no straddling-row un-realization artifact.
|
||||||
|
const SAFE_MARGIN = 100;
|
||||||
|
|
||||||
|
type StepSample = { residual: number; appliedDelta: number };
|
||||||
|
|
||||||
|
type Result = {
|
||||||
|
samples: StepSample[];
|
||||||
|
steps: number;
|
||||||
|
reachedTop: boolean;
|
||||||
|
rowCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("GATE: upscroll anchor residual stays below the realization-jitter threshold", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockBridge(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The `jitter-corpus` channel is pre-seeded (e2eBridge.ts) with 400
|
||||||
|
// heterogeneous rows in its mock store — no dependence on live-emit timing,
|
||||||
|
// the same reliable cold-load path scroll-history.spec.ts uses.
|
||||||
|
await page.getByTestId("channel-jitter-corpus").click();
|
||||||
|
await expect(page.getByTestId("chat-title")).toHaveText("jitter-corpus");
|
||||||
|
const timeline = page.getByTestId("message-timeline");
|
||||||
|
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const el = document.querySelector(
|
||||||
|
'[data-testid="message-timeline"]',
|
||||||
|
) as HTMLDivElement | null;
|
||||||
|
return !!el && el.scrollHeight > el.clientHeight + 1000;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pin to the true bottom so everything above is unpainted (at estimate).
|
||||||
|
await timeline.evaluate((element) => {
|
||||||
|
const el = element as HTMLDivElement;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
|
||||||
|
// Log native-anchoring support for this engine, then FORCE it off so
|
||||||
|
// Chromium reproduces the shipped WKWebView (which has no `overflow-anchor`).
|
||||||
|
// Without this, Blink's native anchoring silently corrects the realization
|
||||||
|
// shift and the gate would measure a world macOS users never see.
|
||||||
|
const anchorSupport = await page.evaluate(() =>
|
||||||
|
typeof CSS !== "undefined" && typeof CSS.supports === "function"
|
||||||
|
? CSS.supports("overflow-anchor", "auto")
|
||||||
|
: false,
|
||||||
|
);
|
||||||
|
await timeline.evaluate((element) => {
|
||||||
|
(element as HTMLElement).style.overflowAnchor = "none";
|
||||||
|
});
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
console.log(
|
||||||
|
`overflow-anchor supported by this engine: ${anchorSupport} ` +
|
||||||
|
`(forced to 'none' on the scroller to mirror shipped WKWebView)`,
|
||||||
|
);
|
||||||
|
/* eslint-enable no-console */
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
|
||||||
|
// Hover so the timeline owns wheel scrolling (mirrors a real reader; also
|
||||||
|
// engages the `:hover` realization rule, utilities.css:21).
|
||||||
|
await timeline.hover();
|
||||||
|
|
||||||
|
const samples: StepSample[] = [];
|
||||||
|
let reachedTop = false;
|
||||||
|
|
||||||
|
for (let step = 0; step < MAX_STEPS; step += 1) {
|
||||||
|
// Pick a row sitting comfortably inside the viewport (SAFE_MARGIN from both
|
||||||
|
// edges) so it stays realized across the wheel notch. Capture its top and
|
||||||
|
// the scrollTop BEFORE the notch. reachedTop is decided by scrollTop, not by
|
||||||
|
// whether a safe-band row exists.
|
||||||
|
const before = await timeline.evaluate((element, margin: number) => {
|
||||||
|
const el = element as HTMLDivElement;
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
const safeTop = box.top + margin;
|
||||||
|
const safeBottom = box.bottom - margin;
|
||||||
|
const rows = el.querySelectorAll<HTMLElement>("[data-message-id]");
|
||||||
|
for (const row of rows) {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
if (rect.top > safeTop && rect.bottom < safeBottom) {
|
||||||
|
return {
|
||||||
|
id: row.dataset.messageId ?? null,
|
||||||
|
top: rect.top,
|
||||||
|
scrollTop: el.scrollTop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: null, top: 0, scrollTop: el.scrollTop };
|
||||||
|
}, SAFE_MARGIN);
|
||||||
|
|
||||||
|
if (before.scrollTop <= 0) {
|
||||||
|
reachedTop = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!before.id) {
|
||||||
|
// No row fully inside the safe band this step (rare: a single row taller
|
||||||
|
// than the band). Nudge up and try the next step rather than scoring it.
|
||||||
|
await page.mouse.wheel(0, -WHEEL_NOTCH);
|
||||||
|
await timeline.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((r) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => r())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One real wheel notch upward, then settle two frames so realization +
|
||||||
|
// any anchor correction land before we read positions back.
|
||||||
|
await page.mouse.wheel(0, -WHEEL_NOTCH);
|
||||||
|
await timeline.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((r) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => r())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const after = await timeline.evaluate(
|
||||||
|
(element, args: { id: string; margin: number }) => {
|
||||||
|
const el = element as HTMLDivElement;
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
const row = el.querySelector<HTMLElement>(
|
||||||
|
`[data-message-id="${CSS.escape(args.id)}"]`,
|
||||||
|
);
|
||||||
|
if (!row)
|
||||||
|
return { top: null, inSafeBand: false, scrollTop: el.scrollTop };
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
top: rect.top,
|
||||||
|
// Only score the step if the SAME row is still fully inside the band —
|
||||||
|
// otherwise a partial exit would reintroduce the un-realization jump.
|
||||||
|
inSafeBand:
|
||||||
|
rect.top > box.top + args.margin &&
|
||||||
|
rect.bottom < box.bottom - args.margin,
|
||||||
|
scrollTop: el.scrollTop,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ id: before.id, margin: SAFE_MARGIN },
|
||||||
|
);
|
||||||
|
|
||||||
|
const appliedDelta = before.scrollTop - after.scrollTop; // px moved up
|
||||||
|
if (appliedDelta <= 0) {
|
||||||
|
// No movement (already at top / wheel absorbed) — stop.
|
||||||
|
reachedTop = after.scrollTop <= 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (after.top === null || !after.inSafeBand) {
|
||||||
|
// Tracked row left the safe band this step — skip scoring, keep scrolling.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Smooth: reducing scrollTop by appliedDelta moves the row DOWN the
|
||||||
|
// viewport by exactly appliedDelta. Residual = motion NOT from the wheel,
|
||||||
|
// i.e. realization deltas from rows that crossed the band ABOVE this row.
|
||||||
|
const residual = after.top - before.top - appliedDelta;
|
||||||
|
samples.push({ residual, appliedDelta });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Result = {
|
||||||
|
samples,
|
||||||
|
steps: samples.length,
|
||||||
|
reachedTop,
|
||||||
|
rowCount: await timeline.evaluate(
|
||||||
|
(el) =>
|
||||||
|
(el as HTMLDivElement).querySelectorAll("[data-message-id]").length,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const abs = result.samples.map((s) => Math.abs(s.residual));
|
||||||
|
const peak = abs.length ? Math.max(...abs) : 0;
|
||||||
|
const rms = abs.length
|
||||||
|
? Math.sqrt(abs.reduce((a, r) => a + r * r, 0) / abs.length)
|
||||||
|
: 0;
|
||||||
|
const mean = abs.length ? abs.reduce((a, r) => a + r, 0) / abs.length : 0;
|
||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
console.log("\n=== UPSCROLL JITTER GATE (anchor residual, Chromium) ===");
|
||||||
|
console.log(`rows mounted (live DOM): ${result.rowCount}`);
|
||||||
|
console.log(`steps measured: ${result.steps}`);
|
||||||
|
console.log(`reached top of history: ${result.reachedTop}`);
|
||||||
|
console.log(
|
||||||
|
`peak single-step residual: ${peak.toFixed(2)}px (gate <= ${MAX_PEAK_RESIDUAL_PX})`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`rms residual: ${rms.toFixed(2)}px (gate <= ${MAX_RMS_RESIDUAL_PX})`,
|
||||||
|
);
|
||||||
|
console.log(`mean |residual|: ${mean.toFixed(2)}px`);
|
||||||
|
console.log("(0 == every on-screen row tracked the wheel exactly)");
|
||||||
|
console.log("========================================================\n");
|
||||||
|
/* eslint-enable no-console */
|
||||||
|
|
||||||
|
// Sanity: the run actually exercised a meaningful upscroll. The cold-load
|
||||||
|
// windows the 400-row seed to the newest ~100 rows (CHANNEL_HISTORY_LIMIT),
|
||||||
|
// all in the DOM (de-virtualized), so ~100 mounted rows is the expected
|
||||||
|
// fully-loaded window we scroll within.
|
||||||
|
expect(result.rowCount).toBeGreaterThanOrEqual(80);
|
||||||
|
expect(result.samples.length).toBeGreaterThan(8);
|
||||||
|
|
||||||
|
// THE GATE. RED at tip 77bd0e70; a real estimator/anchor fix turns it green.
|
||||||
|
expect(peak).toBeLessThanOrEqual(MAX_PEAK_RESIDUAL_PX);
|
||||||
|
expect(rms).toBeLessThanOrEqual(MAX_RMS_RESIDUAL_PX);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user