cdead49838
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>
170 lines
6.8 KiB
TypeScript
170 lines
6.8 KiB
TypeScript
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);
|
||
});
|
||
});
|