import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; 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 PhotoFeed from '../components/PhotoFeed.vue'; // PhotoFeed(Task 7)组件测试:api client 以真实模块为底、替身 getPublicPhotos / // getPublicPhoto;IntersectionObserver / ResizeObserver 以可控假件替换(jsdom 无实现), // clientWidth 以原型 getter mock(jsdom 恒 0)。布局纯函数已在 feed-layout.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); /** 可控 IO 假件:记录实例与 init(rootMargin 区分懒加载 400px / 哨兵 600px)。 */ class FakeIntersectionObserver { static instances: FakeIntersectionObserver[] = []; readonly root: Element | Document | null = null; readonly thresholds: ReadonlyArray = []; private readonly targets = new Set(); constructor( readonly callback: IntersectionObserverCallback, readonly init: IntersectionObserverInit = {}, ) { FakeIntersectionObserver.instances.push(this); } get rootMargin(): string { return typeof this.init.rootMargin === 'string' ? this.init.rootMargin : ''; } get observed(): Element[] { return [...this.targets]; } observe(target: Element): void { this.targets.add(target); } unobserve(target: Element): void { this.targets.delete(target); } disconnect(): void { this.targets.clear(); } takeRecords(): IntersectionObserverEntry[] { return []; } trigger(targets: Element[], isIntersecting: boolean): void { this.callback( targets.map((target) => ({ target, isIntersecting }) as IntersectionObserverEntry), this as unknown as IntersectionObserver, ); } } class FakeResizeObserver { observe(): void {} unobserve(): void {} disconnect(): void {} } function lazyObserver(): FakeIntersectionObserver { const observer = FakeIntersectionObserver.instances.find((instance) => instance.rootMargin.includes('400px'), ); if (observer === undefined) throw new Error('lazy observer not created'); return observer; } function sentinelObserver(): FakeIntersectionObserver { const observer = FakeIntersectionObserver.instances.find((instance) => instance.rootMargin.includes('600px'), ); if (observer === undefined) throw new Error('sentinel observer not created'); return observer; } 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 }; } 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, }, }); mounted.push(wrapper); return wrapper; } describe('photo feed (Task 7)', () => { beforeEach(() => { localStorage.clear(); i18n = createViewerI18n('zh-CN'); FakeIntersectionObserver.instances = []; vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); vi.stubGlobal('ResizeObserver', FakeResizeObserver); // jsdom 无布局:clientWidth 恒 0 → 原型 getter mock 为 700(columnWidth=346)。 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'), makePhoto('p3')], 'cur-1')); mockedGetPublicPhoto.mockReset(); }); afterEach(() => { while (mounted.length > 0) { mounted.pop()?.unmount(); } vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.useRealTimers(); }); it('renders first page as absolute-positioned cells with reserved heights', async () => { const wrapper = mountFeed(); await flushPromises(); expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-feed', { locale: 'zh-CN' }); const cells = wrapper.findAll('.cell'); expect(cells).toHaveLength(3); // 700 容器宽 → columnWidth 346;800x500 比例 → 条高 round(346*.625)=216。 // 3 条:col0 [224,0] → p2 入 col1 → p3 入 col0(top 224)→ 容器高 224+216=440。 expect(wrapper.find('.waterfall').attributes('style')).toContain('height: 440px'); expect(cells[1].attributes('style')).toContain('left: 354px'); expect(cells[2].attributes('style')).toContain('top: 224px'); }); it('binds img src only after the cell approaches the viewport (lazy swap)', async () => { const wrapper = mountFeed(); await flushPromises(); expect(wrapper.findAll('img.cell-img')).toHaveLength(0); const cells = wrapper.findAll('.cell'); expect(lazyObserver().observed).toHaveLength(3); lazyObserver().trigger([cells[0].element, cells[1].element], true); await vi.waitFor(() => expect(wrapper.findAll('img.cell-img')).toHaveLength(2)); expect(wrapper.find('img.cell-img').attributes('src')).toBe('http://img.test/p1-thumb.jpg?s=1'); // 已显示的单元格不再被观察。 expect(lazyObserver().observed).toHaveLength(1); }); it('loads the next page when the sentinel intersects, dedupes and stops at exhaustion', async () => { const wrapper = mountFeed(); await flushPromises(); const sentinel = wrapper.find('.sentinel'); expect(sentinel.exists()).toBe(true); // 第 2 页带回重复的 p3(游标边界漂移防御)→ 仅追加新 id。 mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p3'), makePhoto('p4')], null)); sentinelObserver().trigger([sentinel.element], true); await flushPromises(); expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith( 'tok-feed', expect.objectContaining({ cursor: 'cur-1' }), ); expect(wrapper.findAll('.cell')).toHaveLength(4); // 游标耗尽:哨兵卸载 + 底部提示。 expect(wrapper.find('.sentinel').exists()).toBe(false); expect(wrapper.find('.end-note').text()).toBe('已经到底了'); 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(); await flushPromises(); expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线'); expect(wrapper.find('.cell').exists()).toBe(false); }); it('initial load failure shows the localized error with retry; retry recovers', async () => { mockedGetPublicPhotos .mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network down', 0, null)) .mockResolvedValueOnce(makeList([makePhoto('p1')], null)); const wrapper = mountFeed(); await flushPromises(); expect(wrapper.find('.error[role="alert"]').text()).toContain('网络连接失败'); expect(wrapper.find('.cell').exists()).toBe(false); await wrapper.find('.error button').trigger('click'); await flushPromises(); expect(wrapper.findAll('.cell')).toHaveLength(1); }); it('retries a failed thumb once with a refreshed signed URL, then hides the img', async () => { 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)); mockedGetPublicPhoto.mockResolvedValue({ ...makePhoto('p1'), categoryId: null, thumb: { url: 'http://img.test/p1-thumb.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' }, }); await wrapper.findAll('img.cell-img')[0].trigger('error'); await flushPromises(); expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-feed', 'p1'); expect(wrapper.findAll('img.cell-img')[0].attributes('src')).toBe('http://img.test/p1-thumb.jpg?s=2'); // 第二次失败 → 收起该图(占位底色仍在,布局不塌)。 await wrapper.findAll('img.cell-img')[0].trigger('error'); await flushPromises(); expect(wrapper.findAll('img.cell-img')).toHaveLength(2); expect(wrapper.findAll('.cell')).toHaveLength(3); 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); }); });