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>
This commit is contained in:
浅念
2026-09-18 19:52:27 +08:00
parent 955bea49b5
commit cdead49838
29 changed files with 2581 additions and 29 deletions
@@ -0,0 +1,126 @@
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); // 无重试
});
});