2a489aa16a
Co-Authored-By: Claude Code <noreply@anthropic.com>
185 lines
7.8 KiB
TypeScript
185 lines
7.8 KiB
TypeScript
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);
|
||
});
|