feat(viewer,ui04): E04 interactions — pause reason set, click lock/drag cancel, manual scrub, keyboard semantics, destroy
- pauseReasons Set (hover/focus/pointer/offscreen/hidden/manual + userPaused via Space, no auto-resume); 500ms transient resume with re-evaluation - pointerdown target lock; >8px drag suppresses click and scrubs via nudgeOffset (window-clamped); removed photos clear pending target - keyboard: Space pause toggle, Escape collapse, ArrowUp/Down scrub; input fields untouched - aria-live status announcements (zh/en); viewport touch-action none - e2e: hover freeze/resume, popup deep link hit, drag cancel, keyboard, slot disable destroy Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -270,6 +270,136 @@ test.describe.serial('E03 recording: 30s+ including loop boundary', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('E04 interactions: pause, click lock, keyboard, destroy', () => {
|
||||
async function trackTransform(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;
|
||||
}
|
||||
|
||||
test('fine-pointer hover pauses; leaving resumes after 500ms (§4.2)', async ({ page }) => {
|
||||
await openHost(page, { width: 1440, height: 900 });
|
||||
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
await frame.waitFor();
|
||||
await page.waitForTimeout(1500);
|
||||
// 悬停 iframe 中心(细指针环境):300ms 后 transform 冻结。
|
||||
const box = await frame.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140);
|
||||
await page.waitForTimeout(400);
|
||||
const pausedAt = await trackTransform(page);
|
||||
await page.waitForTimeout(800);
|
||||
expect(await trackTransform(page)).toBe(pausedAt); // 悬停期间静止
|
||||
// 离开:500ms 恢复延时后继续上滚。
|
||||
await page.mouse.move(40, 40);
|
||||
await page.waitForTimeout(400);
|
||||
const resumedAt = await trackTransform(page);
|
||||
await page.waitForTimeout(900);
|
||||
const after = await trackTransform(page);
|
||||
expect(after).toBeLessThan(resumedAt); // 恢复滚动(translateY 更负)
|
||||
});
|
||||
|
||||
test('photo click opens viewer deep link for the clicked photo (§4.1)', 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(400);
|
||||
const target = inside.locator('a.stream-photo').first();
|
||||
const photoId = await target.getAttribute('data-photo-id');
|
||||
expect(photoId).toBeTruthy();
|
||||
const popupPromise = page.waitForEvent('popup', { timeout: 5000 });
|
||||
await target.click();
|
||||
const popup = await popupPromise;
|
||||
expect(popup.url()).toContain(`/p/${photoId}`);
|
||||
await popup.close();
|
||||
});
|
||||
|
||||
test('drag beyond 8px cancels click (§4.1)', async ({ page }) => {
|
||||
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();
|
||||
expect(box).not.toBeNull();
|
||||
const cx = (box!.x ?? 0) + 90;
|
||||
const cy = (box!.y ?? 0) + 200;
|
||||
let popupOpened = false;
|
||||
page.on('popup', () => {
|
||||
popupOpened = true;
|
||||
});
|
||||
await page.mouse.move(cx, cy);
|
||||
await page.waitForTimeout(300);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx, cy - 30, { steps: 5 }); // 拖动 30px > 8px
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
expect(popupOpened).toBe(false);
|
||||
});
|
||||
|
||||
test('keyboard: Space toggles pause (no auto-resume), Escape collapses (§4.2)', 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"]');
|
||||
await page.waitForTimeout(1200);
|
||||
// 聚焦流区域(点击视口非链接处不触发照片点击——顶部渐隐带命中视口)。
|
||||
const box = await frame.boundingBox();
|
||||
await page.mouse.click((box!.x ?? 0) + 90, (box!.y ?? 0) + 6);
|
||||
await inside.locator('.stream-viewport').focus();
|
||||
await page.keyboard.press(' ');
|
||||
await page.waitForTimeout(300);
|
||||
const pausedAt = await trackTransform(page);
|
||||
await page.waitForTimeout(1000);
|
||||
expect(await trackTransform(page)).toBe(pausedAt); // Space 暂停:不自动恢复
|
||||
// Escape → collapse 上行宿主:wrapper 隐藏 + 把手出现。
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('[role="button"]').first()).toBeVisible();
|
||||
await expect(frame).toBeHidden();
|
||||
});
|
||||
|
||||
test('slot disable → destroy: wrapper and handle removed, no ghost (§6.3)', async ({ page }) => {
|
||||
// 独立槽(不破坏主 slot 的后续用例)。
|
||||
const album = await seedAlbum({
|
||||
alias: 'ui04v2-destroy',
|
||||
defaultLocale: 'zh-CN',
|
||||
translations: [{ locale: 'zh-CN', title: '销毁测试' }],
|
||||
});
|
||||
await seedPhoto(album.id, 'ui04v2-destroy-p1', { publishedAt: new Date() });
|
||||
await flushSync(album.id);
|
||||
const slot = await createEmbedSlot('ui04-v2-destroy');
|
||||
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();
|
||||
|
||||
currentHtml = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" />
|
||||
<title>destroy host</title></head><body style="min-height: 600px">
|
||||
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slot.token}" data-poll="1000" defer></script>
|
||||
</body></html>`;
|
||||
await page.setViewportSize({ width: 800, height: 600 });
|
||||
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
|
||||
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
await frame.waitFor();
|
||||
// 停用:管理面 enabled=false → 高优先级 slot.updated → ov 404 → destroy。
|
||||
await updateEmbedSlot(slot.id, { enabled: false });
|
||||
await flushOutbox();
|
||||
// dev 构建 data-poll=1000 → 1s 轮询加速(loader 仅显式透传)。
|
||||
await expect(frame).toBeHidden({ timeout: 15_000 });
|
||||
await expect(page.locator('[role="button"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
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