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 { await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN')); await page.goto(`/a/${token}${path}`); } /** 大图 的自然尺寸(签名 URL 取回真实字节则 > 0)。 */ async function browseNaturalWidth(page: Page): Promise { 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); });