diff --git a/apps/viewer/e2e/album.spec.ts b/apps/viewer/e2e/album.spec.ts index d9966c4..173f1b9 100644 --- a/apps/viewer/e2e/album.spec.ts +++ b/apps/viewer/e2e/album.spec.ts @@ -50,10 +50,12 @@ test('renders album info in zh with timezone-adjusted time, chips and empty stat await expect(time).not.toContainText('9月30'); await expect(page.locator('.meta')).toContainText('大宴会厅 · 上海市浦东新区世纪大道 100 号'); - const chips = page.locator('.chip'); + // Task 10 起排序 chips(最新/热门)与分类 chips 并存——断言收窄到分类栏。 + const chips = page.locator('.categories .chip'); await expect(chips).toHaveCount(3); // Task 7 起:全部 + 各分类(可点选) await expect(chips.first()).toBeEnabled(); await expect(chips).toHaveText(['全部', '签到墙', '开场']); + await expect(page.locator('.sort .chip')).toHaveText(['最新', '热门']); await expect(page.locator('.empty-notice')).toHaveText('照片即将上线'); // 该相册无 banner 资产 → 不渲染图。 diff --git a/apps/viewer/e2e/journey.spec.ts b/apps/viewer/e2e/journey.spec.ts index e33b196..eee0cfd 100644 --- a/apps/viewer/e2e/journey.spec.ts +++ b/apps/viewer/e2e/journey.spec.ts @@ -382,7 +382,7 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)', .poll(async () => banner.evaluate((img) => (img as HTMLImageElement).naturalWidth)) .toBeGreaterThan(0); await expect(page.locator('h1.title')).toHaveText(zhTitle); - await expect(page.locator('.chip')).toHaveCount(2); // 全部 + 主舞台 + await expect(page.locator('.categories .chip')).toHaveCount(2); // 全部 + 主舞台 await page.locator('button.locale-switcher').click(); await expect(page.locator('h1.title')).toHaveText(enTitle); @@ -396,6 +396,10 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)', await page.getByRole('button', { name: '全部' }).click(); await expect(page.locator('.cell')).toHaveCount(3); + // Task 10 互动化:浏览计数行(首访上报后权威回填)+ 排序 chips 在位。 + await expect(page.locator('.view-count')).toHaveText('1', { timeout: 15_000 }); + await expect(page.locator('.sort .chip')).toHaveText(['最新', '热门']); + // 大图:点击开图 → 真实字节 → 滚轮缩放 → 双击复位 → 关闭回列表。 const lightboxPhoto = photoIds[1]!; // 800×500(后续 admin 腿按尺寸锚定隐藏) await page.locator('.cell').nth(1).click(); @@ -403,6 +407,9 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)', await expect( page.locator('img.photo'), ).toHaveAttribute('src', new RegExp(lightboxPhoto)); + // Task 10:大图内真实点赞 1 次 → 防抖冲批 → 权威计数 1。 + await page.locator('.control.like-button').click(); + await expect(page.locator('.like-count')).toHaveText('1'); await expect .poll(async () => page.locator('img.photo').evaluate((img) => (img as HTMLImageElement).naturalWidth), diff --git a/apps/viewer/e2e/likes.spec.ts b/apps/viewer/e2e/likes.spec.ts new file mode 100644 index 0000000..f116f43 --- /dev/null +++ b/apps/viewer/e2e/likes.spec.ts @@ -0,0 +1,263 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test, type Page } from '@playwright/test'; +import { + cleanupSeededAlbums, + deleteSeededPhoto, + flushSync, + seedAlbum, + seedPhoto, +} from './helpers'; + +// Task 10 互动化 Playwright(真实后端 + 真实 WS/点赞/浏览管线): +// · 大图真实点赞 ×3 → 计数权威 3 → 刷新持久; +// · 气泡自身过滤(双真实浏览器上下文:他赞出气泡、己赞不出); +// · WS 不可达(__viewerWsUrl 死端口注入;setOffline 不切断已建 WS,见用例注) +// → 三败降级轮询校准计数 → 翻回真实 URL 重连 open; +// · 照片下线 WS 推送 → 序列即时移除 + 大图回退邻居(deleteSeededPhoto+flushSync); +// · 排序切换(最新/热门):API 直推 B 点赞 → 热门置顶;大图开时 chips 禁用; +// · 浏览上报:全新上下文计数 +1,刷新不重复,第二上下文再 +1; +// · 减少动画:他赞无气泡、计数照常校准。 +// e2e 注入点:__viewerRealtimePollMs / __viewerWsRetryBaseMs / __viewerBubbleMs / +// __viewerWsUrl(沿用 __viewerPollMs 约定);连接态锚 .activity[data-connection]。 + +const OV_BASE = 'http://127.0.0.1:8081/v1'; + +test.afterAll(async () => { + await cleanupSeededAlbums(); +}); + +/** 直推一个点赞批次(他者会话面,绕开 UI 时序):返回权威 total。 */ +async function apiLike(token: string, photoId: string, sessionId: string, delta: number): Promise { + const res = await fetch(`${OV_BASE}/public/albums/${token}/photos/${photoId}/likes`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionId, batchId: randomUUID(), delta }), + }); + expect(res.status).toBe(200); + return ((await res.json()) as { total: number }).total; +} + +/** 种「互动相册」:n 张已发布照片(publishedAt 逐张递减,最新序确定)。 */ +async function seedInteractiveAlbum( + alias: string, + photos: number, +): Promise<{ albumId: string; token: string; photoIds: string[] }> { + const album = await seedAlbum({ + alias, + translations: [{ locale: 'zh-CN', title: `互动测试相册 ${alias}` }], + }); + const base = Date.now(); + const photoIds: string[] = []; + for (let i = 0; i < photos; i++) { + photoIds.push( + await seedPhoto(album.id, `${alias}-p${i}`, { + width: 480, + height: 300, + publishedAt: new Date(base - (i + 1) * 60_000), + }), + ); + } + await flushSync(album.id); + return { albumId: album.id, token: album.publicToken, photoIds }; +} + +async function openAlbum(page: Page, token: string, init?: () => void): Promise { + await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN')); + if (init) await page.addInitScript(init); + await page.goto(`/a/${token}`); + await expect(page.locator('.cell').first()).toBeVisible(); +} + +test('lightbox likes accumulate to 3 and persist across reload', async ({ page }) => { + test.setTimeout(90_000); + const { token } = await seedInteractiveAlbum('likebox', 1); + await openAlbum(page, token); + + await page.locator('.cell').first().click(); + await expect(page.locator('.lightbox')).toBeVisible(); + for (let i = 0; i < 3; i++) { + await page.locator('.control.like-button').click(); + } + // 防抖 500ms 冲批 → 服务端权威校准 3(响应/WS burst 先后到达均为 3)。 + await expect(page.locator('.like-count')).toHaveText('3'); + // 等批次真正落服务端(乐观显示先于冲批——刷新前必须确认已持久)。 + await expect + .poll(async () => { + const res = await fetch(`${OV_BASE}/public/albums/${token}/photos`); + const json = (await res.json()) as { items: Array<{ likeCount: number }> }; + return json.items[0]?.likeCount ?? 0; + }) + .toBe(3); + + // 刷新后从服务端列表回归:深链自动重开大图(URL 仍停在该照片),计数 3; + // 关闭后格子计数亦 3(sessionId 会话内持久,幂等面不变)。 + await page.reload(); + await expect(page.locator('.lightbox')).toBeVisible(); + await expect(page.locator('.like-count')).toHaveText('3'); + await page.locator('.control.close').click(); + await expect(page.locator('.lightbox')).toHaveCount(0); + await expect(page.locator('.cell-like .count').first()).toHaveText('3'); +}); + +test('others burst bubbles on my page; my own like does not', async ({ browser, page }) => { + test.setTimeout(120_000); + const { token } = await seedInteractiveAlbum('bubbles', 1); + const ctx2 = await browser.newContext(); + const page2 = await ctx2.newPage(); + try { + await openAlbum(page, token, () => { + (window as unknown as { __viewerBubbleMs?: number }).__viewerBubbleMs = 8000; + }); + await expect(page.locator('.activity[data-connection="open"]')).toBeVisible(); + await openAlbum(page2, token); + await expect(page2.locator('.activity[data-connection="open"]')).toBeVisible(); + + // 己赞:burst 的 originBatchIds 全属本会话 → 过滤,不出气泡。 + await page.locator('.cell-like').first().click(); + await page.waitForTimeout(2500); // 冲批(500ms)+广播合并窗(500ms)+裕量 + await expect(page.locator('.like-bubble')).toHaveCount(0); + await expect(page.locator('.cell-like .count').first()).toHaveText('1'); + + // 他赞(page2 真实点击):page1 应出 +1 气泡,点击直达该照片大图。 + await page2.locator('.cell-like').first().click(); + await expect(page.locator('.like-bubble')).toHaveCount(1); + await expect(page.locator('.like-bubble .bubble-delta')).toHaveText('+1'); + await expect(page.locator('.cell-like .count').first()).toHaveText('2'); + await page.locator('.like-bubble').click(); + await expect(page.locator('.lightbox')).toBeVisible(); + } finally { + await ctx2.close(); + } +}); + +test('WS loss degrades to polling and recovers to open with calibrated counts', async ({ page }) => { + test.setTimeout(150_000); + const { token, photoIds } = await seedInteractiveAlbum('degrade', 1); + const photoId = photoIds[0]!; + // 注:Chromium 的 setOffline 不切断已建立的 WS(实测保持 open),中途改 URL 也 + // 只影响后续连接——改为「首连即不可达」驱动降级:__viewerWsUrl 指向死端口 + // (error+close 3ms、无 open),HTTP 面保持可用,恰好可断言降级轮询的校准职责; + // 运行中翻回真实 URL 验证后台重连恢复。 + await openAlbum(page, token, () => { + (window as unknown as Record).__viewerWsUrl = 'ws://127.0.0.1:9/dead'; + (window as unknown as Record).__viewerWsRetryBaseMs = 300; + (window as unknown as Record).__viewerRealtimePollMs = 1000; + }); + + // 三次连接失败(3ms 内 close,退避 300/600/1200ms)→ degraded(轮询接管,此处 1s)。 + await expect(page.locator('.activity[data-connection="degraded"]')).toBeVisible({ + timeout: 30_000, + }); + + // 降级期间他者经 API 点赞 2:WS 已死,靠轮询第 1 页重取把计数对齐到 2。 + await apiLike(token, photoId, 'e2e-degrade-other', 2); + await expect(page.locator('.cell-like .count').first()).toHaveText('2', { timeout: 15_000 }); + await expect(page.locator('.activity[data-connection="degraded"]')).toBeVisible(); // 仍降级(无气泡补播面) + + // 翻回真实 URL:后台重连成功 → open,计数维持权威 2。 + await page.evaluate(() => { + (window as unknown as Record).__viewerWsUrl = ''; + }); + await expect(page.locator('.activity[data-connection="open"]')).toBeVisible({ + timeout: 30_000, + }); + await expect(page.locator('.cell-like .count').first()).toHaveText('2'); +}); + +test('hidden photo disappears from the feed and closes the lightbox via WS push', async ({ page }) => { + test.setTimeout(120_000); + const { albumId, token, photoIds } = await seedInteractiveAlbum('remove', 3); + await openAlbum(page, token); + await expect(page.locator('.activity[data-connection="open"]')).toBeVisible(); + await expect(page.locator('.cell')).toHaveCount(3); + + // 大图停在第 2 张:下线推送应回退邻居并收口(closeLightbox 路径)。 + await page.locator('.cell').nth(1).click(); + await expect(page.locator('.lightbox')).toBeVisible(); + + await deleteSeededPhoto(photoIds[1]!); + await flushSync(albumId); // photo.deleted 走真实管线 → ov 撤下 + WS 房间广播 + + // 推送到达:列表无刷新即时 2 张 + 下线提示;大图回退到后邻(第 3 张)仍可看。 + await expect(page.locator('.cell')).toHaveCount(2, { timeout: 20_000 }); + await expect(page.locator('.removed-notice')).toBeVisible(); + await expect(page.locator('.lightbox')).toBeVisible(); + await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[2]!)); +}); + +test('sort toggle switches latest/hot snapshots and is disabled in the lightbox', async ({ page }) => { + test.setTimeout(120_000); + const { token, photoIds } = await seedInteractiveAlbum('sort', 2); + const [newer, older] = photoIds as [string, string]; + + await openAlbum(page, token); + // 最新序:publishedAt 新者(第 1 张种)在前。 + await expect(page.locator('.cell-img').first()).toHaveAttribute('src', new RegExp(newer)); + + // 他者会话直推 older ×10 → 热门序首取即新鲜快照:older 置顶。 + await apiLike(token, older, 'e2e-hot-session', 10); + await page.getByRole('button', { name: '热门' }).click(); + await expect(page.locator('.cell-img').first()).toHaveAttribute('src', new RegExp(older)); + await expect(page.locator('.sort .chip').nth(1)).toHaveClass(/active/); + + // 切回最新:原顺序恢复(冷快照语义,各自独立)。 + await page.getByRole('button', { name: '最新' }).click(); + await expect(page.locator('.cell-img').first()).toHaveAttribute('src', new RegExp(newer)); + + // 大图打开期间排序切换禁用(快照冻结,避免半开序列换序)。 + await page.locator('.cell').first().click(); + await expect(page.locator('.lightbox')).toBeVisible(); + await expect(page.locator('.sort .chip').first()).toBeDisabled(); + await expect(page.locator('.sort .chip').nth(1)).toBeDisabled(); +}); + +test('visit counting: fresh context counts once, reload is idempotent, second context counts', async ({ + browser, +}) => { + test.setTimeout(120_000); + const { token } = await seedInteractiveAlbum('visits', 1); + + const ctx1 = await browser.newContext(); + const page1 = await ctx1.newPage(); + try { + await openAlbum(page1, token); + // 首访计数(上报成功后以权威 viewCount 回填)。 + await expect(page1.locator('.view-count')).toHaveText('1', { timeout: 15_000 }); + + // 刷新:visitorId 持久 + 窗口去重 → 不再计数。 + await page1.reload(); + await expect(page1.locator('.cell').first()).toBeVisible(); + await expect(page1.locator('.view-count')).toHaveText('1'); + + // 第二个全新上下文:新 visitorId → 再计数。 + const ctx2 = await browser.newContext(); + try { + const page2 = await ctx2.newPage(); + await openAlbum(page2, token); + await expect(page2.locator('.view-count')).toHaveText('2', { timeout: 15_000 }); + } finally { + await ctx2.close(); + } + } finally { + await ctx1.close(); + } +}); + +test('reduced motion suppresses bubbles while counts still calibrate', async ({ page }) => { + test.setTimeout(120_000); + const { token, photoIds } = await seedInteractiveAlbum('motion', 1); + await openAlbum(page, token, () => { + (window as unknown as Record).__viewerBubbleMs = 8000; + }); + await expect(page.locator('.activity[data-connection="open"]')).toBeVisible(); + + // 顶栏开关减少动画(localStorage 偏好持久)。 + await page.locator('.motion-toggle').click(); + await expect(page.locator('.motion-toggle')).toHaveAttribute('aria-pressed', 'true'); + + // 他者点赞:无气泡(动画停用),计数照常被 burst 校准。 + await apiLike(token, photoIds[0]!, 'e2e-motion-other', 1); + await page.waitForTimeout(2500); // 广播合并窗 + 裕量:给「本该出气泡」的窗口 + await expect(page.locator('.like-bubble')).toHaveCount(0); + await expect(page.locator('.cell-like .count').first()).toHaveText('1'); +}); diff --git a/apps/viewer/src/App.vue b/apps/viewer/src/App.vue index 7653ff4..e45feb1 100644 --- a/apps/viewer/src/App.vue +++ b/apps/viewer/src/App.vue @@ -2,6 +2,7 @@ import { watchEffect } from 'vue'; import { useI18n } from 'vue-i18n'; import LocaleSwitcher from './components/LocaleSwitcher.vue'; +import { reducedMotion, toggleReducedMotion } from './ui/prefs'; const { t, locale } = useI18n(); @@ -16,7 +17,18 @@ watchEffect(() => {
{{ t('app.title') }} - +
+ + + +
@@ -49,6 +61,25 @@ body { .brand { font-weight: 600; } +.topbar-actions { + display: flex; + align-items: center; + gap: 12px; +} +.motion-toggle { + border: 1px solid #d1d5db; + border-radius: 6px; + background: #f9fafb; + color: #374151; + padding: 2px 10px; + font-size: 0.875rem; + cursor: pointer; +} +.motion-toggle[aria-pressed='true'] { + border-color: #1d4ed8; + background: #eff6ff; + color: #1d4ed8; +} .content { flex: 1; padding: 16px; diff --git a/apps/viewer/src/__tests__/activity.spec.ts b/apps/viewer/src/__tests__/activity.spec.ts index 9e99e03..ec0a88c 100644 --- a/apps/viewer/src/__tests__/activity.spec.ts +++ b/apps/viewer/src/__tests__/activity.spec.ts @@ -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(); 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); diff --git a/apps/viewer/src/__tests__/likes.spec.ts b/apps/viewer/src/__tests__/likes.spec.ts new file mode 100644 index 0000000..2acd190 --- /dev/null +++ b/apps/viewer/src/__tests__/likes.spec.ts @@ -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(); + 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); + }); +}); diff --git a/apps/viewer/src/__tests__/realtime-router.spec.ts b/apps/viewer/src/__tests__/realtime-router.spec.ts new file mode 100644 index 0000000..228739d --- /dev/null +++ b/apps/viewer/src/__tests__/realtime-router.spec.ts @@ -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 { + 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 & { 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')); + }); +}); diff --git a/apps/viewer/src/__tests__/realtime-ws.spec.ts b/apps/viewer/src/__tests__/realtime-ws.spec.ts new file mode 100644 index 0000000..f6760c4 --- /dev/null +++ b/apps/viewer/src/__tests__/realtime-ws.spec.ts @@ -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 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[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); + }); +}); diff --git a/apps/viewer/src/__tests__/routing.spec.ts b/apps/viewer/src/__tests__/routing.spec.ts index 691b92d..ee446dc 100644 --- a/apps/viewer/src/__tests__/routing.spec.ts +++ b/apps/viewer/src/__tests__/routing.spec.ts @@ -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(); 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); diff --git a/apps/viewer/src/__tests__/sort.spec.ts b/apps/viewer/src/__tests__/sort.spec.ts new file mode 100644 index 0000000..3e78ba4 --- /dev/null +++ b/apps/viewer/src/__tests__/sort.spec.ts @@ -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(); + 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('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); + }); +}); diff --git a/apps/viewer/src/__tests__/viewer-route.spec.ts b/apps/viewer/src/__tests__/viewer-route.spec.ts index 38655f2..b332687 100644 --- a/apps/viewer/src/__tests__/viewer-route.spec.ts +++ b/apps/viewer/src/__tests__/viewer-route.spec.ts @@ -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(); @@ -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 () => { diff --git a/apps/viewer/src/__tests__/visit-report.spec.ts b/apps/viewer/src/__tests__/visit-report.spec.ts new file mode 100644 index 0000000..a56bdae --- /dev/null +++ b/apps/viewer/src/__tests__/visit-report.spec.ts @@ -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(); + return { ...actual, postAlbumVisit: vi.fn() }; +}); + +const mockedPostAlbumVisit = vi.mocked(postAlbumVisit); + +const mounted: VueWrapper[] = []; + +function mountReport(token: string): ReturnType { + let report!: ReturnType; + 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); // 无重试 + }); +}); diff --git a/apps/viewer/src/__tests__/visitor-id.spec.ts b/apps/viewer/src/__tests__/visitor-id.spec.ts new file mode 100644 index 0000000..28e240e --- /dev/null +++ b/apps/viewer/src/__tests__/visitor-id.spec.ts @@ -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 = {}): Pick & { data: Map } { + 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); + }); +}); diff --git a/apps/viewer/src/api/client.ts b/apps/viewer/src/api/client.ts index ff44951..9907b11 100644 --- a/apps/viewer/src/api/client.ts +++ b/apps/viewer/src/api/client.ts @@ -6,6 +6,15 @@ export type PublicPhoto = components['schemas']['PublicPhoto']; export type PublicPhotoList = components['schemas']['PublicPhotoList']; export type PublicPhotoDetail = components['schemas']['PublicPhotoDetail']; +// P2 Task 10 互动端点类型(likes/visits,ov 域部署;路径同形、域名区分)。 +export type LikeBatchRequest = components['schemas']['LikeBatchRequest']; +export type LikeBatchResponse = components['schemas']['LikeBatchResponse']; +export type VisitReportRequest = components['schemas']['VisitReportRequest']; +export type VisitReportResponse = components['schemas']['VisitReportResponse']; + +/** 公开照片排序模式(sort=hot 仅 ov 实现:likeTotal 倒序短期快照,§7)。 */ +export type SortMode = 'latest' | 'hot'; + /** 客户端合成码:fetch 本身失败(离线/DNS),非服务端错误信封。 */ export const NETWORK_ERROR_CODE = 'NETWORK_ERROR'; @@ -106,6 +115,7 @@ export type PublicPhotosQuery = { cursor?: string; limit?: number; locale?: string; + sort?: SortMode; }; /** 公开照片列表(游标分页;Task 7 瀑布流数据源)。 */ @@ -119,3 +129,20 @@ export function getPublicPhoto(token: string, photoId: string, locale?: string): withQuery(`/public/albums/${encodeURIComponent(token)}/photos/${encodeURIComponent(photoId)}`, { locale }), ); } + +/** 点赞批次上报(Task 10):失败重试必须复用同一 batchId(服务端幂等键)。 */ +export function postLikeBatch(token: string, photoId: string, body: LikeBatchRequest): Promise { + return apiRequest( + `/public/albums/${encodeURIComponent(token)}/photos/${encodeURIComponent(photoId)}/likes`, + { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }, + ); +} + +/** 浏览上报(Task 10):requestId 为幂等键,counted 确认前跨重试持久复用。 */ +export function postAlbumVisit(token: string, body: VisitReportRequest): Promise { + return apiRequest(`/public/albums/${encodeURIComponent(token)}/visits`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} diff --git a/apps/viewer/src/components/LikeBubbles.vue b/apps/viewer/src/components/LikeBubbles.vue new file mode 100644 index 0000000..c1aac8e --- /dev/null +++ b/apps/viewer/src/components/LikeBubbles.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/apps/viewer/src/components/PhotoFeed.vue b/apps/viewer/src/components/PhotoFeed.vue index 9732177..18daacc 100644 --- a/apps/viewer/src/components/PhotoFeed.vue +++ b/apps/viewer/src/components/PhotoFeed.vue @@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import type { PublicPhoto } from '../api/client'; import type { PhotoSequence } from '../feed/sequence'; +import type { LikesController } from '../likes/useLikes'; import { layoutWaterfall } from '../feed/layout'; // 观众瀑布流(Task 7;Task 8 起数据层上提):双列绝对定位(短列优先,条目高按 @@ -21,6 +22,8 @@ import { layoutWaterfall } from '../feed/layout'; const props = defineProps<{ sequence: PhotoSequence; + /** 点赞控制器(Task 10):缺省不渲染单元格点赞位(组件单测组装)。 */ + likes?: LikesController; }>(); const emit = defineEmits<{ @@ -154,6 +157,11 @@ async function onImgError(photo: PublicPhoto): Promise { if (result !== 'replaced') failedIds.add(photo.photoId); } +/** 单元格点赞(Task 10):乐观 +1,不冒泡打开大图。 */ +function likeCell(photo: PublicPhoto): void { + props.likes?.like(photo.photoId); +} + onBeforeUnmount(() => { lazyObserver?.disconnect(); sentinelObserver?.disconnect(); @@ -202,6 +210,16 @@ onBeforeUnmount(() => { decoding="async" @error="onImgError(photos[placement.index])" > +
@@ -284,6 +302,43 @@ onBeforeUnmount(() => { height: 100%; object-fit: cover; } +.cell-like { + position: absolute; + left: 6px; + bottom: 6px; + display: inline-flex; + align-items: center; + gap: 4px; + border: none; + border-radius: 999px; + background: rgba(17, 24, 39, 0.65); + color: #f9fafb; + padding: 2px 8px; + font-size: 0.75rem; + cursor: pointer; +} +.cell-like .heart { + color: #ef4444; + display: inline-block; + animation: heart-pop 400ms ease-out; +} +.cell-like .count { + font-variant-numeric: tabular-nums; +} +@keyframes heart-pop { + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.6); + } + 100% { + transform: scale(1); + } +} +.viewer-reduced-motion .cell-like .heart { + animation: none; +} .end-note { text-align: center; color: #9ca3af; diff --git a/apps/viewer/src/components/PhotoLightbox.vue b/apps/viewer/src/components/PhotoLightbox.vue index 20688ac..d5e1df4 100644 --- a/apps/viewer/src/components/PhotoLightbox.vue +++ b/apps/viewer/src/components/PhotoLightbox.vue @@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import type { PhotoSequence } from '../feed/sequence'; +import type { LikesController } from '../likes/useLikes'; import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom'; // 大图查看器(Task 8):browse URL 全屏展示 + 左右切换(保持列表浏览序列,越过 @@ -24,6 +25,8 @@ import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from const props = defineProps<{ sequence: PhotoSequence; photoId: string; + /** 点赞控制器(Task 10):缺省不渲染点赞位(组件单测/降级组装)。 */ + likes?: LikesController; }>(); const emit = defineEmits<{ @@ -253,6 +256,13 @@ function preload(url: string): void { img.src = url; } +// —— 点赞(Task 10):乐观 +1 与心跳动画由 LikesController 承担 —— + +function likeCurrent(): void { + if (photo.value === null || props.likes === undefined) return; + props.likes.like(photo.value.photoId); +} + // —— 签名过期/下线处理 —— /** 大图加载失败:静默重取单图接口换新签名 URL;404 → 通知父级移除;其他失败可重试。 */ @@ -327,7 +337,17 @@ onBeforeUnmount(() => {
{{ t('viewer.counter', { current: index + 1, total }) }} - + +