import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { nextTick } from 'vue'; import App from '../App.vue'; import { ApiError, getPublicAlbum, type PublicAlbum } from '../api/client'; import { createViewerI18n, type ViewerI18n } from '../i18n'; import ActivityPage from '../pages/ActivityPage.vue'; import { router } from '../router'; import { makeAlbum, makeEnAlbum } from './fixtures'; // ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum / // getPublicPhotos(Task 7 起活动页内嵌 PhotoFeed,需喂空页避免真实 fetch), // 保证 ApiError/errors 映射等真实逻辑参与断言。 vi.mock('../api/client', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getPublicAlbum: vi.fn(), getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }), }; }); const mockedGetPublicAlbum = vi.mocked(getPublicAlbum); // 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例 // (见 i18n/index.ts 注释),复用单例会让后续用例的 locale 写入失效;残留组件的 // locale watch 也会在后续用例切换语言时再度触发取数,污染调用序断言。 let i18n: ViewerI18n; const mounted: VueWrapper[] = []; function mountPage(): VueWrapper { const wrapper = mount(ActivityPage, { global: { plugins: [router, i18n] } }); mounted.push(wrapper); return wrapper; } function mountApp(): VueWrapper { const wrapper = mount(App, { global: { plugins: [router, i18n] } }); mounted.push(wrapper); return wrapper; } describe('activity page (Task 6)', () => { beforeEach(async () => { localStorage.clear(); i18n = createViewerI18n('zh-CN'); document.title = ''; mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum()); await router.push('/a/tok-activity'); await router.isReady(); }); afterEach(() => { while (mounted.length > 0) { mounted.pop()?.unmount(); } }); it('renders album info in zh: title, pre-wrap description, venue · address, categories chips and empty notice', async () => { const wrapper = mountPage(); await flushPromises(); expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-activity', 'zh-CN'); expect(wrapper.find('h1.title').text()).toBe('2026 公司年会'); expect(wrapper.find('.description').text()).toBe('年度盛会\n多行介绍'); expect(wrapper.find('.meta').text()).toContain('大宴会厅 · 上海市浦东新区世纪大道 100 号'); // Asia/Shanghai:UTC 10-01 02:00/12:00 → 本地 10-01 10:00/20:00。 expect(wrapper.find('.meta').text()).toContain('10月1日'); const chips = wrapper.findAll('.chip'); expect(chips.map((chip) => chip.text())).toEqual(['全部', '签到墙', '开场']); expect(chips.every((chip) => chip.attributes('disabled') === undefined)).toBe(true); expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线'); // 无 banner 资产 → 不渲染 img;viewCount 占位 null → 不渲染任何计数节点。 expect(wrapper.find('img.banner').exists()).toBe(false); expect(wrapper.find('.view-count').exists()).toBe(false); expect(document.title).toBe('2026 公司年会'); }); it('formats the time range in the album timezone (America/New_York shifts UTC date back one day)', async () => { mockedGetPublicAlbum.mockResolvedValue( makeAlbum({ timezone: 'America/New_York' }), ); const wrapper = mountPage(); await flushPromises(); // UTC 10-01 02:00 → New York(EDT, UTC-4)09-30 22:00;endAt 12:00Z → 10-01 08:00。 const time = wrapper.find('.meta').text(); expect(time).toContain('9月30'); expect(time).toContain('10月1'); expect(time).not.toContain('9月29'); }); it('renders a single date when endAt is missing (no range separator)', async () => { mockedGetPublicAlbum.mockResolvedValue( makeAlbum({ timezone: 'America/New_York', endAt: null }), ); const wrapper = mountPage(); await flushPromises(); const time = wrapper.find('.meta').text(); expect(time).toContain('9月30'); expect(time).not.toContain('–'); // 区间分隔符不应出现 expect(time).not.toContain('10月1'); }); it('switching locale re-fetches with ?locale= and shows resolved English content', async () => { mockedGetPublicAlbum.mockImplementation((_token, locale) => Promise.resolve(locale === 'en' ? makeEnAlbum() : makeAlbum()), ); const wrapper = mountApp(); await flushPromises(); expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'zh-CN'); expect(wrapper.find('h1.title').text()).toBe('2026 公司年会'); await wrapper.find('button.locale-switcher').trigger('click'); await flushPromises(); expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'en'); expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026'); expect(wrapper.find('.empty-notice').text()).toBe('Photos coming soon'); expect(document.title).toBe('Annual Party 2026'); }); it('ALBUM_NOT_PUBLIC renders the localized message with retry; retry recovers', async () => { mockedGetPublicAlbum .mockRejectedValueOnce(new ApiError('ALBUM_NOT_PUBLIC', 'album not public', 404, 'req-1')) .mockResolvedValueOnce(makeAlbum()); const wrapper = mountPage(); await flushPromises(); expect(wrapper.find('.error[role="alert"]').text()).toContain('相册不存在或已下线'); expect(wrapper.find('h1.title').exists()).toBe(false); await wrapper.find('.error button').trigger('click'); await flushPromises(); expect(wrapper.find('h1.title').text()).toBe('2026 公司年会'); }); it('renders banner img with the signed url when present, nothing when absent', async () => { mockedGetPublicAlbum.mockResolvedValue( makeAlbum({ banner: { url: 'http://127.0.0.1:9000/photo-live/dev/albums/a1/assets/banner.jpg?sig=1', expiresAt: '2026-10-01T00:00:00.000Z' } }), ); const withBanner = mountPage(); await flushPromises(); const img = withBanner.find('img.banner'); expect(img.exists()).toBe(true); expect(img.attributes('src')).toContain('/albums/a1/assets/banner.jpg?sig=1'); mockedGetPublicAlbum.mockResolvedValue(makeAlbum()); const withoutBanner = mountPage(); await flushPromises(); expect(withoutBanner.find('img.banner').exists()).toBe(false); }); it('hides the banner on img load failure (layout adapts, no broken image)', async () => { mockedGetPublicAlbum.mockResolvedValue( makeAlbum({ banner: { url: 'http://127.0.0.1:9000/photo-live/dev/expired.jpg?sig=stale', expiresAt: '2020-01-01T00:00:00.000Z' } }), ); const wrapper = mountPage(); await flushPromises(); expect(wrapper.find('img.banner').exists()).toBe(true); await wrapper.find('img.banner').trigger('error'); await nextTick(); expect(wrapper.find('img.banner').exists()).toBe(false); }); it('shows the localized loading state until the request settles', async () => { let resolve!: (value: PublicAlbum) => void; mockedGetPublicAlbum.mockReturnValue( new Promise((res) => { resolve = res; }), ); const wrapper = mountPage(); await flushPromises(); expect(wrapper.find('.status').text()).toBe('加载中…'); expect(wrapper.find('h1.title').exists()).toBe(false); resolve(makeAlbum()); await flushPromises(); expect(wrapper.find('.status').exists()).toBe(false); expect(wrapper.find('h1.title').text()).toBe('2026 公司年会'); }); it('discards stale responses when the locale changes quickly (last request wins)', async () => { const pending: Array<(value: PublicAlbum | PromiseLike) => void> = []; mockedGetPublicAlbum.mockImplementation( () => new Promise((res) => { pending.push(res); }), ); const wrapper = mountPage(); await flushPromises(); // zh 请求在途时切到 en(触发第二个请求),en 先返回、zh 后返回——zh 为过期响应。 i18n.global.locale.value = 'en'; await flushPromises(); expect(pending).toHaveLength(2); pending[1](makeEnAlbum()); await flushPromises(); expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026'); pending[0](makeAlbum()); await flushPromises(); expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026'); }); });