Files
photo-live/apps/viewer/e2e/viewer.spec.ts
T
2026-09-21 23:10:58 +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('.photo-viewer')).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('.position-indicator')).toHaveText('6 / 8');
// 滚轮缩放(无需 ctrl):transform scale > 1;双击复位。
const box = await page.locator('.photo-viewer').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('.viewer-close').click();
await expect(page.locator('.photo-viewer')).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('.photo-viewer')).toBeVisible();
await expect
.poll(() => browseNaturalWidth(page))
.toBeGreaterThan(0);
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(target));
await expect(page.locator('.position-indicator')).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('.viewer-close').click();
await expect(page.locator('.photo-viewer')).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('.photo-viewer')).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('.photo-viewer')).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('.position-indicator')).toHaveText('31 / 32');
// 背后列表同步扩展到 32 张。
await expect(page.locator('.cell')).toHaveCount(32);
});