Files
photo-live/apps/viewer/e2e/feed.spec.ts
T
浅念 0fcf18cc5c feat(ov): public read api on replicas with hot sorting
P2 Task 6(ov 公开读 API + viewer 切换):
- ov 装配 PublicOvModule(仅 app-ov.module):/public/albums/{token}、
  /photos、/photos/{id} 三端点同契约读 ov 副本——translations/categories
  自 JSON 列解析(防御性过滤),banner 经 ov 侧 STORAGE_PORT_OV 预签名
  (同本地 MinIO;只读属约定,真实 OSS 只读凭证 P5),viewCount 取
  album_stats 权威累计(无行默认 0;计数端点 Task 8 接线)
- 排序:latest=发布时间 keyset(shared/cursor.ts 同 cn);hot=likeTotal
  desc + photoId desc 决胜,顺序按 `${albumId}|${category}` 短期快照
  (内存 TTL 默认 30s,HOT_SORT_TTL_MS 惰性读取)——计数逐请求现读、
  顺序冻结窗口内一致,偏移游标(base64url 整数)在快照内严格稳定;
  非法 sort 400 VALIDATION_FAILED(cn 不感知该参数)
- 契约:viewCount 放宽为 integer|null(cn null、ov 权威值);
  listPublicPhotos 增 sort 枚举(latest|hot,缺省 latest);
  PhotoPublishedPayload 增 categoryId——补上 Tasks 3/4 管线缺口
  (payloads/publish.service/sync-inbox 投影 + 关联测试随改)
- e2e(services/api/test/public-ov.e2e-spec.ts,7 用例):双入口同进程
  起 cn+ov,cn API 种子→真实发送管线投递;断言相册/列表/单图与 cn
  深等(仅签名 URL 剔除)、banner 真字节、参数矩阵 400/404、hot 排序
  TTL 内快照(计数新/顺序旧)+TTL 过期翻转+偏移翻页、隐藏/下线/紧急
  停用逐态撤下
- viewer 切 ov:API 基址 VITE_API_BASE(缺省 http://127.0.0.1:8081,
  空串=同源网关;Vite 只读 apps/viewer/.env*,根 .env 对前端无效),
  移除 /v1 dev 代理(CORS_ORIGINS 增 127.0.0.1:5175 直连两形);
  playwright webServer 增 dev:ov;e2e 夹具 flushSync(albumId)=cn
  bootstrap 快照 + deliverPendingOnce(sender 新导出)经真实管线投影
  种子,deleteSeededPhoto 改软删让墓碑可同步;14 个 Playwright 全绿
  (journey 管理/staffB 腿仍走 cn 8080)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 17:05:56 +08:00

