Files
photo-live/apps/viewer/e2e/viewer.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

185 lines
7.8 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,
deleteSeededPhoto,
flushSync,
seedAlbum,
seedPhoto,
} from './helpers';
// Task 8 大图查看器 Playwright(真实后端):点击开大图(browse 经签名 URL 取回
// 真实字节)→ 左右切换保持列表序列 → 滚轮缩放(受控 transform)→ 关闭回原滚动
// 位置 → 深链直达(单图先行 + 背后列表构成序列)→ 404 深链本地化提示 → 越过
// 末页边界的导航扩展序列。种子直种 cn 侧后 flushSync 投影到 ov(viewer 读 ov)。
test.afterAll(async () => {
await cleanupSeededAlbums();
});
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 },
];
/** 种「查看器相册」:count 张已发布照片,publishedAt 逐张递减 60s(列表顺序确定)。
* 返回 token 与按列表顺序排列的 photoId。种子后 flushSync 投影到 ov。 */
async function seedViewerAlbum(
alias: string,
count: number,
): Promise<{ albumId: string; token: string; photoIds: string[] }> {
const album = await seedAlbum({
alias,
translations: [{ locale: 'zh-CN', title: '大图查看测试相册' }],
});
const photoIds: string[] = [];
const base = Date.now();
for (let i = 0; i < count; i++) {
const ratio = RATIOS[i % RATIOS.length]!;
photoIds.push(
await seedPhoto(album.id, `${alias}-p${i}`, {
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, photoIds };
}
async function openLocale(page: Page, token: string, path = ''): Promise<void> {
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
await page.goto(`/a/${token}${path}`);
}
/** 大图 <img> 的自然尺寸(签名 URL 取回真实字节则 > 0)。 */
async function browseNaturalWidth(page: Page): Promise<number> {
return page.locator('img.photo').evaluate((img) => (img as HTMLImageElement).naturalWidth);
}
test('open from feed, navigate in sequence, zoom, close restores scroll and url', async ({ page }) => {
test.setTimeout(90_000);
const { token, photoIds } = await seedViewerAlbum('viewer-open', 8);
await openLocale(page, token);
await expect(page.locator('.cell')).toHaveCount(8);
await page.evaluate(() => window.scrollTo(0, 600));
await page.waitForTimeout(100); // 等 scrollEnd 后的懒加载稳定
// 点击第 3 格 → 大图:browse 图取回真实字节,URL 换为深链。
await page.locator('.cell').nth(2).click();
await expect(page.locator('.lightbox')).toBeVisible();
await expect
.poll(() => browseNaturalWidth(page))
.toBeGreaterThan(0);
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[2]!));
expect(page.url()).toContain(`/a/${token}/p/${photoIds[2]}`);
// 记录开图后的滚动位置(关闭需精确回位)。
const scrollBefore = await page.evaluate(() => window.scrollY);
expect(scrollBefore).toBeGreaterThan(0);
// 右切 ×3:第 4/5/6 张依次换 src,URL 同步 replace。
for (const step of [3, 4, 5]) {
await page.keyboard.press('ArrowRight');
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[step]!), { timeout: 10_000 });
}
expect(page.url()).toContain(`/a/${token}/p/${photoIds[5]}`);
await expect(page.locator('.counter')).toHaveText('6 / 8');
// 滚轮缩放(无需 ctrl):transform scale > 1;双击复位。
const box = await page.locator('.lightbox').boundingBox();
expect(box).not.toBeNull();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await page.mouse.wheel(0, -360);
await page.mouse.wheel(0, -360);
const zoomedScale = await page.locator('img.photo').evaluate((img) => {
const matrix = new DOMMatrixReadOnly(getComputedStyle(img).transform);
return matrix.a;
});
expect(zoomedScale).toBeGreaterThan(1.2);
await page.locator('img.photo').dblclick();
await expect
.poll(async () =>
page.locator('img.photo').evaluate((img) => new DOMMatrixReadOnly(getComputedStyle(img).transform).a),
)
.toBeCloseTo(1, 1);
// 关闭:URL 弹回相册路由,滚动精确回位。
await page.locator('.control.close').click();
await expect(page.locator('.lightbox')).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(`/a/${token}/?$`));
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBe(scrollBefore);
});
test('deep link opens on the target photo with the list loaded behind', async ({ page }) => {
test.setTimeout(90_000);
const { token, photoIds } = await seedViewerAlbum('viewer-deep', 8);
const target = photoIds[4]!; // 第 5 张
await openLocale(page, token, `/p/${target}`);
await expect(page.locator('.lightbox')).toBeVisible();
await expect
.poll(() => browseNaturalWidth(page))
.toBeGreaterThan(0);
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(target));
await expect(page.locator('.counter')).toHaveText('5 / 8');
// 背后列表已构成序列(8 张)。
await expect(page.locator('.cell')).toHaveCount(8);
// 序列可切换:左切到第 4 张,URL 同步。
await page.keyboard.press('ArrowLeft');
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[3]!), { timeout: 10_000 });
expect(page.url()).toContain(`/a/${token}/p/${photoIds[3]}`);
// 关闭 → 回相册路由(深链直达无本会话 push:replace 语义)。
await page.locator('.control.close').click();
await expect(page.locator('.lightbox')).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(`/a/${token}/?$`));
});
test('deep link to a removed photo shows the localized notice and a working back link', async ({ page }) => {
test.setTimeout(90_000);
const { token, albumId, photoIds } = await seedViewerAlbum('viewer-404', 2);
const gone = photoIds[1]!;
// 模拟照片已下线(旧分享链接):软删 + flushSync 让墓碑走真实管线撤下 ov 副本。
await deleteSeededPhoto(gone);
await flushSync(albumId);
await openLocale(page, token, `/p/${gone}`);
const notice = page.locator('.deep-link-notice');
await expect(notice).toBeVisible();
await expect(notice).toContainText('照片不存在或已下线');
await expect(page.locator('.lightbox')).toHaveCount(0);
const back = notice.getByRole('link', { name: '返回相册' });
await back.click();
await expect(page).toHaveURL(new RegExp(`/a/${token}/?$`));
await expect(page.locator('.deep-link-notice')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '大图查看测试相册' })).toBeVisible();
});
test('navigating past the first page extends the sequence', async ({ page }) => {
test.setTimeout(120_000);
// 32 张:公开列表默认页 30 → 第 31/32 张需导航越过末尾时拉第 2 页。
const { token, photoIds } = await seedViewerAlbum('viewer-extend', 32);
await openLocale(page, token);
await expect(page.locator('.cell')).toHaveCount(30);
await page.locator('.cell').nth(29).click(); // 第 1 页最后一张
await expect(page.locator('.lightbox')).toBeVisible();
// 打开即临近末页边界:相邻预取已后台拉取第 2 页(设计行为),右切越过末尾
// 落在第 31 张(序列第 2 页),列表同步扩展。
await page.keyboard.press('ArrowRight');
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[30]!), { timeout: 15_000 });
await expect(page.locator('.counter')).toHaveText('31 / 32');
// 背后列表同步扩展到 32 张。
await expect(page.locator('.cell')).toHaveCount(32);
});