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
+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 () => {