Files
photo-live/apps/viewer/e2e/album.spec.ts
T
浅念 cdead49838 feat(viewer): likes, bubbles, visit reporting and hot sorting
Task 10 viewer 互动化:

- 点赞:useLikes 批次状态机(500ms 防抖 / 5 次满额即冲 / 失败同 batchId
  退避重试 3 次后回滚丢弃),乐观计数 + 服务端 total 权威校准
  (countVersion 单调),大图与格子双入口;
- WS 实时:RealtimeClient 纯状态机(半频心跳、指数退避重连、三败降级、
  重连成功 resync 第 1 页重取),消息路由器做 likes.burst 自身过滤
  (originBatchIds 求交)、隐藏页暂停与减少动画下仅校准不出气泡;
  photo.hidden/deleted 即时移出序列并回退邻居大图;降级后 8s 轮询接管;
- 浏览上报:visitorId 跨标签 BroadcastChannel first-writer-wins 协调,
  可见时上报、requestId 持久幂等、网络失败指数重试;
- 排序:最新/热门 chips(热门走 ov 冷快照),大图打开期间禁用切换;
  顶栏「减少动画」偏好开关;
- 契约包新增 ./realtime 子路径出口(浏览器面不可引桶出口——其连带
  internal-sync 的 node:crypto 会被 Vite 外置化致整页崩溃);
- e2e 注入钩子 __viewerWsUrl/__viewerWsRetryBaseMs/__viewerRealtimePollMs/
  __viewerBubbleMs 与 .activity[data-connection] 连接态锚。

vitest:viewer 105(新增 36:likes 批次 8、visitor 协调 5、上报门控 4、
路由 8、WS 状态机 7、排序 4);Playwright:viewer 21(新增 likes.spec 7、
journey 补互动腿断言)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 19:52:27 +08:00

142 lines
5.9 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 { expect, test } from '@playwright/test';
import {
cleanupSeededAlbums,
flushSync,
makePublicToken,
seedAlbum,
seedBannerAsset,
seedCategory,
} from './helpers';
// Task 6 活动页 Playwright(真实后端:cn API 8080 + ov API 8081 由
// playwright.config webServer 拉起或复用已在跑的实例;种子走 Prisma/MinIO 直种
// cn 侧,再 flushSync 经真实管线投影到 ov——viewer 已切 ov 读,见 helpers.ts)。
// 语言前置:无头 Chromium 默认 en-US——用 addInitScript 预置 localStorage,避免
// 依赖浏览器语言;zh 断言由此确定性地落在 zh-CN UI。
test.afterAll(async () => {
await cleanupSeededAlbums();
});
test('renders album info in zh with timezone-adjusted time, chips and empty state', async ({ page }) => {
const album = await seedAlbum({
alias: 'main',
translations: [
{
locale: 'zh-CN',
title: '2026 公司年会',
description: '年度盛会',
venue: '大宴会厅',
address: '上海市浦东新区世纪大道 100 号',
},
{ locale: 'en', title: 'Annual Party 2026', description: 'Yearly gala' },
],
});
await seedCategory(album.id, 0, [
{ locale: 'zh-CN', name: '签到墙' },
{ locale: 'en', name: 'Check-in Wall' },
]);
await seedCategory(album.id, 1, [{ locale: 'zh-CN', name: '开场' }]);
await flushSync(album.id);
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
await page.goto(`/a/${album.publicToken}`);
await expect(page.locator('h1.title')).toHaveText('2026 公司年会');
await expect(page.locator('.description')).toHaveText('年度盛会');
// Asia/Shanghai:UTC 10-01 02:00/12:00 → 本地 10-01(不含 9/30 越日错误)。
const time = page.locator('.meta');
await expect(time).toContainText('10月1日');
await expect(time).not.toContainText('9月30');
await expect(page.locator('.meta')).toContainText('大宴会厅 · 上海市浦东新区世纪大道 100 号');
// Task 10 起排序 chips(最新/热门)与分类 chips 并存——断言收窄到分类栏。
const chips = page.locator('.categories .chip');
await expect(chips).toHaveCount(3); // Task 7 起:全部 + 各分类(可点选)
await expect(chips.first()).toBeEnabled();
await expect(chips).toHaveText(['全部', '签到墙', '开场']);
await expect(page.locator('.sort .chip')).toHaveText(['最新', '热门']);
await expect(page.locator('.empty-notice')).toHaveText('照片即将上线');
// 该相册无 banner 资产 → 不渲染图。
await expect(page.locator('img.banner')).toHaveCount(0);
await expect(page).toHaveTitle('2026 公司年会');
});
test('switching locale re-fetches and shows the English translation', async ({ page }) => {
const album = await seedAlbum({
alias: 'switch',
translations: [
{ locale: 'zh-CN', title: '双语测试相册', description: '中文介绍' },
{ locale: 'en', title: 'Bilingual Test Album', description: 'English intro', venue: 'Main Hall' },
],
});
await flushSync(album.id);
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
await page.goto(`/a/${album.publicToken}`);
await expect(page.locator('h1.title')).toHaveText('双语测试相册');
await page.locator('button.locale-switcher').click();
await expect(page.locator('h1.title')).toHaveText('Bilingual Test Album');
await expect(page.locator('.description')).toHaveText('English intro');
await expect(page.locator('.meta')).toContainText('Main Hall');
await expect(page.locator('.empty-notice')).toHaveText('Photos coming soon');
await expect(page).toHaveTitle('Bilingual Test Album');
});
test('en UI with a zh-only album falls back to zh content while chrome stays en', async ({ page }) => {
const album = await seedAlbum({
alias: 'fallback',
translations: [{ locale: 'zh-CN', title: '只有中文的相册', venue: '大宴会厅' }],
});
await flushSync(album.id);
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'en'));
await page.goto(`/a/${album.publicToken}`);
// 内容回退:resolvedLocale=zh-CN(服务端返回 zh 翻译)。
await expect(page.locator('h1.title')).toHaveText('只有中文的相册');
await expect(page.locator('.meta')).toContainText('大宴会厅');
// UI 语言仍为用户选择(en)。
await expect(page.locator('.empty-notice')).toHaveText('Photos coming soon');
});
test('banner signed URL loads a real image', async ({ page }) => {
const album = await seedAlbum({
alias: 'banner',
translations: [{ locale: 'zh-CN', title: '有封面的相册' }],
});
await seedBannerAsset(album.id);
await flushSync(album.id);
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
await page.goto(`/a/${album.publicToken}`);
const banner = page.locator('img.banner');
await expect(banner).toBeVisible();
// 签名 URL 可取回真实字节(加载完成 naturalWidth > 0;失败则保持 0)。
await expect
.poll(async () => banner.evaluate((img) => (img as HTMLImageElement).naturalWidth))
.toBeGreaterThan(0);
});
test('offline and ghost tokens show the localized unavailable error', async ({ page }) => {
const offline = await seedAlbum({
alias: 'offline',
status: 'offline',
translations: [{ locale: 'zh-CN', title: '已下线相册' }],
});
// 下线态也经真实管线同步:ov 侧 status=offline → isAlbumVisibleOv=false → 404。
await flushSync(offline.id);
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
await page.goto(`/a/${offline.publicToken}`);
await expect(page.locator('.error[role="alert"]')).toContainText('相册不存在或已下线');
await expect(page.locator('h1.title')).toHaveCount(0);
// 幽灵 token:同码同文案(防枚举)。
await page.goto(`/a/${makePublicToken()}`);
await expect(page.locator('.error[role="alert"]')).toContainText('相册不存在或已下线');
});