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

170 lines
6.8 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 { 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);
});
});