Files
photo-live/apps/viewer/src/__tests__/likes.spec.ts
T

170 lines
6.8 KiB
TypeScript
Raw Normal View History

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);
});
});