feat(viewer,ui04): E05 realtime hearts + effect-demo alignment (initial offset, heart params, decode gate, bidirectional manual scroll)

- reactions.ts pure fns: delta->hearts sampling, burstId dedupe (TTL/capacity), pending-visible cache (5s/50), 250ms emission throttle with merge
- useEmbedReactions: read-only RealtimeClient subscription on album.publicToken (no viewer-page side effects, A15), rebind teardown, 10s grace disconnect, no fake bubbles on WS failure
- ReactionLayer: demo-frozen heart effect (12/16/20 sizes, two colors, 110px rise, 1.6s ease-out, 300ms stagger, max 8, drop-shadow) in unscoped block (created elements lack scoped hash)
- demo alignment: initial offset 32px, decode+500ms startup gate, 1:1 drag, arrows +-40, focus-tip, bidirectional manual scroll (manualScroll), loader handle 100x44 exact styles, <=599px offset default 12
- tests: +8 reactions unit (244 viewer), e2e E05: real cross-context like -> heart on correct photo (EMB-A09), zero likes/visits submission (EMB-A15), 16s hearts recording

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 17:38:02 +08:00
parent f6ff1d44f2
commit 91c85e196a
15 changed files with 667 additions and 120 deletions
+122
View File
@@ -37,6 +37,7 @@ const FIXTURES: Array<[string, number, number]> = [
];
let slotToken = '';
let albumToken = '';
async function seedFeed(alias: string): Promise<string> {
const album = await seedAlbum({
@@ -47,6 +48,7 @@ async function seedFeed(alias: string): Promise<string> {
{ locale: 'en', title: `${alias} Album` },
],
});
albumToken = album.publicToken;
for (let i = 0; i < FIXTURES.length; i++) {
const [file, width, height] = FIXTURES[i];
const bytes = await readFile(join(HERE, 'fixtures', file));
@@ -102,6 +104,11 @@ async function openHost(page: Page, viewport: { width: number; height: number })
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
}
// 文件级 serial:config fullyParallel 会把同文件用例分散到多 worker,各自
// 执行 beforeAll(种子/夹具服务器/admin 幂等种子并发冲突)。整文件单 worker
// 串行,beforeAll 恰好一次。
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
slotToken = await seedFeed('ui04v2');
await new Promise<void>((resolve) => {
@@ -400,6 +407,121 @@ test.describe.serial('E04 interactions: pause, click lock, keyboard, destroy', (
});
});
test.describe.serial('E05 realtime hearts: real burst drives particles', () => {
test('real like from another context spawns heart on the liked photo (EMB-A09)', async ({ page }) => {
await openHost(page, { width: 1440, height: 900 });
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
await frame.waitFor();
const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
// 悬停冻结(稳定归属判定),取首个可见照片。
const box = await frame.boundingBox();
await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140);
await page.waitForTimeout(500);
const embedFrame = page.frames().find((f) => f.url().includes('v=2'));
expect(embedFrame).toBeDefined();
const photoId = await embedFrame!.locator('a.stream-photo').first().getAttribute('data-photo-id');
expect(photoId).toBeTruthy();
// 真实公开路径点赞(ov 域):服务端广播 likes.burst → 订阅出泡。
const like = await page.request.post(
`http://127.0.0.1:8081/v1/public/albums/${albumToken}/photos/${photoId}/likes`,
{
headers: { 'content-type': 'application/json' },
data: { sessionId: `ui04-e2e-${Date.now()}`, batchId: `batch-${Date.now()}`, delta: 1 },
},
);
expect(like.status()).toBeLessThan(300);
// 心形在 1.6s 动画内出现于 iframe。
await expect(inside.locator('.heart').first()).toBeVisible({ timeout: 5_000 });
// 归属正确:心形 top 落在被赞照片交集附近(±80px 容差:起点为交集下侧-10)。
const heartTop = await inside.locator('.heart').first().boundingBox();
const photoRect = await embedFrame!
.locator(`a.stream-photo[data-photo-id="${photoId}"]`)
.first()
.boundingBox();
expect(heartTop).not.toBeNull();
expect(photoRect).not.toBeNull();
const frameOriginY = (await frame.boundingBox())!.y ?? 0;
// 统一 frame 坐标:bbox 均为页面坐标,减 frame 顶。期望起点 = 照片交集
// 下侧 -10(origin),心形自 origin 上浮 ≤110px(动画进行中的实测位置
// 落在 [origin-130, origin+5] 区间)。
const heartFrame = heartTop!.y - frameOriginY;
const originFrame = photoRect!.y + photoRect!.height - 10 - frameOriginY;
expect(heartFrame).toBeLessThanOrEqual(originFrame + 5);
expect(heartFrame).toBeGreaterThanOrEqual(originFrame - 130);
});
test('embed itself submits no likes or visits (EMB-A15)', async ({ page }) => {
const interactive: string[] = [];
page.on('request', (req) => {
const url = req.url();
if (url.includes('/likes') || url.includes('/visits')) {
if (req.method() === 'POST') interactive.push(url);
}
});
await openHost(page, { width: 1440, height: 900 });
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
await frame.waitFor();
// 悬停/滚轮/键盘各通道触发后观察一个窗口期。
const box = await frame.boundingBox();
await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140);
await page.mouse.wheel(0, 40);
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(2_500);
expect(interactive).toEqual([]);
});
});
test.describe.serial('E05 recording: scroll + real like hearts', () => {
test('record 16s scroll with periodic real likes into recordings/', async ({ browser }) => {
test.setTimeout(90_000);
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 hearts recording host</title></head>
<body style="min-height: 1200px">
<h1>Host page (v2 hearts 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}`);
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
await frame.waitFor();
await page.waitForTimeout(2_000);
// 每 2.4s 真实点赞一个可见照片(效果示例 simulate 节奏)。
const embedFrame = page.frames().find((f) => f.url().includes('v=2'));
for (let i = 0; i < 6; i++) {
const photoId =
(await embedFrame!.locator('a.stream-photo').nth(i % 2).getAttribute('data-photo-id')) ?? '';
if (photoId !== '') {
await page.request
.post(`http://127.0.0.1:8081/v1/public/albums/${albumToken}/photos/${photoId}/likes`, {
headers: { 'content-type': 'application/json' },
data: {
sessionId: `ui04-rec-${Date.now()}`,
batchId: `batch-rec-${Date.now()}-${i}`,
delta: 1,
},
})
.catch(() => undefined);
}
await page.waitForTimeout(2_400);
}
} 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', 'e05-hearts-16s.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 });