diff --git a/apps/viewer/e2e/helpers.ts b/apps/viewer/e2e/helpers.ts index d27d71e..61c6236 100644 --- a/apps/viewer/e2e/helpers.ts +++ b/apps/viewer/e2e/helpers.ts @@ -198,6 +198,13 @@ export async function seedPhoto( return photoId; } +/** 直删照片域行(Task 8 e2e:模拟已分享深链的照片下线——变体行在前、主行在后; + * MinIO 对象随 dev 前缀清理约定,不在此删)。 */ +export async function deleteSeededPhoto(photoId: string): Promise { + await prisma.photo_variants.deleteMany({ where: { photoId } }); + await prisma.photos.deleteMany({ where: { id: photoId } }); +} + /** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删; * category_translations 只挂 categoryId,先取分类 id 再删翻译;Task 7 起照片域 * 同理:先按相册取照片 id 删变体行,再删 photos 主行)。 diff --git a/apps/viewer/e2e/viewer.spec.ts b/apps/viewer/e2e/viewer.spec.ts new file mode 100644 index 0000000..9bc8023 --- /dev/null +++ b/apps/viewer/e2e/viewer.spec.ts @@ -0,0 +1,172 @@ +import { expect, test, type Page } from '@playwright/test'; +import { cleanupSeededAlbums, deleteSeededPhoto, seedAlbum, seedPhoto } from './helpers'; + +// Task 8 大图查看器 Playwright(真实后端):点击开大图(browse 经签名 URL 取回 +// 真实字节)→ 左右切换保持列表序列 → 滚轮缩放(受控 transform)→ 关闭回原滚动 +// 位置 → 深链直达(单图先行 + 背后列表构成序列)→ 404 深链本地化提示 → 越过 +// 末页边界的导航扩展序列。 + +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。 */ +async function seedViewerAlbum(alias: string, count: number): Promise<{ 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), + }), + ); + } + return { 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('.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, photoIds } = await seedViewerAlbum('viewer-404', 2); + const gone = photoIds[1]!; + await deleteSeededPhoto(gone); // 模拟照片已下线(旧分享链接) + + 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); +}); diff --git a/apps/viewer/src/__tests__/feed.spec.ts b/apps/viewer/src/__tests__/feed.spec.ts index 1f9a8b0..8b647b5 100644 --- a/apps/viewer/src/__tests__/feed.spec.ts +++ b/apps/viewer/src/__tests__/feed.spec.ts @@ -1,4 +1,5 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; +import { computed, defineComponent } from 'vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError, @@ -9,10 +10,13 @@ import { } from '../api/client'; import { createViewerI18n, type ViewerI18n } from '../i18n'; import PhotoFeed from '../components/PhotoFeed.vue'; +import { usePhotoSequence, type PhotoSequence } from '../feed/sequence'; -// PhotoFeed(Task 7)组件测试:api client 以真实模块为底、替身 getPublicPhotos / -// getPublicPhoto;IntersectionObserver / ResizeObserver 以可控假件替换(jsdom 无实现), -// clientWidth 以原型 getter mock(jsdom 恒 0)。布局纯函数已在 feed-layout.spec 覆盖。 +// PhotoFeed(Task 7;Task 8 起数据层在 PhotoSequence)组件测试:api client 以真实 +// 模块为底、替身 getPublicPhotos / getPublicPhoto;IntersectionObserver / +// ResizeObserver 以可控假件替换(jsdom 无实现),clientWidth 以原型 getter mock +// (jsdom 恒 0)。序列数据/轮询已在 sequence.spec.ts 覆盖;布局纯函数在 +// feed-layout.spec。宿主组件持 sequence(对齐 ActivityPage 的真实组装方式)。 vi.mock('../api/client', async (importOriginal) => { const actual = await importOriginal(); @@ -109,20 +113,32 @@ function makeList(items: PublicPhoto[], nextCursor: string | null = null): Publi let i18n: ViewerI18n; const mounted: VueWrapper[] = []; -function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): VueWrapper { - const wrapper = mount(PhotoFeed, { - global: { plugins: [i18n] }, - props: { - token: props.token ?? 'tok-feed', - locale: props.locale ?? 'zh-CN', - category: props.category ?? null, +/** 宿主:创建 sequence 并渲染 PhotoFeed(镜像 ActivityPage 组装);捕获 open 事件。 */ +function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): { + wrapper: VueWrapper; + sequence: PhotoSequence; + opened: string[]; +} { + const opened: string[] = []; + const Host = defineComponent({ + components: { PhotoFeed }, + setup() { + const sequence = usePhotoSequence({ + token: computed(() => props.token ?? 'tok-feed'), + locale: computed(() => props.locale ?? 'zh-CN'), + category: computed(() => props.category ?? null), + }); + return { sequence, onOpen: (photoId: string) => opened.push(photoId) }; }, + template: ``, }); + const wrapper = mount(Host, { global: { plugins: [i18n] } }); mounted.push(wrapper); - return wrapper; + const sequence = (wrapper.vm as unknown as { sequence: PhotoSequence }).sequence; + return { wrapper, sequence, opened }; } -describe('photo feed (Task 7)', () => { +describe('photo feed (Task 7 / Task 8 sequence props)', () => { beforeEach(() => { localStorage.clear(); i18n = createViewerI18n('zh-CN'); @@ -144,11 +160,10 @@ describe('photo feed (Task 7)', () => { } vi.unstubAllGlobals(); vi.restoreAllMocks(); - vi.useRealTimers(); }); it('renders first page as absolute-positioned cells with reserved heights', async () => { - const wrapper = mountFeed(); + const { wrapper } = mountFeed(); await flushPromises(); expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-feed', { locale: 'zh-CN' }); @@ -161,8 +176,16 @@ describe('photo feed (Task 7)', () => { expect(cells[2].attributes('style')).toContain('top: 224px'); }); + it('emits open with the photoId when a cell is clicked', async () => { + const { wrapper, opened } = mountFeed(); + await flushPromises(); + + await wrapper.findAll('.cell')[1].trigger('click'); + expect(opened).toEqual(['p2']); + }); + it('binds img src only after the cell approaches the viewport (lazy swap)', async () => { - const wrapper = mountFeed(); + const { wrapper } = mountFeed(); await flushPromises(); expect(wrapper.findAll('img.cell-img')).toHaveLength(0); @@ -177,7 +200,7 @@ describe('photo feed (Task 7)', () => { }); it('loads the next page when the sentinel intersects, dedupes and stops at exhaustion', async () => { - const wrapper = mountFeed(); + const { wrapper } = mountFeed(); await flushPromises(); const sentinel = wrapper.find('.sentinel'); @@ -199,59 +222,9 @@ describe('photo feed (Task 7)', () => { expect(wrapper.find('.new-photo-bar').exists()).toBe(false); }); - it('does not fire a second page fetch while one is in flight', async () => { - const wrapper = mountFeed(); - await flushPromises(); - mockedGetPublicPhotos.mockClear(); - - let resolvePage!: (value: PublicPhotoList) => void; - mockedGetPublicPhotos.mockReturnValueOnce( - new Promise((resolve) => { - resolvePage = resolve; - }), - ); - const sentinel = wrapper.find('.sentinel'); - sentinelObserver().trigger([sentinel.element], true); - sentinelObserver().trigger([sentinel.element], true); - await flushPromises(); - expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(1); - - resolvePage(makeList([makePhoto('p4')], null)); - await flushPromises(); - expect(wrapper.findAll('.cell')).toHaveLength(4); - }); - - it('switching category resets the list and passes the filter through', async () => { - const wrapper = mountFeed(); - await flushPromises(); - mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('c1p1'), makePhoto('c1p2')], null)); - - await wrapper.setProps({ category: 'cat-1' }); - await flushPromises(); - - expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith( - 'tok-feed', - expect.objectContaining({ category: 'cat-1', locale: 'zh-CN' }), - ); - expect(wrapper.findAll('.cell')).toHaveLength(2); - }); - - it('locale switch re-fetches the current category (filter preserved)', async () => { - const wrapper = mountFeed({ category: 'cat-1' }); - await flushPromises(); - - await wrapper.setProps({ locale: 'en' }); - await flushPromises(); - - expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith( - 'tok-feed', - expect.objectContaining({ locale: 'en', category: 'cat-1' }), - ); - }); - it('renders the localized empty state when the album has no photos', async () => { mockedGetPublicPhotos.mockResolvedValueOnce(makeList([])); - const wrapper = mountFeed(); + const { wrapper } = mountFeed(); await flushPromises(); expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线'); @@ -262,7 +235,7 @@ describe('photo feed (Task 7)', () => { mockedGetPublicPhotos .mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network down', 0, null)) .mockResolvedValueOnce(makeList([makePhoto('p1')], null)); - const wrapper = mountFeed(); + const { wrapper } = mountFeed(); await flushPromises(); expect(wrapper.find('.error[role="alert"]').text()).toContain('网络连接失败'); @@ -274,7 +247,7 @@ describe('photo feed (Task 7)', () => { }); it('retries a failed thumb once with a refreshed signed URL, then hides the img', async () => { - const wrapper = mountFeed(); + const { wrapper } = mountFeed(); await flushPromises(); lazyObserver().trigger(wrapper.findAll('.cell').map((cell) => cell.element), true); await vi.waitFor(() => expect(wrapper.findAll('img.cell-img')).toHaveLength(3)); @@ -298,83 +271,3 @@ describe('photo feed (Task 7)', () => { expect(mockedGetPublicPhoto).toHaveBeenCalledTimes(1); }); }); - -describe('photo feed polling (Task 7)', () => { - beforeEach(() => { - localStorage.clear(); - i18n = createViewerI18n('zh-CN'); - FakeIntersectionObserver.instances = []; - vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); - vi.stubGlobal('ResizeObserver', FakeResizeObserver); - const proto = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'clientWidth') - ? HTMLElement.prototype - : Element.prototype; - vi.spyOn(proto, 'clientWidth', 'get').mockReturnValue(700); - mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null)); - mockedGetPublicPhoto.mockReset(); - vi.useFakeTimers(); - }); - - afterEach(() => { - while (mounted.length > 0) { - mounted.pop()?.unmount(); - } - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - it('poll detects new photos, shows the hint bar; click refreshes to page 1, clears the hint and scrolls top', async () => { - const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); - const wrapper = mountFeed(); - await flushPromises(); - expect(wrapper.find('.new-photo-bar').exists()).toBe(false); - - // 轮询取到的第 1 页含未知 id p9 → 只亮提示条,不自动插入。 - mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2')], null)); - vi.advanceTimersByTime(30_000); - await flushPromises(); - expect(wrapper.find('.new-photo-bar').text()).toBe('有新照片,点击查看'); - expect(wrapper.findAll('.cell')).toHaveLength(2); - - // 点击提示条:整表刷新(第 1 页含新照片)并回顶。 - mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2')], null)); - await wrapper.find('.new-photo-bar').trigger('click'); - await flushPromises(); - - expect(wrapper.findAll('.cell')).toHaveLength(3); - expect(wrapper.find('.new-photo-bar').exists()).toBe(false); - expect(scrollTo).toHaveBeenCalledWith(0, 0); - }); - - it('keeps polling silent on failure (no error state, no hint)', async () => { - const wrapper = mountFeed(); - await flushPromises(); - - mockedGetPublicPhotos.mockRejectedValueOnce(new ApiError('INTERNAL_ERROR', 'boom', 500, 'req-1')); - vi.advanceTimersByTime(30_000); - await flushPromises(); - - expect(wrapper.find('.error').exists()).toBe(false); - expect(wrapper.find('.new-photo-bar').exists()).toBe(false); - expect(wrapper.findAll('.cell')).toHaveLength(2); - }); - - it('pauses polling while the tab is hidden and resumes when visible again', async () => { - mountFeed(); - await flushPromises(); - mockedGetPublicPhotos.mockClear(); - - const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true); - document.dispatchEvent(new Event('visibilitychange')); - vi.advanceTimersByTime(120_000); - await flushPromises(); - expect(mockedGetPublicPhotos).not.toHaveBeenCalled(); - - hidden.mockReturnValue(false); - document.dispatchEvent(new Event('visibilitychange')); - vi.advanceTimersByTime(30_000); - await flushPromises(); - expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/viewer/src/__tests__/lightbox.spec.ts b/apps/viewer/src/__tests__/lightbox.spec.ts new file mode 100644 index 0000000..5b87d77 --- /dev/null +++ b/apps/viewer/src/__tests__/lightbox.spec.ts @@ -0,0 +1,299 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; +import { defineComponent, nextTick, ref } from 'vue'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ApiError, + getPublicPhoto, + getPublicPhotos, + type PublicPhoto, + type PublicPhotoList, +} from '../api/client'; +import { createViewerI18n, type ViewerI18n } from '../i18n'; +import PhotoLightbox from '../components/PhotoLightbox.vue'; +import { usePhotoSequence, type PhotoSequence } from '../feed/sequence'; + +// PhotoLightbox(Task 8)组件测试:browse 渲染/导航/序列扩展、wheel 缩放与双击 +// 复位、Esc/背景/按钮关闭、指针轻扫切换、签名过期静默重取与 404 移除上报。 +// 深链解析与 URL 同步在 viewer-route.spec(真实路由)覆盖。 + +vi.mock('../api/client', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getPublicPhotos: vi.fn(), getPublicPhoto: vi.fn() }; +}); + +const mockedGetPublicPhotos = vi.mocked(getPublicPhotos); +const mockedGetPublicPhoto = vi.mocked(getPublicPhoto); + +function makePhoto(id: string, likeCount = 0): PublicPhoto { + return { + photoId: id, + width: 800, + height: 500, + thumb: { url: `http://img.test/${id}-thumb.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' }, + browse: { url: `http://img.test/${id}-browse.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' }, + likeCount, + publishedAt: '2026-10-01T00:00:00.000Z', + }; +} + +function makeList(items: PublicPhoto[], nextCursor: string | null = null): PublicPhotoList { + return { items, nextCursor, hasMore: nextCursor !== null }; +} + +let i18n: ViewerI18n; +const mounted: VueWrapper[] = []; + +/** 宿主:sequence + v-model photoId(镜像 ActivityPage 组装)。页面数据经 + * mockedGetPublicPhotos 的当前 mock 队列供给(与真实时序一致:挂载后到达)。 */ +function mountLightbox(photoId: string): VueWrapper { + const Host = defineComponent({ + components: { PhotoLightbox }, + setup() { + const sequence = usePhotoSequence({ + token: ref('tok-lb'), + locale: ref('zh-CN'), + category: ref(null), + }); + const current = ref(photoId); + return { sequence, photoId: current }; + }, + template: ``, + }); + const wrapper = mount(Host, { global: { plugins: [i18n] } }); + mounted.push(wrapper); + return wrapper; +} + +function lightbox(wrapper: VueWrapper): VueWrapper> { + return wrapper.getComponent(PhotoLightbox) as unknown as VueWrapper>; +} + +function img(wrapper: VueWrapper) { + return wrapper.find('img.photo'); +} + +/** jsdom 无 PointerEvent 构造器:MouseEvent 补 pointerId/pointerType 字段。 */ +function pointerEvent( + type: 'pointerdown' | 'pointermove' | 'pointerup', + init: { pointerId?: number; clientX?: number; clientY?: number; pointerType?: string }, +): PointerEvent { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0, + }); + return Object.assign(event, { + pointerId: init.pointerId ?? 1, + pointerType: init.pointerType ?? 'touch', + }) as unknown as PointerEvent; +} + +function keydown(key: string): void { + window.dispatchEvent(new KeyboardEvent('keydown', { key, cancelable: true })); +} + +/** test-utils 的 trigger 无法写 MouseEvent 只读坐标:直接派生 WheelEvent。 */ +function wheel(wrapper: VueWrapper, deltaY: number, clientX = 100, clientY = 80): void { + wrapper.find('.lightbox').element.dispatchEvent( + new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY, clientX, clientY }), + ); +} + +describe('photo lightbox (Task 8)', () => { + beforeEach(() => { + localStorage.clear(); + i18n = createViewerI18n('zh-CN'); + mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')])); + mockedGetPublicPhoto.mockReset(); + }); + + afterEach(() => { + while (mounted.length > 0) { + mounted.pop()?.unmount(); + } + vi.restoreAllMocks(); + }); + + it('renders the current browse url with counter and like count', async () => { + mockedGetPublicPhotos.mockResolvedValue( + makeList([makePhoto('p1'), makePhoto('p2', 7), makePhoto('p3')]), + ); + const wrapper = mountLightbox('p2'); + await flushPromises(); + + expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=1'); + expect(wrapper.find('.counter').text()).toBe('2 / 3'); + expect(wrapper.find('.likes').text()).toBe('7 次点赞'); + }); + + it('navigates with buttons and arrow keys, keeping the list order', async () => { + const wrapper = mountLightbox('p2'); + await flushPromises(); + + await wrapper.find('.nav.next').trigger('click'); + expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]); + + keydown('ArrowLeft'); + await flushPromises(); + expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3'], ['p2']]); + + keydown('ArrowLeft'); + await flushPromises(); + expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3'], ['p2'], ['p1']]); + + // 首张:previous 按钮禁用,再按左键不再发事件;末张同理(无更多页)。 + expect(wrapper.find('.nav.prev').attributes('disabled')).toBeDefined(); + keydown('ArrowLeft'); + await flushPromises(); + expect(lightbox(wrapper).emitted('update:photoId')).toHaveLength(3); + expect(wrapper.find('.nav.next').attributes('disabled')).toBeUndefined(); + }); + + it('closes on Esc, close button and backdrop click', async () => { + const wrapper = mountLightbox('p2'); + await flushPromises(); + + keydown('Escape'); + expect(lightbox(wrapper).emitted('close')).toHaveLength(1); + + await wrapper.find('.control.close').trigger('click'); + expect(lightbox(wrapper).emitted('close')).toHaveLength(2); + + await wrapper.find('.backdrop').trigger('click'); + expect(lightbox(wrapper).emitted('close')).toHaveLength(3); + }); + + it('zooms with wheel around the cursor and clamps to [1, 4]; dblclick resets', async () => { + const wrapper = mountLightbox('p2'); + await flushPromises(); + + // jsdom 下容器 rect 为 0 → 锚点 = 事件坐标(100, 80)。 + wheel(wrapper, -240); + await nextTick(); + let style = img(wrapper).attributes('style') ?? ''; + expect(style).toContain('scale('); + const zoomedScale = Number(/scale\(([\d.]+)\)/.exec(style)?.[1]); + expect(zoomedScale).toBeGreaterThan(1); + // 不动点:锚点屏幕位置不变(translate 补偿)。 + const zoomedTx = Number(/translate\((-?[\d.]+)px/.exec(style)?.[1]); + expect(100 * zoomedScale + zoomedTx).toBeCloseTo(100, 5); + + // 连续放大钳制到 4。 + for (let i = 0; i < 8; i++) wheel(wrapper, -600); + await nextTick(); + style = img(wrapper).attributes('style') ?? ''; + expect(Number(/scale\(([\d.]+)\)/.exec(style)?.[1])).toBe(4); + + // 缩到底回到 1 且平移归零(复位对齐)。 + for (let i = 0; i < 12; i++) wheel(wrapper, 600); + await nextTick(); + style = img(wrapper).attributes('style') ?? ''; + expect(style).toContain('translate(0px, 0px) scale(1)'); + + // 双击复位。 + wheel(wrapper, -240); + await nextTick(); + await img(wrapper).trigger('dblclick'); + expect(img(wrapper).attributes('style')).toContain('scale(1)'); + }); + + it('swipe navigates when flat; drag on backdrop does not close', async () => { + const wrapper = mountLightbox('p2'); + await flushPromises(); + const root = wrapper.find('.lightbox'); + + // 左滑 80px → 下一张。 + root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 })); + root.element.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 150, clientY: 150 })); + root.element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1, clientX: 120, clientY: 150 })); + await flushPromises(); + expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]); + + // 拖拽距离超过阈值后收尾在背景上:click 不关闭。 + root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 })); + root.element.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 100, clientY: 160 })); + root.element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1, clientX: 100, clientY: 160 })); + await wrapper.find('.backdrop').trigger('click'); + expect(lightbox(wrapper).emitted('close')).toBeUndefined(); + }); + + it('extends the sequence when navigating past the end with more pages', async () => { + let resolvePage!: (value: PublicPhotoList) => void; + mockedGetPublicPhotos.mockReset(); + mockedGetPublicPhotos + .mockResolvedValueOnce(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1')) + .mockReturnValueOnce( + new Promise((resolve) => { + resolvePage = resolve; + }), + ); + const wrapper = mountLightbox('p2'); + await flushPromises(); + + // 临近末尾的预取已在途(受控 promise 挂起)→ 右切越过末尾与预取共用同一请求。 + await wrapper.find('.nav.next').trigger('click'); // → p3(预取仍未完成) + expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]); + + await wrapper.find('.nav.next').trigger('click'); // 越过末尾:等待扩展 + expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]); // 未落地前不切换 + expect(wrapper.find('.loading').exists()).toBe(true); + + resolvePage(makeList([makePhoto('p4')], null)); + await flushPromises(); + expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3'], ['p4']]); + expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(2); + }); + + it('silently refreshes an expired signed url on img error', async () => { + const wrapper = mountLightbox('p2'); + await flushPromises(); + mockedGetPublicPhoto.mockResolvedValue({ + ...makePhoto('p2'), + categoryId: null, + browse: { url: 'http://img.test/p2-browse.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' }, + }); + + await img(wrapper).trigger('error'); + await flushPromises(); + + expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-lb', 'p2'); + expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=2'); + }); + + it('reports removal with the pre-removal index when the photo is gone (404)', async () => { + const wrapper = mountLightbox('p2'); + await flushPromises(); + mockedGetPublicPhoto.mockRejectedValue(new ApiError('PHOTO_NOT_PUBLIC', 'gone', 404, 'req-1')); + + await img(wrapper).trigger('error'); + await flushPromises(); + + expect(lightbox(wrapper).emitted('removed')).toEqual([['p2', 1]]); + expect((wrapper.vm as unknown as { sequence: PhotoSequence }).sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p3']); + }); + + it('shows a retryable failure notice for non-404 refresh failures', async () => { + const wrapper = mountLightbox('p2'); + await flushPromises(); + mockedGetPublicPhoto.mockRejectedValue(new ApiError('NETWORK_ERROR', 'down', 0, null)); + + await img(wrapper).trigger('error'); + await flushPromises(); + expect(wrapper.find('.img-failed[role="alert"]').text()).toContain('图片加载失败'); + + // 重试:清标记并强制 重挂载(key 变化),错误链可再次触发。 + mockedGetPublicPhoto.mockResolvedValue({ + ...makePhoto('p2'), + categoryId: null, + browse: { url: 'http://img.test/p2-browse.jpg?s=3', expiresAt: '2026-10-01T02:00:00.000Z' }, + }); + await wrapper.find('.img-failed button').trigger('click'); + await nextTick(); + expect(wrapper.find('.img-failed').exists()).toBe(false); // 占位收起,img 回到加载中 + expect(img(wrapper).exists()).toBe(true); + await img(wrapper).trigger('error'); + await flushPromises(); + expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=3'); + }); +}); diff --git a/apps/viewer/src/__tests__/routing.spec.ts b/apps/viewer/src/__tests__/routing.spec.ts index b62805d..691b92d 100644 --- a/apps/viewer/src/__tests__/routing.spec.ts +++ b/apps/viewer/src/__tests__/routing.spec.ts @@ -1,24 +1,27 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from '../App.vue'; -import { getPublicAlbum } from '../api/client'; +import { getPublicAlbum, getPublicPhoto } from '../api/client'; import { createViewerI18n } from '../i18n'; import { router } from '../router'; import { makeAlbum } from './fixtures'; // 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经 -// api mock 断言——活动页以 token 取数,photoId 供 Task 8 深链定位(不影响取数)。 -// getPublicPhotos 一并替身为空页(Task 7 起活动页内嵌 PhotoFeed)。 +// api mock 断言——活动页以 token 取数,photoId 驱动 Task 8 深链(单图先行 + 序列 +// 兜底)。getPublicPhotos / getPublicPhoto 一并替身(Task 7 起活动页内嵌 PhotoFeed, +// Task 8 起深链取单图)。 vi.mock('../api/client', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getPublicAlbum: vi.fn(), getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }), + getPublicPhoto: vi.fn(), }; }); const mockedGetPublicAlbum = vi.mocked(getPublicAlbum); +const mockedGetPublicPhoto = vi.mocked(getPublicPhoto); // 逐挂载独立 i18n + 逐用例卸载(背景见 activity.spec.ts 头注)。 let i18n: ReturnType; @@ -49,6 +52,16 @@ describe('viewer routing', () => { }); it('deep link resolves token and photoId params on the activity page', async () => { + mockedGetPublicPhoto.mockResolvedValue({ + photoId: 'photo-42', + width: 800, + height: 500, + thumb: { url: 'http://img.test/photo-42-thumb.jpg?s=1', expiresAt: '2026-10-01T00:00:00.000Z' }, + browse: { url: 'http://img.test/photo-42-browse.jpg?s=1', expiresAt: '2026-10-01T00:00:00.000Z' }, + likeCount: 0, + publishedAt: '2026-10-01T00:00:00.000Z', + categoryId: null, + }); await router.push('/a/tok-abc/p/photo-42'); await router.isReady(); const wrapper = mount(App, { global: { plugins: [router, i18n] } }); @@ -56,8 +69,10 @@ describe('viewer routing', () => { await flushPromises(); expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-abc', 'zh-CN'); + expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-abc', 'photo-42'); expect(wrapper.text()).toContain('2026 公司年会'); - // photoId 仅用于 Task 8 大图定位,不进入信息区文案。 + // 深链以大图呈现(solo 序列),id 本身不进入文案。 + expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/photo-42-browse.jpg?s=1'); expect(wrapper.text()).not.toContain('photo-42'); }); }); diff --git a/apps/viewer/src/__tests__/sequence.spec.ts b/apps/viewer/src/__tests__/sequence.spec.ts new file mode 100644 index 0000000..a96e0af --- /dev/null +++ b/apps/viewer/src/__tests__/sequence.spec.ts @@ -0,0 +1,285 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; +import { defineComponent, ref } from 'vue'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ApiError, + getPublicPhoto, + getPublicPhotos, + type PublicPhoto, + type PublicPhotoList, +} from '../api/client'; +import { usePhotoSequence, type PhotoSequence } from '../feed/sequence'; + +// PhotoSequence(Task 8 数据层):分页追加去重、深链 solo 条目(就位/自然归位/ +// 越上限保留)、单图签名刷新(换绑/404 移除)与轮询提示。jsdom 下 IO/RO 无关 +// (本层无 DOM 观察者);轮询用假时钟驱动。 + +vi.mock('../api/client', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getPublicPhotos: vi.fn(), getPublicPhoto: vi.fn() }; +}); + +const mockedGetPublicPhotos = vi.mocked(getPublicPhotos); +const mockedGetPublicPhoto = vi.mocked(getPublicPhoto); + +function makePhoto(id: string, width = 800, height = 500): PublicPhoto { + return { + photoId: id, + width, + height, + thumb: { url: `http://img.test/${id}-thumb.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' }, + browse: { url: `http://img.test/${id}-browse.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' }, + likeCount: 0, + publishedAt: '2026-10-01T00:00:00.000Z', + }; +} + +function makeList(items: PublicPhoto[], nextCursor: string | null = null): PublicPhotoList { + return { items, nextCursor, hasMore: nextCursor !== null }; +} + +/** 挂载一个仅创建 sequence 的宿主组件(composable 的 onMounted/watch 需组件上下文)。 */ +function mountSequence(options: { token?: string; category?: string | null } = {}): { + wrapper: VueWrapper; + sequence: PhotoSequence; +} { + let sequence!: PhotoSequence; + const Host = defineComponent({ + setup() { + sequence = usePhotoSequence({ + token: ref(options.token ?? 'tok-seq'), + locale: ref('zh-CN'), + category: ref(options.category ?? null), + }); + return () => null; + }, + }); + const wrapper = mount(Host); + return { wrapper, sequence }; +} + +describe('photo sequence pagination (Task 8)', () => { + beforeEach(() => { + mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1')); + mockedGetPublicPhoto.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('loads the first page and exposes cursor state', async () => { + const { wrapper, sequence } = mountSequence(); + expect(sequence.initialLoading).toBe(true); + await flushPromises(); + + expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-seq', { locale: 'zh-CN' }); + expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2']); + expect(sequence.initialLoading).toBe(false); + expect(sequence.hasMore).toBe(true); + expect(sequence.pagesLoaded).toBe(1); + wrapper.unmount(); + }); + + it('appends the next page with dedupe and stops at cursor exhaustion', async () => { + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + // 第 2 页带回重复 p2(游标边界漂移防御)→ 仅追加新 id。 + mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p2'), makePhoto('p3')], null)); + + await sequence.loadMore(); + await flushPromises(); + + expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-seq', expect.objectContaining({ cursor: 'cur-1' })); + expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p3']); + expect(sequence.hasMore).toBe(false); + expect(sequence.pagesLoaded).toBe(2); + + // 游标耗尽后再 loadMore 为无操作(大图导航越过末尾的安全边界)。 + await sequence.loadMore(); + expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(2); + wrapper.unmount(); + }); + + it('dedupes against an in-flight loadMore (single fetch while pending)', async () => { + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + + let resolvePage!: (value: PublicPhotoList) => void; + mockedGetPublicPhotos.mockReturnValueOnce( + new Promise((resolve) => { + resolvePage = resolve; + }), + ); + void sequence.loadMore(); + void sequence.loadMore(); + await flushPromises(); + expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(2); + + resolvePage(makeList([makePhoto('p9')], null)); + await flushPromises(); + expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p9']); + wrapper.unmount(); + }); +}); + +describe('photo sequence deep-link solo entries (Task 8)', () => { + beforeEach(() => { + mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null)); + mockedGetPublicPhoto.mockReset(); + }); + + it('ensureInSequence returns the existing index without duplicating', async () => { + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + + expect(sequence.ensureInSequence(makePhoto('p2'))).toBe(1); + expect(sequence.photos).toHaveLength(2); + expect(sequence.isSolo('p2')).toBe(false); + wrapper.unmount(); + }); + + it('appends an unknown photo as a solo entry navigable at the tail', async () => { + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + + const index = sequence.ensureInSequence(makePhoto('p9')); + expect(index).toBe(2); + expect(sequence.isSolo('p9')).toBe(true); + expect(sequence.photos[2]?.photoId).toBe('p9'); + wrapper.unmount(); + }); + + it('replaces the solo copy with its natural position when a later page brings it', async () => { + mockedGetPublicPhotos.mockReset(); + mockedGetPublicPhotos + .mockResolvedValueOnce(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1')) + .mockResolvedValueOnce(makeList([makePhoto('p4'), makePhoto('p9')], null)); + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + sequence.ensureInSequence(makePhoto('p9')); // 深链目标不在第 1 页 + + // 相邻页把 p4 与 p9 带回:solo 副本被自然位置副本取代,顺序纠正。 + await sequence.loadMore(); + await flushPromises(); + + expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p4', 'p9']); + expect(sequence.isSolo('p9')).toBe(false); + expect(sequence.indexOf('p9')).toBe(3); + wrapper.unmount(); + }); + + it('keeps surviving solos across a full reset, and drops ones covered by the page', async () => { + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + sequence.ensureInSequence(makePhoto('p9')); // 保留 + sequence.ensureInSequence(makePhoto('p2')); // 第 1 页含 p2 → 自然副本接管 + + mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p2'), makePhoto('p1')], null)); + await sequence.resetAndLoad(); + await flushPromises(); + + expect(sequence.photos.map((p) => p.photoId)).toEqual(['p2', 'p1', 'p9']); + expect(sequence.isSolo('p9')).toBe(true); + expect(sequence.isSolo('p2')).toBe(false); + wrapper.unmount(); + }); + + it('removes a photo from the sequence (404 removal path)', async () => { + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + + sequence.removePhoto('p1'); + expect(sequence.photos.map((p) => p.photoId)).toEqual(['p2']); + expect(sequence.indexOf('p1')).toBe(-1); + wrapper.unmount(); + }); +}); + +describe('photo sequence url refresh (Task 8)', () => { + beforeEach(() => { + mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null)); + mockedGetPublicPhoto.mockReset(); + }); + + it('replaces signed urls in place on success', async () => { + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + mockedGetPublicPhoto.mockResolvedValue({ + ...makePhoto('p1'), + categoryId: null, + thumb: { url: 'http://img.test/p1-thumb.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' }, + browse: { url: 'http://img.test/p1-browse.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' }, + }); + + expect(await sequence.refreshPhotoUrls('p1')).toBe('replaced'); + expect(sequence.photos[0]?.browse.url).toBe('http://img.test/p1-browse.jpg?s=2'); + expect(sequence.photos[0]?.thumb.url).toBe('http://img.test/p1-thumb.jpg?s=2'); + wrapper.unmount(); + }); + + it('removes the photo on PHOTO_NOT_PUBLIC', async () => { + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + mockedGetPublicPhoto.mockRejectedValue(new ApiError('PHOTO_NOT_PUBLIC', 'gone', 404, 'req-1')); + + expect(await sequence.refreshPhotoUrls('p1')).toBe('removed'); + expect(sequence.photos.map((p) => p.photoId)).toEqual(['p2']); + wrapper.unmount(); + }); + + it('keeps the photo on transient failures', async () => { + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + mockedGetPublicPhoto.mockRejectedValue(new ApiError('NETWORK_ERROR', 'down', 0, null)); + + expect(await sequence.refreshPhotoUrls('p1')).toBe('failed'); + expect(sequence.photos).toHaveLength(2); + wrapper.unmount(); + }); +}); + +describe('photo sequence polling (Task 8)', () => { + beforeEach(() => { + mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null)); + mockedGetPublicPhoto.mockReset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('raises the hint when page 1 contains an unknown id; refresh scrolls top', async () => { + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + expect(sequence.hasNew).toBe(false); + + mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1')], null)); + vi.advanceTimersByTime(30_000); + await flushPromises(); + expect(sequence.hasNew).toBe(true); + expect(sequence.photos).toHaveLength(2); // 只亮提示不自动插入 + + mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2')], null)); + await sequence.refreshFromHint(); + await flushPromises(); + expect(sequence.hasNew).toBe(false); + expect(sequence.photos.map((p) => p.photoId)).toEqual(['p9', 'p1', 'p2']); + expect(scrollTo).toHaveBeenCalledWith(0, 0); + wrapper.unmount(); + }); + + it('stays silent when a poll fails', async () => { + const { wrapper, sequence } = mountSequence(); + await flushPromises(); + + mockedGetPublicPhotos.mockRejectedValueOnce(new ApiError('INTERNAL_ERROR', 'boom', 500, 'req-1')); + vi.advanceTimersByTime(30_000); + await flushPromises(); + expect(sequence.hasNew).toBe(false); + expect(sequence.initialErrorKey).toBe(null); + wrapper.unmount(); + }); +}); diff --git a/apps/viewer/src/__tests__/viewer-route.spec.ts b/apps/viewer/src/__tests__/viewer-route.spec.ts new file mode 100644 index 0000000..38655f2 --- /dev/null +++ b/apps/viewer/src/__tests__/viewer-route.spec.ts @@ -0,0 +1,188 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../App.vue'; +import { ApiError, getPublicAlbum, getPublicPhoto, getPublicPhotos, type PublicPhoto, type PublicPhotoList } from '../api/client'; +import { createViewerI18n } from '../i18n'; +import { router } from '../router'; +import { makeAlbum } from './fixtures'; + +// 大图查看器路由编排(Task 8):URL 同步(打开 push / 切换 replace / 关闭 back 或 +// replace)、滚动锚点恢复、深链解析顺序(单图先行 → 相邻页构成序列)、深链 404 +// 本地化提示与返回链接。使用真实 router 单例(jsdom 提供_history),对齐 +// routing.spec.ts 的既定模式;api 三端点替身。 + +vi.mock('../api/client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getPublicAlbum: vi.fn(), + getPublicPhotos: vi.fn(), + getPublicPhoto: vi.fn(), + }; +}); + +const mockedGetPublicAlbum = vi.mocked(getPublicAlbum); +const mockedGetPublicPhotos = vi.mocked(getPublicPhotos); +const mockedGetPublicPhoto = vi.mocked(getPublicPhoto); + +function makePhoto(id: string): PublicPhoto { + return { + photoId: id, + width: 800, + height: 500, + thumb: { url: `http://img.test/${id}-thumb.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' }, + browse: { url: `http://img.test/${id}-browse.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' }, + likeCount: 0, + publishedAt: '2026-10-01T00:00:00.000Z', + }; +} + +function makeList(items: PublicPhoto[], nextCursor: string | null = null): PublicPhotoList { + return { items, nextCursor, hasMore: nextCursor !== null }; +} + +let i18n: ReturnType; +const mounted: VueWrapper[] = []; + +function mountApp(): VueWrapper { + const wrapper = mount(App, { global: { plugins: [router, i18n] } }); + mounted.push(wrapper); + return wrapper; +} + +function keydown(key: string): void { + window.dispatchEvent(new KeyboardEvent('keydown', { key, cancelable: true })); +} + +describe('viewer url sync and deep link (Task 8)', () => { + beforeEach(async () => { + localStorage.clear(); + i18n = createViewerI18n('zh-CN'); + mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum()); + mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')])); + mockedGetPublicPhoto.mockReset().mockImplementation(async (_token, photoId) => ({ + ...makePhoto(photoId), + categoryId: null, + })); + }); + + afterEach(() => { + while (mounted.length > 0) { + mounted.pop()?.unmount(); + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('open pushes the photo route, navigate replaces, close restores scroll and pops back', async () => { + await router.push('/a/tok-view'); + await router.isReady(); + const wrapper = mountApp(); + await flushPromises(); + expect(wrapper.findAll('.cell')).toHaveLength(3); + + const pushSpy = vi.spyOn(router, 'push'); + const replaceSpy = vi.spyOn(router, 'replace'); + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + vi.stubGlobal('scrollY', 650); + + // 点击第 2 格开大图:push 深链路由,browse 渲染。 + await wrapper.findAll('.cell')[1].trigger('click'); + await flushPromises(); + expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p2-browse.jpg?s=1'); + expect(router.currentRoute.value.path).toBe('/a/tok-view/p/p2'); + expect(pushSpy).toHaveBeenCalledTimes(1); + + // 键盘右切:replace(不新增历史条目)。 + keydown('ArrowRight'); + await flushPromises(); + expect(router.currentRoute.value.path).toBe('/a/tok-view/p/p3'); + expect(replaceSpy).toHaveBeenCalledTimes(1); + expect(pushSpy).toHaveBeenCalledTimes(1); + + // Esc 关闭:恢复滚动锚点 + 历史出栈回相册路由(jsdom 的 popstate 异步,需等待)。 + scrollTo.mockClear(); + keydown('Escape'); + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view')); + expect(wrapper.find('.lightbox').exists()).toBe(false); + expect(scrollTo).toHaveBeenCalledWith(0, 650); + expect(replaceSpy).toHaveBeenCalledTimes(1); // 关闭未额外 replace + }); + + it('browser back from the photo route closes the lightbox without touching history further', async () => { + await router.push('/a/tok-view'); + await router.isReady(); + const wrapper = mountApp(); + await flushPromises(); + + vi.stubGlobal('scrollY', 320); + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + await wrapper.findAll('.cell')[0].trigger('click'); + await flushPromises(); + expect(wrapper.find('.lightbox').exists()).toBe(true); + + scrollTo.mockClear(); + router.back(); // 等价浏览器后退(popstate 异步派发) + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view')); + await flushPromises(); + expect(wrapper.find('.lightbox').exists()).toBe(false); + expect(scrollTo).toHaveBeenCalledWith(0, 320); + }); + + it('deep link renders the single photo first, then naturalizes it as pages arrive', async () => { + // 第 1 页挂起(受控):验证「先单图、后序列」的解析顺序。 + let resolveFirstPage!: (value: PublicPhotoList) => void; + mockedGetPublicPhotos.mockReset(); + mockedGetPublicPhotos + .mockReturnValueOnce( + new Promise((resolve) => { + resolveFirstPage = resolve; + }), + ) + .mockResolvedValueOnce(makeList([makePhoto('p4'), makePhoto('p5')], null)); + + await router.push('/a/tok-view/p/p5'); + await router.isReady(); + const wrapper = mountApp(); + await flushPromises(); + + // 单图先行:列表仍在加载,大图已可看(solo 序列 1/1)。 + expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-view', 'p5'); + expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p5-browse.jpg?s=1'); + expect(wrapper.find('.counter').text()).toBe('1 / 1'); + + // 第 1 页到达(无 p5)→ 深链 watcher 追拉第 2 页 → p5 归位自然顺序(5/5)。 + resolveFirstPage(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1')); + await vi.waitFor(() => expect(wrapper.findAll('.cell')).toHaveLength(5)); + expect(wrapper.find('.counter').text()).toBe('5 / 5'); + + // 序列已构成:左切到前一张。 + keydown('ArrowLeft'); + await flushPromises(); + expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p4-browse.jpg?s=1'); + expect(router.currentRoute.value.path).toBe('/a/tok-view/p/p4'); + }); + + it('deep link to a non-public photo shows the localized notice with a back link', async () => { + mockedGetPublicPhoto.mockRejectedValue(new ApiError('PHOTO_NOT_PUBLIC', 'hidden', 404, 'req-1')); + + await router.push('/a/tok-view/p/gone'); + await router.isReady(); + const wrapper = mountApp(); + await flushPromises(); + + expect(wrapper.find('.lightbox').exists()).toBe(false); + const notice = wrapper.find('.deep-link-notice[role="alert"]'); + expect(notice.text()).toContain('照片不存在或已下线'); + expect(notice.text()).not.toContain('PHOTO_NOT_PUBLIC'); // 不裸露错误码 + + const back = notice.find('a'); + expect(back.text()).toBe('返回相册'); + expect(back.attributes('href')).toBe('/a/tok-view'); + await back.trigger('click'); + await flushPromises(); + expect(router.currentRoute.value.path).toBe('/a/tok-view'); + expect(wrapper.find('.deep-link-notice').exists()).toBe(false); + expect(wrapper.text()).toContain('2026 公司年会'); + }); +}); diff --git a/apps/viewer/src/__tests__/zoom.spec.ts b/apps/viewer/src/__tests__/zoom.spec.ts new file mode 100644 index 0000000..06837d2 --- /dev/null +++ b/apps/viewer/src/__tests__/zoom.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + INITIAL_ZOOM, + MAX_SCALE, + MIN_SCALE, + clampScale, + panBy, + resetZoom, + wheelFactor, + zoomAt, + type ZoomState, +} from '../viewer/zoom'; + +// 大图查看器缩放状态机(Task 8)纯函数层:钳制、不动点缩放、平移门控与复位。 + +describe('zoom state machine (Task 8)', () => { + it('clamps scale into [1, 4]', () => { + expect(clampScale(0.2)).toBe(MIN_SCALE); + expect(clampScale(3.9)).toBe(3.9); + expect(clampScale(11)).toBe(MAX_SCALE); + }); + + it('zooms around the given anchor point: the anchor stays fixed on screen', () => { + const state: ZoomState = { scale: 1, tx: 0, ty: 0 }; + // 锚点 (100, 40)(相对图片中心):缩放前屏幕位置 = 100*1+0 = 100。 + const next = zoomAt(state, 2, 100, 40); + expect(next.scale).toBe(2); + expect(next.tx).toBe(100 * (1 - 2)); + expect(next.ty).toBe(40 * (1 - 2)); + // 锚点屏幕位置不变:100*2 + (-100) = 100。 + expect(100 * next.scale + next.tx).toBe(100); + expect(40 * next.scale + next.ty).toBe(40); + }); + + it('clamps at max scale and keeps the anchor fixed with the actual factor', () => { + const state: ZoomState = { scale: 3, tx: -50, ty: 20 }; + const next = zoomAt(state, 10, 200, 0); + expect(next.scale).toBe(MAX_SCALE); + // 实际因子 4/3;锚点 200*3-50 = 550 → 200*4 + tx' = 550 → tx' = -250。 + expect(200 * next.scale + next.tx).toBe(200 * state.scale + state.tx); + }); + + it('resets translation when scale returns to min', () => { + const state: ZoomState = { scale: 2, tx: -120, ty: 80 }; + const next = zoomAt(state, 0.1, 300, 300); + expect(next).toEqual(resetZoom()); + }); + + it('pans only when zoomed in; pan accumulates', () => { + const flat: ZoomState = { ...INITIAL_ZOOM }; + expect(panBy(flat, 40, 20)).toBe(flat); // scale=1:无平移空间 + + const zoomed = zoomAt(flat, 2, 0, 0); + const panned = panBy(panBy(zoomed, 10, -5), 6, 6); + expect(panned.tx).toBe(16); + expect(panned.ty).toBe(1); + }); + + it('wheel factor maps deltaY sign consistently and stays continuous', () => { + expect(wheelFactor(-120)).toBeGreaterThan(1); // 上滚/外张 → 放大 + expect(wheelFactor(120)).toBeLessThan(1); // 下滚/内收 → 缩小 + expect(wheelFactor(0)).toBe(1); + }); +}); diff --git a/apps/viewer/src/components/PhotoFeed.vue b/apps/viewer/src/components/PhotoFeed.vue index a9d7907..9732177 100644 --- a/apps/viewer/src/components/PhotoFeed.vue +++ b/apps/viewer/src/components/PhotoFeed.vue @@ -1,120 +1,47 @@ @@ -238,4 +437,42 @@ function selectCategory(categoryId: string | null): void { background: #eff6ff; color: #1d4ed8; } +.deep-link-notice { + border: 1px solid #fde68a; + border-radius: 8px; + background: #fffbeb; + padding: 12px 16px; + margin-bottom: 16px; + color: #92400e; +} +.deep-link-notice p { + margin: 0 0 8px; +} +.notice-actions { + display: flex; + gap: 12px; + align-items: center; +} +.notice-actions a { + color: #1d4ed8; +} +.notice-actions button { + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + padding: 4px 12px; + cursor: pointer; +} +.removed-notice { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + margin: 0; + z-index: 110; + border-radius: 8px; + background: rgba(17, 24, 39, 0.9); + color: #f9fafb; + padding: 8px 16px; +} diff --git a/apps/viewer/src/viewer/zoom.ts b/apps/viewer/src/viewer/zoom.ts new file mode 100644 index 0000000..75b4528 --- /dev/null +++ b/apps/viewer/src/viewer/zoom.ts @@ -0,0 +1,53 @@ +// apps/viewer/src/viewer/zoom.ts —— 大图查看器(Task 8)缩放/平移纯状态机: +// 无 DOM 依赖的数学层,供 PhotoLightbox 以 PointerEvent/wheel 驱动、vitest 直接覆盖。 +// +// 模型:图片经 CSS `translate(tx,ty) scale(s)`(transform-origin 居中)渲染, +// 图片上距中心 (px,py) 的点落在屏幕 (px*s+tx, py*s+ty)(相对中心)。据此: +// · zoomAt:以 (cx,cy)(相对图片中心)为不动点缩放——该点缩放前后屏幕位置不变, +// 反解 t' = t + c*(s-s'); +// · scale 钳制 [MIN_SCALE, MAX_SCALE];回到 1 时平移归零(复位对齐居中); +// · panBy 仅在 scale>1 生效(scale=1 无可平移的溢出边)。 + +export interface ZoomState { + scale: number; + tx: number; + ty: number; +} + +export const MIN_SCALE = 1; +export const MAX_SCALE = 4; + +export const INITIAL_ZOOM: ZoomState = { scale: MIN_SCALE, tx: 0, ty: 0 }; + +export function clampScale(scale: number): number { + return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale)); +} + +/** 复位(双击/双触/切换照片时)。 */ +export function resetZoom(): ZoomState { + return { ...INITIAL_ZOOM }; +} + +/** 以 (cx,cy) 为不动点缩放 factor 倍(>1 放大);钳制后返回新状态(不修改入参)。 */ +export function zoomAt(state: ZoomState, factor: number, cx: number, cy: number): ZoomState { + const scale = clampScale(state.scale * factor); + if (scale === state.scale) return state; + if (scale === MIN_SCALE) return resetZoom(); + return { + scale, + tx: state.tx + cx * (state.scale - scale), + ty: state.ty + cy * (state.scale - scale), + }; +} + +/** 按指针位移平移;scale=1 时为无操作(无可平移空间)。 */ +export function panBy(state: ZoomState, dx: number, dy: number): ZoomState { + if (state.scale <= MIN_SCALE) return state; + return { scale: state.scale, tx: state.tx + dx, ty: state.ty + dy }; +} + +/** wheel 事件 → 缩放因子:deltaY<0(上滚/外张)放大;指数映射让手感均匀。 */ +export function wheelFactor(deltaY: number): number { + const WHEEL_SENSITIVITY = 0.0022; + return Math.exp(-deltaY * WHEEL_SENSITIVITY); +}