Files
photo-live/apps/viewer/e2e/embed-v2.spec.ts
T
浅念 998565e439 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>
2026-09-22 13:19:21 +08:00

342 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createServer, type Server } from 'node:http';
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';
import {
cleanupSeededAlbums,
createEmbedSlot,
flushOutbox,
flushSync,
seedAlbum,
seedPhoto,
updateEmbedSlot,
} from './helpers';
// UI-04 v2(vertical-stream 照片流窗)跨源 Playwright:宿主 127.0.0.1:3999 ←
// viewer 5175 真跨源,loader /embed/v2/loader.js。随 E01-E07 逐任务扩展:
// E01 出口门 = 固定 195×280 小窗跨源可见且无 footer;E02 = 静态高保真测量。
// 照片种子为真实摄影样本(e2e/fixtures,picsum 固定 seed 下载——验收 §11.1
// 「无渐变色块假图」;比例覆盖横/竖/方/全景,EMB-A03 素材基础)。
const HOST_PORT = 3999;
const VIEWER_ORIGIN = 'http://127.0.0.1:5175';
const MINUTE = 60_000;
const HERE = dirname(fileURLToPath(import.meta.url));
const OUT_ROOT = join(HERE, '../../../docs/designs/ui04-implementation');
/** 摄影样本(picsum seed 固定):[file, width, height]。 */
const FIXTURES: Array<[string, number, number]> = [
['photo-01.jpg', 800, 500],
['photo-02.jpg', 800, 1100],
['photo-03.jpg', 800, 800],
['photo-04.jpg', 1200, 400],
['photo-05.jpg', 800, 1400],
['photo-06.jpg', 900, 600],
['photo-07.jpg', 800, 1000],
['photo-08.jpg', 1000, 700],
];
let slotToken = '';
async function seedFeed(alias: string): Promise<string> {
const album = await seedAlbum({
alias,
defaultLocale: 'zh-CN',
translations: [
{ locale: 'zh-CN', title: `${alias} 中文相册` },
{ locale: 'en', title: `${alias} Album` },
],
});
for (let i = 0; i < FIXTURES.length; i++) {
const [file, width, height] = FIXTURES[i];
const bytes = await readFile(join(HERE, 'fixtures', file));
await seedPhoto(album.id, `${alias}-p${i + 1}`, {
publishedAt: new Date(Date.now() - (FIXTURES.length - i) * MINUTE),
width,
height,
thumbBytes: bytes,
browseBytes: bytes,
});
}
await flushSync(album.id);
const slot = await createEmbedSlot(`ui04-v2-${alias}`);
// v2 模式显式提交(E01 契约:displayMode vertical-stream + 速度/气泡)
await updateEmbedSlot(slot.id, {
albumId: album.id,
settings: {
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
displayMode: 'vertical-stream',
scrollSpeed: 16,
reactionsEnabled: true,
},
});
await flushOutbox();
return slot.token;
}
let currentHtml = '';
const server: Server = createServer((_req, res) => {
res.setHeader('content-type', 'text/html; charset=utf-8');
res.end(currentHtml);
});
async function openHost(page: Page, viewport: { width: number; height: number }): Promise<void> {
await page.setViewportSize(viewport);
currentHtml = `<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UI-04 v2 host</title></head>
<body style="min-height: 1200px; font-family: system-ui">
<h1>Host page (v2 stream)</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}`);
}
test.beforeAll(async () => {
slotToken = await seedFeed('ui04v2');
await new Promise<void>((resolve) => {
// 残留进程容错:上轮失败运行的夹具服务器可能仍占端口——同内容服务,
// 复用即可(其后用例改写 currentHtml 对两个进程同值)。
server.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') resolve();
else throw err;
});
server.listen(HOST_PORT, '127.0.0.1', () => resolve());
});
});
test.afterAll(async () => {
await cleanupSeededAlbums();
server.close();
});
test.describe.serial('E01 exit gate: fixed window, no footer', () => {
for (const [name, viewport] of [
['desktop 1440x900', { width: 1440, height: 900 }],
['mobile 390x844', { width: 390, height: 844 }],
] as const) {
test(`v2 window is fixed 195x280 on ${name}`, async ({ page }) => {
const mobile = viewport.width < 480;
await openHost(page, viewport);
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
const wrapper = frame.locator('..');
// 手机默认收起(沿用 v1 策略):点击把手展开后测量。
if (mobile) {
await page.locator('[role="button"]').first().click();
}
await expect(frame).toBeVisible();
await expect
.poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0))
.toBe(195);
await expect
.poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0))
.toBe(280);
// iframe 内:无 header/footer(E01 静态壳);右上收起按钮存在。
const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
await expect(inside.getByRole('button', { name: /收起照片窗|Collapse photo window/ })).toBeVisible();
// v1 结构不出现(判别渲染 DOM 而非原始 HTML——dev 模式内联 CSS 含全部
// scoped 类名,字符串断言会误报;「活动照片」字样亦存在于照片 alt 文案)。
await expect(inside.locator('.embed-head')).toHaveCount(0);
await expect(inside.locator('.embed-footer')).toHaveCount(0);
await expect(inside.getByText('查看相册')).toHaveCount(0);
await expect(inside.getByText('照片轮播')).toHaveCount(0);
await expect(inside.locator('.stream-viewport')).toHaveCount(1);
});
}
});
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 });
const wrapper = page.locator('iframe[src*="/embed/"][src*="v=2"]').locator('..');
await expect(page.locator('iframe[src*="/embed/"][src*="v=2"]')).toBeVisible();
await expect
.poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0))
.toBe(195);
await expect
.poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0))
.toBe(280);
// 外壳视觉规格(§2.1):圆角 12 / 单圈 1px 白边 / 底色 #10161E / 阴影。
const shell = await wrapper.evaluate((el) => {
const cs = getComputedStyle(el);
return {
radius: cs.borderTopLeftRadius,
borderWidth: cs.borderTopWidth,
borderColor: cs.borderTopColor,
background: cs.backgroundColor,
boxShadow: cs.boxShadow,
boxSizing: cs.boxSizing,
};
});
expect(shell.radius).toBe('12px');
expect(shell.borderWidth).toBe('1px');
expect(shell.borderColor).toBe('rgba(255, 255, 255, 0.8)');
expect(shell.background).toBe('rgb(16, 22, 30)');
expect(shell.boxSizing).toBe('border-box');
// 照片带(§2.2/2.3):原比例、宽满内宽(195-2=193)、3px 缝。
const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
const firstImg = inside.locator('.stream-photo__img').first();
await expect(firstImg).toBeVisible();
const imgBox = await firstImg.boundingBox();
expect(Math.round(imgBox?.width ?? 0)).toBe(193);
// 首图 = 最新照片(publishedAt 倒序)= photo-08 1000×700 → 193*0.7 ≈ 135。
expect(Math.round(imgBox?.height ?? 0)).toBe(135);
const gap = await inside.locator('.stream-track').evaluate((el) => getComputedStyle(el).rowGap);
expect(gap).toBe('3px');
// 收起入口(§2.4):可视圆 24×24 右上内缩 6px;有效触区 44×44。
const collapse = inside.locator('.stream-collapse');
const cbBox = await collapse.boundingBox();
expect(Math.round(cbBox?.width ?? 0)).toBe(44);
expect(Math.round(cbBox?.height ?? 0)).toBe(44);
const vpBox = await inside.locator('.stream-viewport').boundingBox();
expect(Math.round((vpBox?.x ?? 0) + (vpBox?.width ?? 0) - (cbBox?.x ?? 0) - 44)).toBe(0);
expect(Math.round((cbBox?.y ?? 0) - (vpBox?.y ?? 0))).toBe(0);
// 表面无工具元素(§2.2):除收起外无其他 button/链接按钮形态。
await expect(inside.locator('button')).toHaveCount(1);
await page.screenshot({ path: join(OUT_ROOT, 'actual/desktop/e02-static-desktop.png') });
});
test('mobile 390x844: expanded window identical 195x280 + screenshot', async ({ page }) => {
await openHost(page, { width: 390, height: 844 });
await page.locator('[role="button"]').first().click();
const wrapper = page.locator('iframe[src*="/embed/"][src*="v=2"]').locator('..');
await expect
.poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0))
.toBe(195);
await expect
.poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0))
.toBe(280);
await page.screenshot({ path: join(OUT_ROOT, 'actual/mobile/e02-static-mobile.png') });
});
});