142 lines
5.5 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, type Page } from '@playwright/test';
import {
cleanupSeededAlbums,
flushSync,
seedAlbum,
seedCategory,
seedPhoto,
} from './helpers';
// Task 7 瀑布流 Playwright(真实后端):35 张已发布照片(公开列表默认页 30)→
// 初始 30 + 滚动取第 2 页 5 张;分类 chips 过滤;新照片轮询提示(__viewerPollMs
// 注入缩短间隔,见 PhotoFeed pollIntervalMs 的 dev/test 覆盖钩子)。
// 种子直种 cn 侧,seedFeedAlbum/新增照片后 flushSync 投影到 ov(viewer 读 ov)。
test.afterAll(async () => {
await cleanupSeededAlbums();
});
/** 5 种宽高比例轮换(瀑布流高度预留的真实形状输入)。 */
const RATIOS: Array<{ width: number; height: number }> = [
{ width: 480, height: 300 },
{ width: 300, height: 480 },
{ width: 400, height: 400 },
{ width: 360, height: 480 },
{ width: 480, height: 270 },
];
const TOTAL = 35; // 30(第 1 页)+ 5(第 2 页)
/** 种「瀑布流相册」:2 分类 + 35 张已发布照片(前 20 张 catA、后 15 张 catB;
* publishedAt 逐张递减 60s 保证列表顺序确定)。 */
async function seedFeedAlbum(alias: string): Promise<{ albumId: string; token: string }> {
const album = await seedAlbum({
alias,
translations: [{ locale: 'zh-CN', title: '瀑布流测试相册' }],
});
const catA = await seedCategory(album.id, 0, [{ locale: 'zh-CN', name: '签到墙' }]);
const catB = await seedCategory(album.id, 1, [{ locale: 'zh-CN', name: '开场' }]);
const base = Date.now();
for (let i = 0; i < TOTAL; i++) {
const ratio = RATIOS[i % RATIOS.length]!;
await seedPhoto(album.id, `${alias}-p${i}`, {
categoryId: i < 20 ? catA : catB,
width: ratio.width,
height: ratio.height,
publishedAt: new Date(base - (i + 1) * 60_000),
});
}
await flushSync(album.id);
return { albumId: album.id, token: album.publicToken };
}
/** 反复滚到底直至游标耗尽(end-note 出现)或循环上限。 */
async function scrollToLoadAll(page: Page): Promise<void> {
for (let i = 0; i < 15; i++) {
if ((await page.locator('.end-note').count()) > 0) return;
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(400);
}
}
test('scroll loads both pages until exhausted and thumbs decode', async ({ page }) => {
test.setTimeout(90_000);
const { token } = await seedFeedAlbum('scroll');
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
await page.goto(`/a/${token}`);
// 第 1 页 30 张 + 哨兵在位(hasMore)。
await expect(page.locator('.cell')).toHaveCount(30);
await expect(page.locator('.sentinel')).toHaveCount(1);
// 懒加载:首屏缩略图经签名 URL 取回真实字节(解码成功 naturalWidth > 0)。
await expect
.poll(async () =>
page.locator('img.cell-img').first().evaluate((img) => (img as HTMLImageElement).naturalWidth),
)
.toBeGreaterThan(0);
await scrollToLoadAll(page);
// 第 2 页 5 张追加,游标耗尽 → 哨兵卸载、底部提示出现。
await expect(page.locator('.cell')).toHaveCount(35);
await expect(page.locator('.end-note')).toHaveText('已经到底了');
await expect(page.locator('.sentinel')).toHaveCount(0);
});
test('category chips filter the feed; All restores pagination', async ({ page }) => {
test.setTimeout(90_000);
const { token } = await seedFeedAlbum('filter');
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
await page.goto(`/a/${token}`);
await expect(page.locator('.cell')).toHaveCount(30);
await page.getByRole('button', { name: '签到墙' }).click();
await expect(page.locator('.cell')).toHaveCount(20);
await expect(page.locator('.end-note')).toBeVisible(); // 20 ≤ 30 单页即尽
await page.getByRole('button', { name: '开场' }).click();
await expect(page.locator('.cell')).toHaveCount(15);
await page.getByRole('button', { name: '全部' }).click();
await expect(page.locator('.cell')).toHaveCount(30);
await scrollToLoadAll(page);
await expect(page.locator('.cell')).toHaveCount(35);
});
test('new-photo poll hint appears; click refreshes to include new photos', async ({ page }) => {
test.setTimeout(90_000);
const { albumId, token } = await seedFeedAlbum('poll');
await page.addInitScript(() => {
localStorage.setItem('viewer.locale', 'zh-CN');
(window as unknown as { __viewerPollMs?: number }).__viewerPollMs = 500;
});
await page.goto(`/a/${token}`);
await expect(page.locator('.cell')).toHaveCount(30);
await scrollToLoadAll(page);
await expect(page.locator('.cell')).toHaveCount(35);
await expect(page.locator('.new-photo-bar')).toHaveCount(0);
// 轮询期间直种 2 张更新的照片(publishedAt 晚于全部既有)并同步到 ov。
const base = Date.now();
for (let i = 0; i < 2; i++) {
await seedPhoto(albumId, `poll-new-${i}`, {
categoryId: null,
width: 480,
height: 300,
publishedAt: new Date(base + (i + 1) * 60_000),
});
}
await flushSync(albumId);
// 提示条出现(不自动插入——仍 35 张)。
await expect(page.locator('.new-photo-bar')).toBeVisible({ timeout: 10_000 });
await expect(page.locator('.cell')).toHaveCount(35);
// 点击 → 整表刷新回第 1 页(含新照片),滚动后 37 张。
await page.locator('.new-photo-bar').click();
await expect(page.locator('.new-photo-bar')).toHaveCount(0);
await scrollToLoadAll(page);
await expect(page.locator('.cell')).toHaveCount(37);
});