Files
photo-live/apps/viewer/src/__tests__/activity.spec.ts
T
浅念 cdead49838 feat(viewer): likes, bubbles, visit reporting and hot sorting
Task 10 viewer 互动化:

- 点赞:useLikes 批次状态机(500ms 防抖 / 5 次满额即冲 / 失败同 batchId
  退避重试 3 次后回滚丢弃),乐观计数 + 服务端 total 权威校准
  (countVersion 单调),大图与格子双入口;
- WS 实时:RealtimeClient 纯状态机(半频心跳、指数退避重连、三败降级、
  重连成功 resync 第 1 页重取),消息路由器做 likes.burst 自身过滤
  (originBatchIds 求交)、隐藏页暂停与减少动画下仅校准不出气泡;
  photo.hidden/deleted 即时移出序列并回退邻居大图;降级后 8s 轮询接管;
- 浏览上报:visitorId 跨标签 BroadcastChannel first-writer-wins 协调,
  可见时上报、requestId 持久幂等、网络失败指数重试;
- 排序:最新/热门 chips(热门走 ov 冷快照),大图打开期间禁用切换;
  顶栏「减少动画」偏好开关;
- 契约包新增 ./realtime 子路径出口(浏览器面不可引桶出口——其连带
  internal-sync 的 node:crypto 会被 Vite 外置化致整页崩溃);
- e2e 注入钩子 __viewerWsUrl/__viewerWsRetryBaseMs/__viewerRealtimePollMs/
  __viewerBubbleMs 与 .activity[data-connection] 连接态锚。

vitest:viewer 105(新增 36:likes 批次 8、visitor 协调 5、上报门控 4、
路由 8、WS 状态机 7、排序 4);Playwright:viewer 21(新增 likes.spec 7、
journey 补互动腿断言)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 19:52:27 +08:00

224 lines
9.0 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 映射等真实逻辑参与断言。Task 10 起活动页组装 WS/浏览
// 上报:RealtimeClient 以无操作桩替换(jsdom WebSocket 会发起真实连接)、
// postAlbumVisit 替身防真实 fetch。
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 }),
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
};
});
vi.mock('../realtime/ws', () => {
class RealtimeClientStub {
start(): void {}
stop(): void {}
}
return { RealtimeClient: RealtimeClientStub, wsUrlFromApiBase: () => 'ws://test/v1/realtime' };
});
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('.categories .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');
});
});