Files
photo-live/apps/viewer/src/__tests__/activity.spec.ts
T
2026-09-18 10:23:27 +08:00

213 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof import('../api/client')>();
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<PublicAlbum>((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<PublicAlbum>) => void> = [];
mockedGetPublicAlbum.mockImplementation(
() =>
new Promise<PublicAlbum>((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');
});
});