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
+169
View File
@@ -0,0 +1,169 @@
import { mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { postLikeBatch, type LikeBatchResponse } from '../api/client';
import { useLikes, type LikesController } from '../likes/useLikes';
// useLikes(Task 10 点赞状态机):批次防抖冲刷 / 5 次满额即冲 / 失败同 batchId
// 退避重试 / 3 次丢弃回滚 / 权威校准(含他人点赞跳变)/ 自身批次登记(气泡
// 过滤数据面)/ 列表单调重校准。假时钟驱动全部定时路径。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return { ...actual, postLikeBatch: vi.fn() };
});
const mockedPostLikeBatch = vi.mocked(postLikeBatch);
function ok(total: number, countVersion = 1): LikeBatchResponse {
return { total, countVersion, batchAccepted: true };
}
const mounted: VueWrapper[] = [];
function mountLikes(): LikesController {
let likes!: LikesController;
const Host = defineComponent({
setup() {
likes = useLikes({ token: ref('tok-likes'), photos: () => [] });
return () => null;
},
});
mounted.push(mount(Host));
return likes;
}
describe('likes batching (Task 10)', () => {
beforeEach(() => {
sessionStorage.clear();
mockedPostLikeBatch.mockReset().mockResolvedValue(ok(0));
vi.useFakeTimers();
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('accumulates rapid clicks into one batch flushed on the 500ms debounce', async () => {
mockedPostLikeBatch.mockResolvedValue(ok(3));
const likes = mountLikes();
likes.like('p1');
likes.like('p1');
likes.like('p1');
// 乐观计数立即可见(未冲批)。
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(3);
expect(mockedPostLikeBatch).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(500);
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(1);
const [token, photoId, body] = mockedPostLikeBatch.mock.calls[0]!;
expect(token).toBe('tok-likes');
expect(photoId).toBe('p1');
expect(body.delta).toBe(3);
expect(body.batchId).toMatch(/^[0-9a-f-]{8,}$/);
// 校准后 base=权威 3、pending 清零。
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(3);
});
it('flushes immediately when 5 clicks accumulate (cap path)', async () => {
mockedPostLikeBatch.mockResolvedValue(ok(5));
const likes = mountLikes();
for (let i = 0; i < 5; i++) likes.like('p1');
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(1);
expect(mockedPostLikeBatch.mock.calls[0]![2].delta).toBe(5);
await vi.advanceTimersByTimeAsync(500);
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(1); // 不重复冲
});
it('retries a failed batch with the SAME batchId and calibrates on success', async () => {
mockedPostLikeBatch.mockRejectedValueOnce(new Error('network down')).mockResolvedValue(ok(2));
const likes = mountLikes();
likes.like('p1');
likes.like('p1');
await vi.advanceTimersByTimeAsync(500); // 首发(网络失败)
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(1);
const batchId = mockedPostLikeBatch.mock.calls[0]![2].batchId;
await vi.advanceTimersByTimeAsync(1000); // 退避 1s → 同 batchId 重试成功
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(2);
expect(mockedPostLikeBatch.mock.calls[1]![2].batchId).toBe(batchId);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(2);
});
it('drops the batch after 3 attempts, rolls back the optimistic delta and notes it', async () => {
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
mockedPostLikeBatch.mockRejectedValue(new Error('network down'));
const likes = mountLikes();
likes.like('p1');
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(1);
await vi.advanceTimersByTimeAsync(500); // 首发(尝试 1)
await vi.advanceTimersByTimeAsync(1000); // 退避 1s(尝试 2)
await vi.advanceTimersByTimeAsync(2000); // 退避 2s(尝试 3 → 丢弃)
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(3);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(0);
expect(info).toHaveBeenCalledWith(expect.stringContaining('like batch dropped'));
});
it('calibrates to the authoritative total including other viewers likes', async () => {
// 本会话 1 击,他人已累计 7 → 响应 total=8 权威校准。
mockedPostLikeBatch.mockResolvedValue(ok(8));
const likes = mountLikes();
likes.like('p1');
await vi.advanceTimersByTimeAsync(500);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(8);
});
it('registers accepted batches for burst self-filtering (ownDeltaIn)', async () => {
mockedPostLikeBatch.mockResolvedValueOnce(ok(2)).mockResolvedValueOnce(ok(5));
const likes = mountLikes();
likes.like('p1');
await vi.advanceTimersByTimeAsync(500);
likes.like('p1');
await vi.advanceTimersByTimeAsync(500);
const first = mockedPostLikeBatch.mock.calls[0]![2].batchId;
const second = mockedPostLikeBatch.mock.calls[1]![2].batchId;
expect(likes.ownDeltaIn([first])).toBe(1);
expect(likes.ownDeltaIn([first, second])).toBe(2);
expect(likes.ownDeltaIn(['batch-from-another-session'])).toBe(0);
});
it('recalibrates from list monotonically and keeps in-flight pending', async () => {
mockedPostLikeBatch.mockResolvedValue(ok(0));
const likes = mountLikes();
likes.like('p1');
// 服务端列表显示他人已推到 7(本批在途)→ base 抬升、乐观量保留。
likes.recalibrateFromList([{ photoId: 'p1', likeCount: 7 } as never]);
expect(likes.displayFor({ photoId: 'p1', likeCount: 7 })).toBe(8);
// 滞后快照(4 < 7)不回退 base。
likes.recalibrateFromList([{ photoId: 'p1', likeCount: 4 } as never]);
expect(likes.displayFor({ photoId: 'p1', likeCount: 4 })).toBe(8);
// 未接触过的照片以列表计数为 base。
likes.recalibrateFromList([{ photoId: 'p2', likeCount: 9 } as never]);
expect(likes.displayFor({ photoId: 'p2', likeCount: 9 })).toBe(9);
});
it('ignores stale countVersion calibrations', async () => {
mockedPostLikeBatch.mockResolvedValue(ok(10, 5));
const likes = mountLikes();
likes.like('p1');
await vi.advanceTimersByTimeAsync(500);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(10);
// 乱序旧版本 burst(version 3 < 5):不回退。
likes.calibrate('p1', 4, 3);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(10);
// 新版本照常生效。
likes.calibrate('p1', 12, 6);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(12);
});
});