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