feat(viewer): album page with locale fallback and timezone display
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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,
|
||||
// 保证 ApiError/errors 映射等真实逻辑参与断言。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, getPublicAlbum: vi.fn() };
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { PublicAlbum } from '../api/client';
|
||||
|
||||
// 组件测试共享夹具:公开相册响应的最小完整形状(对齐契约 PublicAlbum 必返字段)。
|
||||
// vi.mock 工厂需在各 spec 文件内联(hoisting 限制),此处只承载数据构造。
|
||||
export function makeAlbum(
|
||||
overrides: Partial<PublicAlbum> & { translation?: Partial<PublicAlbum['translation']> } = {},
|
||||
): PublicAlbum {
|
||||
const { translation, ...rest } = overrides;
|
||||
return {
|
||||
publicToken: 'tokfixture0000000000aaaa',
|
||||
resolvedLocale: 'zh-CN',
|
||||
defaultLocale: 'zh-CN',
|
||||
timezone: 'Asia/Shanghai',
|
||||
startAt: '2026-10-01T02:00:00.000Z',
|
||||
endAt: '2026-10-01T12:00:00.000Z',
|
||||
translation: {
|
||||
locale: 'zh-CN',
|
||||
title: '2026 公司年会',
|
||||
description: '年度盛会\n多行介绍',
|
||||
venue: '大宴会厅',
|
||||
address: '上海市浦东新区世纪大道 100 号',
|
||||
...translation,
|
||||
},
|
||||
categories: [
|
||||
{ id: 'cat-sign', name: '签到墙', sortOrder: 0 },
|
||||
{ id: 'cat-open', name: '开场', sortOrder: 1 },
|
||||
],
|
||||
banner: null,
|
||||
viewCount: null,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeEnAlbum(): PublicAlbum {
|
||||
return makeAlbum({
|
||||
resolvedLocale: 'en',
|
||||
translation: {
|
||||
locale: 'en',
|
||||
title: 'Annual Party 2026',
|
||||
description: 'Yearly gala',
|
||||
venue: 'Grand Ballroom',
|
||||
address: '100 Century Ave, Pudong, Shanghai',
|
||||
},
|
||||
categories: [
|
||||
{ id: 'cat-sign', name: 'Check-in Wall', sortOrder: 0 },
|
||||
{ id: 'cat-open', name: 'Opening', sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,27 +1,36 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import App from '../App.vue';
|
||||
import { LOCALE_STORAGE_KEY, i18n } from '../i18n';
|
||||
import { LOCALE_STORAGE_KEY, createViewerI18n } from '../i18n';
|
||||
import { router } from '../router';
|
||||
|
||||
// i18n 切换机制(顶栏 chrome 层):活动页级的「切换语言 → 重取内容」由
|
||||
// activity.spec.ts 覆盖,此处聚焦首页 chrome(无需 api mock)。
|
||||
// 逐挂载独立 i18n + 逐用例卸载(背景见 activity.spec.ts 头注)。
|
||||
let i18n: ReturnType<typeof createViewerI18n>;
|
||||
let wrapper: VueWrapper;
|
||||
|
||||
describe('viewer i18n', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
i18n.global.locale.value = 'zh-CN';
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
await router.push('/');
|
||||
await router.isReady();
|
||||
wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('switching locale re-renders messages and persists the choice', async () => {
|
||||
await router.push('/a/tok-i18n');
|
||||
await router.isReady();
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
|
||||
expect(wrapper.text()).toContain('照片即将上线');
|
||||
expect(wrapper.text()).toContain('打开相册链接即可观看');
|
||||
expect(document.title).toBe('现场相册');
|
||||
|
||||
await wrapper.find('button.locale-switcher').trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toContain('Photos coming soon');
|
||||
expect(wrapper.text()).toContain('Open an album link to start viewing');
|
||||
expect(document.title).toBe('Live Gallery');
|
||||
expect(document.documentElement.getAttribute('lang')).toBe('en');
|
||||
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('en');
|
||||
@@ -29,7 +38,7 @@ describe('viewer i18n', () => {
|
||||
// 切回 zh-CN 同样生效
|
||||
await wrapper.find('button.locale-switcher').trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toContain('照片即将上线');
|
||||
expect(wrapper.text()).toContain('打开相册链接即可观看');
|
||||
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-CN');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,58 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../App.vue';
|
||||
import { i18n } from '../i18n';
|
||||
import { getPublicAlbum } 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 深链定位(不影响取数)。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, getPublicAlbum: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
|
||||
// 逐挂载独立 i18n + 逐用例卸载(背景见 activity.spec.ts 头注)。
|
||||
let i18n: ReturnType<typeof createViewerI18n>;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
describe('viewer routing', () => {
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'zh-CN';
|
||||
beforeEach(async () => {
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum());
|
||||
});
|
||||
|
||||
it('activity route resolves token param and renders empty notice', async () => {
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('activity route resolves token param and loads the album', async () => {
|
||||
await router.push('/a/tok-abc');
|
||||
await router.isReady();
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
expect(wrapper.text()).toContain('tok-abc');
|
||||
mounted.push(wrapper);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-abc', 'zh-CN');
|
||||
expect(wrapper.text()).toContain('2026 公司年会');
|
||||
expect(wrapper.text()).toContain('照片即将上线');
|
||||
expect(wrapper.text()).not.toContain('photo-');
|
||||
});
|
||||
|
||||
it('deep link resolves token and photoId params on the activity page', async () => {
|
||||
await router.push('/a/tok-abc/p/photo-42');
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
await router.isReady();
|
||||
expect(wrapper.text()).toContain('tok-abc');
|
||||
expect(wrapper.text()).toContain('photo-42');
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
mounted.push(wrapper);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-abc', 'zh-CN');
|
||||
expect(wrapper.text()).toContain('2026 公司年会');
|
||||
// photoId 仅用于 Task 8 大图定位,不进入信息区文案。
|
||||
expect(wrapper.text()).not.toContain('photo-42');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user