Files
photo-live/apps/viewer/src/__tests__/visit-report.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

127 lines
4.9 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 { defineComponent, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiError, postAlbumVisit } from '../api/client';
import { useVisitReport } from '../stats/visitor';
import { VISITOR_STORAGE_KEY } from '../stats/visitor';
// useVisitReport(Task 10 浏览上报编排):加载成功且页面可见才报(隐藏时等
// 首次可见)/ 单相册一次 / 网络失败同 requestId 退避重试 / 4xx 静默放弃 /
// requestId 持久 sessionStorage 直到成功响应。visitorId 预置 localStorage
// 走快路径(跨标签协调单独在 visitor-id.spec 覆盖)。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return { ...actual, postAlbumVisit: vi.fn() };
});
const mockedPostAlbumVisit = vi.mocked(postAlbumVisit);
const mounted: VueWrapper[] = [];
function mountReport(token: string): ReturnType<typeof useVisitReport> {
let report!: ReturnType<typeof useVisitReport>;
const Host = defineComponent({
setup() {
report = useVisitReport({
token: ref(token),
onCounted: (count) => counted.push(count),
});
return () => null;
},
});
mounted.push(mount(Host));
return report;
}
const counted: number[] = [];
/** jsdom document.hidden 只读:defineProperty 覆写(用例后还原)。 */
function setHidden(hidden: boolean): void {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
}
describe('visit report gating (Task 10)', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
localStorage.setItem(VISITOR_STORAGE_KEY, 'v'.repeat(32));
counted.length = 0;
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 1, counted: true });
setHidden(false);
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
vi.restoreAllMocks();
// 收尾后在途上报微任务可能仍落到已还原的替身(返回 undefined → .then 崩):
// 恢复后立即补挂兜底实现。
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
setHidden(false);
});
it('reports once after album load while visible, then clears the persisted requestId', async () => {
const report = mountReport('tok-visit');
report.notifyAlbumLoaded();
await flushPromises();
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
const [token, body] = mockedPostAlbumVisit.mock.calls[0]!;
expect(token).toBe('tok-visit');
expect(body.visitorId).toBe('v'.repeat(32));
expect(body.requestId).toMatch(/^[0-9a-f-]{8,}$/);
expect(counted).toEqual([1]);
expect(sessionStorage.getItem('viewer.visitRequest:tok-visit')).toBeNull(); // 确认后清除
report.notifyAlbumLoaded(); // 幂等:同相册只报一次
await flushPromises();
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
});
it('defers the report until the page becomes visible', async () => {
setHidden(true);
const report = mountReport('tok-hidden');
report.notifyAlbumLoaded();
await flushPromises();
expect(mockedPostAlbumVisit).not.toHaveBeenCalled();
setHidden(false);
document.dispatchEvent(new Event('visibilitychange'));
await flushPromises();
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
});
it('retries network failure with the same persisted requestId and succeeds', async () => {
mockedPostAlbumVisit
.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network request failed', 0, null))
.mockResolvedValue({ viewCount: 6, counted: true });
vi.useFakeTimers();
const report = mountReport('tok-retry');
report.notifyAlbumLoaded();
await vi.advanceTimersByTimeAsync(0);
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
const requestId = mockedPostAlbumVisit.mock.calls[0]![1].requestId;
// 失败期间 requestId 保持持久(刷新/重试幂等键不换)。
expect(sessionStorage.getItem('viewer.visitRequest:tok-retry')).toBe(requestId);
await vi.advanceTimersByTimeAsync(1000);
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(2);
expect(mockedPostAlbumVisit.mock.calls[1]![1].requestId).toBe(requestId);
expect(counted).toEqual([6]);
expect(sessionStorage.getItem('viewer.visitRequest:tok-retry')).toBeNull();
});
it('gives up silently on 4xx without retrying', async () => {
mockedPostAlbumVisit.mockRejectedValue(new ApiError('RATE_LIMITED', 'too many', 429, null));
vi.useFakeTimers();
const report = mountReport('tok-4xx');
report.notifyAlbumLoaded();
await vi.advanceTimersByTimeAsync(0);
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(10_000);
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1); // 无重试
});
});