feat(viewer,ui04): E03 continuous scroll engine — pure state machine, rAF orchestration, boundary continuity
- stream.ts: advanceStream (per-frame, dt cap 50ms), loop generation keys, window math (DOM<=16, queue<=30, 480px buffer), anchorRebuild (photo-position preserving), removePhoto, single-photo static - useVerticalPhotoStream: rAF integration, direct transform writes (no per-frame Vue reactivity), low-frequency item sync, ResizeObserver re-anchor, stepForTest hook - EmbedStream wired to engine; cycle copies aria-hidden; img error -> controlled drop - tests: 14 unit cases (236 viewer total), e2e: 32px/2s speed, boundary continuity <2.5px, DOM<=16 over 20s, 32s recording with loop boundaries Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { readFile, rename } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
@@ -155,6 +155,121 @@ test.describe.serial('E01 exit gate: fixed window, no footer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.describe.serial('E03 continuous scroll: speed, boundary, DOM cap', () => {
|
||||
/** 读 iframe 内轨道 transform 的 translateY 像素值。 */
|
||||
async function trackOffset(page: Page): Promise<number> {
|
||||
const embedFrame = page.frames().find((f) => f.url().includes('v=2'));
|
||||
expect(embedFrame).toBeDefined();
|
||||
return (await embedFrame!.locator('.stream-track').evaluate((el) => {
|
||||
const m = /translateY\((-?[\d.]+)px\)/.exec(el.style.transform ?? '');
|
||||
return m === null ? 0 : Number.parseFloat(m[1]);
|
||||
})) as number;
|
||||
}
|
||||
|
||||
/** 可见照片元素(src+top)快照:连续性匹配用。 */
|
||||
async function visibleImgs(page: Page): Promise<Array<{ src: string; top: number }>> {
|
||||
const embedFrame = page.frames().find((f) => f.url().includes('v=2'));
|
||||
expect(embedFrame).toBeDefined();
|
||||
return (await embedFrame!.locator('.stream-track').evaluate((el) =>
|
||||
Array.from(el.querySelectorAll<HTMLImageElement>('.stream-photo__img')).map((img) => ({
|
||||
src: img.currentSrc.split('/').pop() ?? img.currentSrc,
|
||||
top: img.getBoundingClientRect().top,
|
||||
})),
|
||||
)) as Array<{ src: string; top: number }>;
|
||||
}
|
||||
|
||||
test('default 16px/s: anchor photo moves up ~32px over 2s (±2px, §11.2)', async ({ page }) => {
|
||||
await openHost(page, { width: 1440, height: 900 });
|
||||
await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor();
|
||||
await page.waitForTimeout(1200); // 首帧静置 + 稳定
|
||||
const before = await trackOffset(page);
|
||||
await page.waitForTimeout(2000);
|
||||
const after = await trackOffset(page);
|
||||
// 无退役区间(首条 123.6px > 32px 位移)→ transform 差即位移(越滚越负)。
|
||||
const moved = before - after;
|
||||
expect(moved).toBeGreaterThanOrEqual(30);
|
||||
expect(moved).toBeLessThanOrEqual(34);
|
||||
});
|
||||
|
||||
test('loop boundary: matched photo keeps -16px/s continuity, no jump >2px', async ({ page }) => {
|
||||
await openHost(page, { width: 1440, height: 900 });
|
||||
await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor();
|
||||
await page.waitForTimeout(800);
|
||||
// 12s 采样(每 250ms):覆盖 ≥1 个退役边界(条高 123.6px / 16px/s ≈ 7.7s)。
|
||||
const samples: Array<Array<{ src: string; top: number }>> = [];
|
||||
const deadline = Date.now() + 12_000;
|
||||
while (Date.now() < deadline) {
|
||||
samples.push(await visibleImgs(page));
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
expect(samples.length).toBeGreaterThan(30);
|
||||
let matched = 0;
|
||||
let maxError = 0;
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
const prev = new Map(samples[i - 1].map((s) => [s.src, s.top]));
|
||||
for (const cur of samples[i]) {
|
||||
const before = prev.get(cur.src);
|
||||
if (before === undefined) continue;
|
||||
matched += 1;
|
||||
const expected = -16 * 0.25; // 采样间隔约 250ms
|
||||
const error = Math.abs(cur.top - before - expected);
|
||||
maxError = Math.max(maxError, error);
|
||||
break; // 每对样本取一个匹配元素即可
|
||||
}
|
||||
}
|
||||
// 匹配连续性成立且误差远小于 2px 口径(定时器抖动计入)。
|
||||
expect(matched).toBeGreaterThan(20);
|
||||
expect(maxError).toBeLessThan(2.5);
|
||||
});
|
||||
|
||||
test('DOM instances bounded (≤16) over 20s (§11.2 resource cap)', async ({ page }) => {
|
||||
await openHost(page, { width: 1440, height: 900 });
|
||||
await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor();
|
||||
await page.waitForTimeout(1000);
|
||||
const embedFrame = page.frames().find((f) => f.url().includes('v=2'));
|
||||
expect(embedFrame).toBeDefined();
|
||||
const counts: number[] = [];
|
||||
const deadline = Date.now() + 20_000;
|
||||
while (Date.now() < deadline) {
|
||||
counts.push(await embedFrame!.locator('.stream-photo__img').count());
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
expect(Math.max(...counts)).toBeLessThanOrEqual(16);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('E03 recording: 30s+ including loop boundary', () => {
|
||||
test('record 32s scroll video into recordings/', async ({ browser }) => {
|
||||
test.setTimeout(120_000); // 录屏 32s + 启动裕量(配置缺省 30s 会截断)
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
recordVideo: { dir: join(OUT_ROOT, 'recordings'), size: { width: 1440, height: 900 } },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
currentHtml = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" />
|
||||
<title>UI-04 v2 recording host</title></head>
|
||||
<body style="min-height: 1200px">
|
||||
<h1>Host page (v2 recording)</h1>
|
||||
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slotToken}" defer></script>
|
||||
</body></html>`;
|
||||
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
|
||||
await page.locator('iframe[src*="/embed/"][src*="v=2"]').waitFor();
|
||||
// 32s:条高 ~123.6px / 16px/s ≈ 7.7s 一退役 → 至少 4 个循环边界。
|
||||
await page.waitForTimeout(32_000);
|
||||
} finally {
|
||||
const video = page.video();
|
||||
await context.close().catch(() => undefined);
|
||||
if (video !== null) {
|
||||
const src = await video.path();
|
||||
if (src !== null) {
|
||||
await rename(src, join(OUT_ROOT, 'recordings', 'e03-scroll-32s.webm')).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('E02 static fidelity: measured styles + screenshots', () => {
|
||||
test('desktop 1440x900: window shell, photo strip, collapse button measured', async ({ page }) => {
|
||||
await openHost(page, { width: 1440, height: 900 });
|
||||
|
||||
Reference in New Issue
Block a user