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:
@@ -10,16 +10,27 @@ import { makeAlbum, makeEnAlbum } from './fixtures';
|
||||
|
||||
// ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum /
|
||||
// getPublicPhotos(Task 7 起活动页内嵌 PhotoFeed,需喂空页避免真实 fetch),
|
||||
// 保证 ApiError/errors 映射等真实逻辑参与断言。
|
||||
// 保证 ApiError/errors 映射等真实逻辑参与断言。Task 10 起活动页组装 WS/浏览
|
||||
// 上报:RealtimeClient 以无操作桩替换(jsdom WebSocket 会发起真实连接)、
|
||||
// postAlbumVisit 替身防真实 fetch。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return {
|
||||
...actual,
|
||||
getPublicAlbum: vi.fn(),
|
||||
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
||||
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../realtime/ws', () => {
|
||||
class RealtimeClientStub {
|
||||
start(): void {}
|
||||
stop(): void {}
|
||||
}
|
||||
return { RealtimeClient: RealtimeClientStub, wsUrlFromApiBase: () => 'ws://test/v1/realtime' };
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
|
||||
// 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例
|
||||
@@ -67,7 +78,7 @@ describe('activity page (Task 6)', () => {
|
||||
// Asia/Shanghai:UTC 10-01 02:00/12:00 → 本地 10-01 10:00/20:00。
|
||||
expect(wrapper.find('.meta').text()).toContain('10月1日');
|
||||
|
||||
const chips = wrapper.findAll('.chip');
|
||||
const chips = wrapper.findAll('.categories .chip');
|
||||
expect(chips.map((chip) => chip.text())).toEqual(['全部', '签到墙', '开场']);
|
||||
expect(chips.every((chip) => chip.attributes('disabled') === undefined)).toBe(true);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { LikesBurstMessage, ServerMessage } from '@photo/contracts';
|
||||
import { handleServerMessage, type RealtimeRouterDeps } from '../realtime/router';
|
||||
|
||||
// realtime 消息路由(Task 10):likes.burst 自身过滤(originBatchIds 求交)/
|
||||
// 隐藏页暂停(校准照常)/ 减少动画(校准照常)/ photo.* 移除 / stats.updated /
|
||||
// photo.published 提示。纯依赖注入,直测语义。
|
||||
|
||||
function burst(overrides: Partial<LikesBurstMessage> = {}): LikesBurstMessage {
|
||||
return {
|
||||
type: 'likes.burst',
|
||||
photoId: 'p1',
|
||||
albumId: 'a1',
|
||||
delta: 3,
|
||||
total: 10,
|
||||
countVersion: 4,
|
||||
burstId: 'b-1',
|
||||
originBatchIds: ['batch-other'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface DepCalls {
|
||||
bubbles: Array<{ photoId: string; delta: number }>;
|
||||
calibrations: Array<{ photoId: string; total: number; countVersion: number }>;
|
||||
removed: string[];
|
||||
newPhotoHints: number;
|
||||
viewCounts: number[];
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
overrides: Partial<RealtimeRouterDeps> & { own?: number; hidden?: boolean; reduced?: boolean } = {},
|
||||
): { deps: RealtimeRouterDeps; calls: DepCalls } {
|
||||
const calls: DepCalls = { bubbles: [], calibrations: [], removed: [], newPhotoHints: 0, viewCounts: [] };
|
||||
const deps: RealtimeRouterDeps = {
|
||||
isHidden: () => overrides.hidden ?? false,
|
||||
reducedMotion: () => overrides.reduced ?? false,
|
||||
ownDeltaIn: () => overrides.own ?? 0,
|
||||
calibrateLike: (photoId, total, countVersion) => calls.calibrations.push({ photoId, total, countVersion }),
|
||||
showBubble: (photoId, delta) => calls.bubbles.push({ photoId, delta }),
|
||||
notifyNewPhotos: () => {
|
||||
calls.newPhotoHints += 1;
|
||||
},
|
||||
removePhoto: (photoId) => calls.removed.push(photoId),
|
||||
setViewCount: (viewCount) => calls.viewCounts.push(viewCount),
|
||||
};
|
||||
return { deps, calls };
|
||||
}
|
||||
|
||||
describe('realtime message router (Task 10)', () => {
|
||||
it("others' burst bubbles and calibrates to the authoritative total", () => {
|
||||
const { deps, calls } = makeDeps({ own: 0 });
|
||||
handleServerMessage(burst({ delta: 3, total: 10 }), deps);
|
||||
expect(calls.bubbles).toEqual([{ photoId: 'p1', delta: 3 }]);
|
||||
expect(calls.calibrations).toEqual([{ photoId: 'p1', total: 10, countVersion: 4 }]);
|
||||
});
|
||||
|
||||
it('suppresses the bubble for own-only bursts but still calibrates', () => {
|
||||
const { deps, calls } = makeDeps({ own: 3 });
|
||||
handleServerMessage(burst({ delta: 3, total: 10, originBatchIds: ['mine-1'] }), deps);
|
||||
expect(calls.bubbles).toEqual([]);
|
||||
expect(calls.calibrations).toEqual([{ photoId: 'p1', total: 10, countVersion: 4 }]);
|
||||
});
|
||||
|
||||
it('bubbles only the others share of a merged burst containing own batches', () => {
|
||||
const { deps, calls } = makeDeps({ own: 2 });
|
||||
handleServerMessage(burst({ delta: 5, total: 12, originBatchIds: ['mine-1', 'other-9'] }), deps);
|
||||
expect(calls.bubbles).toEqual([{ photoId: 'p1', delta: 3 }]);
|
||||
expect(calls.calibrations).toEqual([{ photoId: 'p1', total: 12, countVersion: 4 }]);
|
||||
});
|
||||
|
||||
it('skips the bubble while the page is hidden; counts still calibrate', () => {
|
||||
const { deps, calls } = makeDeps({ hidden: true });
|
||||
handleServerMessage(burst(), deps);
|
||||
expect(calls.bubbles).toEqual([]);
|
||||
expect(calls.calibrations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips the bubble under reduced motion; counts still calibrate', () => {
|
||||
const { deps, calls } = makeDeps({ reduced: true });
|
||||
handleServerMessage(burst(), deps);
|
||||
expect(calls.bubbles).toEqual([]);
|
||||
expect(calls.calibrations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('routes photo.hidden and photo.deleted to removal', () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
handleServerMessage({ type: 'photo.hidden', photoId: 'p2', albumId: 'a1', entityVersion: 5 }, deps);
|
||||
handleServerMessage({ type: 'photo.deleted', photoId: 'p3', albumId: 'a1', entityVersion: 6 }, deps);
|
||||
expect(calls.removed).toEqual(['p2', 'p3']);
|
||||
});
|
||||
|
||||
it('routes photo.published to the new-photo hint and stats.updated to view count', () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
handleServerMessage(
|
||||
{ type: 'photo.published', photoId: 'p4', albumId: 'a1', publishedAt: '2026-10-01T00:00:00.000Z', entityVersion: 2 },
|
||||
deps,
|
||||
);
|
||||
handleServerMessage({ type: 'stats.updated', albumId: 'a1', viewCount: 41, countVersion: 9 }, deps);
|
||||
handleServerMessage({ type: 'pong', ts: 1 } as ServerMessage, deps);
|
||||
expect(calls.newPhotoHints).toBe(1);
|
||||
expect(calls.viewCounts).toEqual([41]);
|
||||
expect(calls.bubbles).toEqual([]);
|
||||
expect(calls.removed).toEqual([]);
|
||||
});
|
||||
|
||||
it('logs error frames without throwing', () => {
|
||||
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
const { deps } = makeDeps();
|
||||
expect(() =>
|
||||
handleServerMessage({ type: 'error', code: 'ALBUM_NOT_PUBLIC', message: 'nope' }, deps),
|
||||
).not.toThrow();
|
||||
expect(info).toHaveBeenCalledWith(expect.stringContaining('ALBUM_NOT_PUBLIC'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { HEARTBEAT_MS } from '@photo/contracts';
|
||||
import { RealtimeClient, wsUrlFromApiBase, type RealtimeState, type WebSocketLike } from '../realtime/ws';
|
||||
|
||||
// RealtimeClient(Task 10 WS 状态机):URL 推导 / open 订阅 + 半频心跳 /
|
||||
// 断线指数退避重连 / 连续 3 败降级 / 重连成功 resync / stop 幂等收尾。
|
||||
// 假 socket 工厂注入,假时钟驱动。
|
||||
|
||||
class FakeSocket {
|
||||
static instances: FakeSocket[] = [];
|
||||
readonly sent: string[] = [];
|
||||
closed = false;
|
||||
private readonly listeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
|
||||
constructor(public readonly url: string) {
|
||||
FakeSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: unknown) => void): void {
|
||||
const list = this.listeners.get(type) ?? [];
|
||||
list.push(listener);
|
||||
this.listeners.set(type, list);
|
||||
}
|
||||
|
||||
emit(type: 'open' | 'close' | 'error'): void {
|
||||
for (const listener of this.listeners.get(type) ?? []) listener({});
|
||||
}
|
||||
|
||||
message(frame: unknown): void {
|
||||
for (const listener of this.listeners.get('message') ?? []) listener({ data: JSON.stringify(frame) });
|
||||
}
|
||||
}
|
||||
|
||||
function makeClient(overrides: Partial<ConstructorParameters<typeof RealtimeClient>[0]> = {}): {
|
||||
client: RealtimeClient;
|
||||
states: RealtimeState[];
|
||||
getResyncs: () => number;
|
||||
messages: unknown[];
|
||||
} {
|
||||
const states: RealtimeState[] = [];
|
||||
const messages: unknown[] = [];
|
||||
let resyncs = 0;
|
||||
const client = new RealtimeClient({
|
||||
url: 'ws://test/v1/realtime',
|
||||
albumToken: () => 'tok-ws',
|
||||
onMessage: (message) => messages.push(message),
|
||||
onStateChange: (state) => states.push(state),
|
||||
onResync: () => {
|
||||
resyncs += 1;
|
||||
},
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocketLike,
|
||||
retryBaseMs: 1000,
|
||||
...overrides,
|
||||
});
|
||||
// getter:resyncs 是闭包计数(返回原语会是创建时的快照)。
|
||||
return { client, states, getResyncs: () => resyncs, messages };
|
||||
}
|
||||
|
||||
function lastSocket(): FakeSocket {
|
||||
return FakeSocket.instances[FakeSocket.instances.length - 1]!;
|
||||
}
|
||||
|
||||
describe('ws url derivation', () => {
|
||||
it('maps http/https api bases onto the realtime path', () => {
|
||||
expect(wsUrlFromApiBase('http://127.0.0.1:8081')).toBe('ws://127.0.0.1:8081/v1/realtime');
|
||||
expect(wsUrlFromApiBase('https://ov.example.com/')).toBe('wss://ov.example.com/v1/realtime');
|
||||
});
|
||||
|
||||
it('derives from the page origin for same-origin deployments', () => {
|
||||
const url = wsUrlFromApiBase('');
|
||||
expect(url).toBe('ws://localhost:3000/v1/realtime'); // jsdom 默认 origin
|
||||
});
|
||||
});
|
||||
|
||||
describe('realtime client state machine (Task 10)', () => {
|
||||
beforeEach(() => {
|
||||
FakeSocket.instances = [];
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('subscribes on open and pings at half the heartbeat interval', () => {
|
||||
const { client, states, messages } = makeClient();
|
||||
client.start();
|
||||
expect(states).toEqual([]); // 初始即 connecting,去重不重复发
|
||||
lastSocket().emit('open');
|
||||
|
||||
const subscribe = JSON.parse(lastSocket().sent[0]!) as { type: string; albumToken: string };
|
||||
expect(subscribe).toEqual({ type: 'subscribe', albumToken: 'tok-ws' });
|
||||
expect(states).toEqual(['open']);
|
||||
|
||||
// HEARTBEAT_MS/2 周期应用层 ping;服务端帧透传 onMessage。
|
||||
vi.advanceTimersByTime(Math.ceil(HEARTBEAT_MS / 2));
|
||||
const ping = JSON.parse(lastSocket().sent[1]!) as { type: string; ts: number };
|
||||
expect(ping.type).toBe('ping');
|
||||
expect(typeof ping.ts).toBe('number');
|
||||
lastSocket().message({ type: 'stats.updated', albumId: 'a1', viewCount: 3, countVersion: 1 });
|
||||
expect(messages).toHaveLength(1);
|
||||
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it('reconnects with exponential backoff after a drop', () => {
|
||||
const { client } = makeClient();
|
||||
client.start();
|
||||
lastSocket().emit('open');
|
||||
const first = lastSocket();
|
||||
first.emit('close'); // 掉线
|
||||
|
||||
vi.advanceTimersByTime(1000); // 退避 1s → 重连
|
||||
expect(FakeSocket.instances).toHaveLength(2);
|
||||
vi.advanceTimersByTime(2000 - 1);
|
||||
expect(FakeSocket.instances).toHaveLength(2); // 未到 2s 不再连
|
||||
lastSocket().emit('close');
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(FakeSocket.instances).toHaveLength(3);
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it('degrades after 3 consecutive failures and stays degraded while retrying', () => {
|
||||
const { client, states } = makeClient();
|
||||
client.start();
|
||||
lastSocket().emit('close'); // 失败 1
|
||||
vi.advanceTimersByTime(1000);
|
||||
lastSocket().emit('close'); // 失败 2
|
||||
vi.advanceTimersByTime(2000);
|
||||
lastSocket().emit('close'); // 失败 3 → degraded(此前一直 connecting,去重不发)
|
||||
expect(states).toEqual(['degraded']);
|
||||
|
||||
// 退避封顶 4s(base 1s)继续后台重试,仍 degraded。
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(FakeSocket.instances).toHaveLength(4);
|
||||
lastSocket().emit('open'); // 恢复 → open
|
||||
expect(states).toEqual(['degraded', 'open']);
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it('fires onResync and re-subscribes when reconnecting after a drop', () => {
|
||||
const { client, getResyncs } = makeClient();
|
||||
client.start();
|
||||
lastSocket().emit('open');
|
||||
expect(getResyncs()).toBe(0); // 首连不算 resync
|
||||
|
||||
lastSocket().emit('close');
|
||||
vi.advanceTimersByTime(1000);
|
||||
lastSocket().emit('open');
|
||||
expect(getResyncs()).toBe(1);
|
||||
const subscribe = JSON.parse(lastSocket().sent[0]!) as { type: string };
|
||||
expect(subscribe.type).toBe('subscribe');
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it('stop() tears down and prevents any further reconnection', () => {
|
||||
const { client } = makeClient();
|
||||
client.start();
|
||||
lastSocket().emit('open');
|
||||
client.stop();
|
||||
expect(lastSocket().closed).toBe(true);
|
||||
|
||||
lastSocket().emit('close'); // stop 后的 close 不再排重连
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(FakeSocket.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,8 @@ import { makeAlbum } from './fixtures';
|
||||
// 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经
|
||||
// api mock 断言——活动页以 token 取数,photoId 驱动 Task 8 深链(单图先行 + 序列
|
||||
// 兜底)。getPublicPhotos / getPublicPhoto 一并替身(Task 7 起活动页内嵌 PhotoFeed,
|
||||
// Task 8 起深链取单图)。
|
||||
// Task 8 起深链取单图)。Task 10 起活动页组装 WS/浏览上报:RealtimeClient 以
|
||||
// 无操作桩替换(jsdom WebSocket 会发起真实连接)、postAlbumVisit 替身防真实 fetch。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return {
|
||||
@@ -17,9 +18,18 @@ vi.mock('../api/client', async (importOriginal) => {
|
||||
getPublicAlbum: vi.fn(),
|
||||
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
||||
getPublicPhoto: vi.fn(),
|
||||
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../realtime/ws', () => {
|
||||
class RealtimeClientStub {
|
||||
start(): void {}
|
||||
stop(): void {}
|
||||
}
|
||||
return { RealtimeClient: RealtimeClientStub, wsUrlFromApiBase: () => 'ws://test/v1/realtime' };
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PublicPhoto, PublicPhotoList, SortMode } from '../api/client';
|
||||
import { getPublicPhotos } from '../api/client';
|
||||
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
||||
|
||||
// 排序切换状态机(Task 10):sort ref 变更 → 重置重取(携带 sort 参数);
|
||||
// 分页游标请求继承当前排序;切回 latest 重新取。冷快照语义在 e2e 覆盖。
|
||||
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, getPublicPhotos: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||||
|
||||
function makePhoto(id: string): PublicPhoto {
|
||||
return {
|
||||
photoId: id,
|
||||
width: 800,
|
||||
height: 500,
|
||||
thumb: { url: `http://img.test/${id}-thumb.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||
browse: { url: `http://img.test/${id}-browse.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||
likeCount: 0,
|
||||
publishedAt: '2026-10-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function makeList(items: PublicPhoto[], nextCursor: string | null = null): PublicPhotoList {
|
||||
return { items, nextCursor, hasMore: nextCursor !== null };
|
||||
}
|
||||
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
function mountSequence(): { sequence: PhotoSequence; sort: { value: SortMode } } {
|
||||
let sequence!: PhotoSequence;
|
||||
const sort = ref<SortMode>('latest');
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
sequence = usePhotoSequence({ token: ref('tok-sort'), locale: ref('zh-CN'), category: ref(null), sort });
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
mounted.push(mount(Host));
|
||||
return { sequence, sort };
|
||||
}
|
||||
|
||||
describe('sort toggle state machine (Task 10)', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) mounted.pop()?.unmount();
|
||||
});
|
||||
|
||||
it('loads page 1 with the default latest sort', async () => {
|
||||
const { sequence } = mountSequence();
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-sort', { locale: 'zh-CN', sort: 'latest' });
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2']);
|
||||
});
|
||||
|
||||
it('resets and refetches with sort=hot when the mode switches', async () => {
|
||||
const { sequence, sort } = mountSequence();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p2'), makePhoto('p1')], null));
|
||||
|
||||
sort.value = 'hot';
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-sort', { locale: 'zh-CN', sort: 'hot' });
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p2', 'p1']); // 快照整体替换,不追加
|
||||
expect(sequence.pagesLoaded).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the hot sort on cursor pagination', async () => {
|
||||
const { sequence, sort } = mountSequence();
|
||||
await flushPromises();
|
||||
sort.value = 'hot';
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p3')], null));
|
||||
|
||||
await sequence.loadMore();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
|
||||
'tok-sort',
|
||||
expect.objectContaining({ sort: 'hot', cursor: 'cur-1' }),
|
||||
);
|
||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p3']);
|
||||
});
|
||||
|
||||
it('switches back to latest with a fresh reset', async () => {
|
||||
const { sequence, sort } = mountSequence();
|
||||
await flushPromises();
|
||||
sort.value = 'hot';
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p1')], null));
|
||||
|
||||
sort.value = 'latest';
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-sort', { locale: 'zh-CN', sort: 'latest' });
|
||||
expect(sequence.photos).toHaveLength(1);
|
||||
expect(sequence.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../App.vue';
|
||||
import { ApiError, getPublicAlbum, getPublicPhoto, getPublicPhotos, type PublicPhoto, type PublicPhotoList } from '../api/client';
|
||||
import { ApiError, getPublicAlbum, getPublicPhoto, getPublicPhotos, postAlbumVisit, type PublicPhoto, type PublicPhotoList } from '../api/client';
|
||||
import { createViewerI18n } from '../i18n';
|
||||
import { router } from '../router';
|
||||
import { makeAlbum } from './fixtures';
|
||||
@@ -9,7 +9,8 @@ import { makeAlbum } from './fixtures';
|
||||
// 大图查看器路由编排(Task 8):URL 同步(打开 push / 切换 replace / 关闭 back 或
|
||||
// replace)、滚动锚点恢复、深链解析顺序(单图先行 → 相邻页构成序列)、深链 404
|
||||
// 本地化提示与返回链接。使用真实 router 单例(jsdom 提供_history),对齐
|
||||
// routing.spec.ts 的既定模式;api 三端点替身。
|
||||
// routing.spec.ts 的既定模式;api 三端点替身。Task 10 起活动页组装 WS/浏览
|
||||
// 上报:RealtimeClient 以无操作桩替换、postAlbumVisit 替身防真实 fetch。
|
||||
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
@@ -18,12 +19,22 @@ vi.mock('../api/client', async (importOriginal) => {
|
||||
getPublicAlbum: vi.fn(),
|
||||
getPublicPhotos: vi.fn(),
|
||||
getPublicPhoto: vi.fn(),
|
||||
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../realtime/ws', () => {
|
||||
class RealtimeClientStub {
|
||||
start(): void {}
|
||||
stop(): void {}
|
||||
}
|
||||
return { RealtimeClient: RealtimeClientStub, wsUrlFromApiBase: () => 'ws://test/v1/realtime' };
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||||
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
|
||||
const mockedPostAlbumVisit = vi.mocked(postAlbumVisit);
|
||||
|
||||
function makePhoto(id: string): PublicPhoto {
|
||||
return {
|
||||
@@ -64,6 +75,9 @@ describe('viewer url sync and deep link (Task 8)', () => {
|
||||
...makePhoto(photoId),
|
||||
categoryId: null,
|
||||
}));
|
||||
// afterEach 的 restoreAllMocks 会清工厂期实现:逐用例重置兜底值
|
||||
//(活动页加载成功即触发浏览上报,见 visitor.ts)。
|
||||
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -72,6 +86,10 @@ describe('viewer url sync and deep link (Task 8)', () => {
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
// restoreAllMocks 将工厂期实现清成返回 undefined 的裸 vi.fn:用例收尾后
|
||||
// 仍可能有在途的浏览上报微任务落到 postAlbumVisit(.then 于 undefined 即
|
||||
// 未处理拒绝)——恢复后立即补挂兜底实现。
|
||||
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
|
||||
});
|
||||
|
||||
it('open pushes the photo route, navigate replaces, close restores scroll and pops back', async () => {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ApiError, postAlbumVisit } from '../api/client';
|
||||
import { useVisitReport } from '../stats/visitor';
|
||||
import { VISITOR_STORAGE_KEY } from '../stats/visitor';
|
||||
|
||||
// useVisitReport(Task 10 浏览上报编排):加载成功且页面可见才报(隐藏时等
|
||||
// 首次可见)/ 单相册一次 / 网络失败同 requestId 退避重试 / 4xx 静默放弃 /
|
||||
// requestId 持久 sessionStorage 直到成功响应。visitorId 预置 localStorage
|
||||
// 走快路径(跨标签协调单独在 visitor-id.spec 覆盖)。
|
||||
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, postAlbumVisit: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedPostAlbumVisit = vi.mocked(postAlbumVisit);
|
||||
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
function mountReport(token: string): ReturnType<typeof useVisitReport> {
|
||||
let report!: ReturnType<typeof useVisitReport>;
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
report = useVisitReport({
|
||||
token: ref(token),
|
||||
onCounted: (count) => counted.push(count),
|
||||
});
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
mounted.push(mount(Host));
|
||||
return report;
|
||||
}
|
||||
|
||||
const counted: number[] = [];
|
||||
|
||||
/** jsdom document.hidden 只读:defineProperty 覆写(用例后还原)。 */
|
||||
function setHidden(hidden: boolean): void {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
|
||||
}
|
||||
|
||||
describe('visit report gating (Task 10)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem(VISITOR_STORAGE_KEY, 'v'.repeat(32));
|
||||
counted.length = 0;
|
||||
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 1, counted: true });
|
||||
setHidden(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) mounted.pop()?.unmount();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
// 收尾后在途上报微任务可能仍落到已还原的替身(返回 undefined → .then 崩):
|
||||
// 恢复后立即补挂兜底实现。
|
||||
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
|
||||
setHidden(false);
|
||||
});
|
||||
|
||||
it('reports once after album load while visible, then clears the persisted requestId', async () => {
|
||||
const report = mountReport('tok-visit');
|
||||
report.notifyAlbumLoaded();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
|
||||
const [token, body] = mockedPostAlbumVisit.mock.calls[0]!;
|
||||
expect(token).toBe('tok-visit');
|
||||
expect(body.visitorId).toBe('v'.repeat(32));
|
||||
expect(body.requestId).toMatch(/^[0-9a-f-]{8,}$/);
|
||||
expect(counted).toEqual([1]);
|
||||
expect(sessionStorage.getItem('viewer.visitRequest:tok-visit')).toBeNull(); // 确认后清除
|
||||
|
||||
report.notifyAlbumLoaded(); // 幂等:同相册只报一次
|
||||
await flushPromises();
|
||||
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('defers the report until the page becomes visible', async () => {
|
||||
setHidden(true);
|
||||
const report = mountReport('tok-hidden');
|
||||
report.notifyAlbumLoaded();
|
||||
await flushPromises();
|
||||
expect(mockedPostAlbumVisit).not.toHaveBeenCalled();
|
||||
|
||||
setHidden(false);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
await flushPromises();
|
||||
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries network failure with the same persisted requestId and succeeds', async () => {
|
||||
mockedPostAlbumVisit
|
||||
.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network request failed', 0, null))
|
||||
.mockResolvedValue({ viewCount: 6, counted: true });
|
||||
vi.useFakeTimers();
|
||||
const report = mountReport('tok-retry');
|
||||
report.notifyAlbumLoaded();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
|
||||
const requestId = mockedPostAlbumVisit.mock.calls[0]![1].requestId;
|
||||
// 失败期间 requestId 保持持久(刷新/重试幂等键不换)。
|
||||
expect(sessionStorage.getItem('viewer.visitRequest:tok-retry')).toBe(requestId);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(2);
|
||||
expect(mockedPostAlbumVisit.mock.calls[1]![1].requestId).toBe(requestId);
|
||||
expect(counted).toEqual([6]);
|
||||
expect(sessionStorage.getItem('viewer.visitRequest:tok-retry')).toBeNull();
|
||||
});
|
||||
|
||||
it('gives up silently on 4xx without retrying', async () => {
|
||||
mockedPostAlbumVisit.mockRejectedValue(new ApiError('RATE_LIMITED', 'too many', 429, null));
|
||||
vi.useFakeTimers();
|
||||
const report = mountReport('tok-4xx');
|
||||
report.notifyAlbumLoaded();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1); // 无重试
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
resolveVisitorId,
|
||||
VISITOR_STORAGE_KEY,
|
||||
type BroadcastChannelLike,
|
||||
} from '../stats/visitor';
|
||||
|
||||
// resolveVisitorId(Task 10 跨标签协调):localStorage 快路径 / 冷启动
|
||||
// query→claim 探询采纳 / 自 claim 写读回校验 + 宣告 / 存储不可用页面级随机。
|
||||
// BroadcastChannel 与 storage 全注入(jsdom 无 BroadcastChannel)。
|
||||
|
||||
/** 内存存储假件(可编程失败)。 */
|
||||
function memoryStorage(initial: Record<string, string> = {}): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> & { data: Map<string, string> } {
|
||||
const data = new Map(Object.entries(initial));
|
||||
return {
|
||||
data,
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem(key: string, value: string) {
|
||||
data.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
data.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeChannel extends BroadcastChannelLike {
|
||||
posted: unknown[];
|
||||
deliver(data: unknown): void;
|
||||
}
|
||||
|
||||
function fakeChannel(): { create: () => FakeChannel; instances: FakeChannel[] } {
|
||||
const instances: FakeChannel[] = [];
|
||||
return {
|
||||
instances,
|
||||
create: () => {
|
||||
const channel: FakeChannel = {
|
||||
posted: [],
|
||||
onmessage: null,
|
||||
postMessage(data: unknown) {
|
||||
channel.posted.push(data);
|
||||
},
|
||||
close() {},
|
||||
deliver(data: unknown) {
|
||||
channel.onmessage?.({ data });
|
||||
},
|
||||
};
|
||||
instances.push(channel);
|
||||
return channel;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('visitor id cross-tab coordination (Task 10)', () => {
|
||||
it('returns the stored id immediately without channel traffic', async () => {
|
||||
const storage = memoryStorage({ [VISITOR_STORAGE_KEY]: 'feedface'.padEnd(32, '0') });
|
||||
const factory = vi.fn(() => null);
|
||||
const id = await resolveVisitorId({ storage, channelFactory: factory, queryWindowMs: 5 });
|
||||
expect(id).toBe('feedface'.padEnd(32, '0'));
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cold start claims, verifies read-back and announces the claim', async () => {
|
||||
const storage = memoryStorage();
|
||||
const { create, instances } = fakeChannel();
|
||||
const id = await resolveVisitorId({ storage, channelFactory: create, queryWindowMs: 5 });
|
||||
|
||||
expect(id).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(storage.data.get(VISITOR_STORAGE_KEY)).toBe(id); // 持久 + 读回一致
|
||||
const posted = instances[0]!.posted;
|
||||
expect(posted[0]).toEqual({ type: 'query' }); // 先探询
|
||||
const claim = posted[1] as { type: string; visitorId: string };
|
||||
expect(claim.type).toBe('claim'); // 后宣告
|
||||
expect(claim.visitorId).toBe(id);
|
||||
});
|
||||
|
||||
it('adopts an existing tab claim answer to the query', async () => {
|
||||
const storage = memoryStorage();
|
||||
const { create, instances } = fakeChannel();
|
||||
// 模拟既有标签页:收到 query 后回 claim。
|
||||
const factory = () => {
|
||||
const channel = create();
|
||||
const original = channel.postMessage.bind(channel);
|
||||
channel.postMessage = (data: unknown) => {
|
||||
original(data);
|
||||
if ((data as { type?: string }).type === 'query') {
|
||||
setTimeout(() => channel.deliver({ type: 'claim', visitorId: 'a'.repeat(32), claimedAt: Date.now() }), 2);
|
||||
}
|
||||
};
|
||||
return channel;
|
||||
};
|
||||
|
||||
const id = await resolveVisitorId({ storage, channelFactory: factory, queryWindowMs: 20 });
|
||||
expect(id).toBe('a'.repeat(32));
|
||||
expect(storage.data.get(VISITOR_STORAGE_KEY)).toBe('a'.repeat(32));
|
||||
expect(instances[0]!.posted.some((frame) => (frame as { type?: string }).type === 'claim')).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to a per-page random id when storage writes fail', async () => {
|
||||
// 注入 undefined 会回落 jsdom 真实 localStorage——以写失败存储模拟不可用。
|
||||
const throwing = {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error('quota exceeded');
|
||||
},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const first = await resolveVisitorId({ storage: throwing, channelFactory: () => null, queryWindowMs: 1 });
|
||||
const second = await resolveVisitorId({ storage: throwing, channelFactory: () => null, queryWindowMs: 1 });
|
||||
expect(first).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(second).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(first).not.toBe(second); // 页面级随机(不持久)
|
||||
});
|
||||
|
||||
it('keeps a working plain-localStorage path when BroadcastChannel is unavailable', async () => {
|
||||
const storage = memoryStorage();
|
||||
const id = await resolveVisitorId({ storage, channelFactory: () => null, queryWindowMs: 5 });
|
||||
expect(id).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(storage.data.get(VISITOR_STORAGE_KEY)).toBe(id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user