Files
photo-live/apps/viewer/e2e/embed-v2.spec.ts
T
浅念 1d1979f540 feat(viewer,ui04): E02 static fidelity — real photo fixtures, measured shell/photo/collapse specs, edge fade
- fixtures: 8 picsum-seeded real photos (mixed ratios); seedPhoto thumbBytes/browseBytes injection
- e2e E02 block: wrapper shell (195x280/radius/border/#10161E), photo strip (193w, ratio-height, 3px gap), collapse (44/44 hit, 24 visual, sole button), desktop+mobile screenshots
- EmbedStream: top/bottom 12px dark fade (0.12, §2.3 optional item)
- fix(admin): pre-existing eslint globals gap for ElNotification (template drift, blocks lint gate)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 13:01:34 +08:00

227 lines
9.1 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 } 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('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') });
});
});