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:
浅念
2026-09-18 19:52:27 +08:00
parent 955bea49b5
commit cdead49838
29 changed files with 2581 additions and 29 deletions
+13 -2
View File
@@ -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);
+169
View File
@@ -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);
});
});
+11 -1
View File
@@ -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);
+109
View File
@@ -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);
});
});
+20 -2
View File
@@ -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);
});
});