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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user