Files
photo-live/apps/viewer/e2e/viewer.spec.ts
T

185 lines
7.8 KiB
TypeScript
Raw Normal View History

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);
});