diff --git a/apps/viewer/e2e/album.spec.ts b/apps/viewer/e2e/album.spec.ts index aa88ede..7d96bb5 100644 --- a/apps/viewer/e2e/album.spec.ts +++ b/apps/viewer/e2e/album.spec.ts @@ -48,9 +48,9 @@ test('renders album info in zh with timezone-adjusted time, chips and empty stat await expect(page.locator('.meta')).toContainText('大宴会厅 · 上海市浦东新区世纪大道 100 号'); const chips = page.locator('.chip'); - await expect(chips).toHaveCount(2); - await expect(chips.first()).toBeDisabled(); - await expect(chips).toHaveText(['签到墙', '开场']); + await expect(chips).toHaveCount(3); // Task 7 起:全部 + 各分类(可点选) + await expect(chips.first()).toBeEnabled(); + await expect(chips).toHaveText(['全部', '签到墙', '开场']); await expect(page.locator('.empty-notice')).toHaveText('照片即将上线'); // 该相册无 banner 资产 → 不渲染图。 diff --git a/apps/viewer/e2e/feed.spec.ts b/apps/viewer/e2e/feed.spec.ts new file mode 100644 index 0000000..dabecd4 --- /dev/null +++ b/apps/viewer/e2e/feed.spec.ts @@ -0,0 +1,132 @@ +import { expect, test, type Page } from '@playwright/test'; +import { cleanupSeededAlbums, seedAlbum, seedCategory, seedPhoto } from './helpers'; + +// Task 7 瀑布流 Playwright(真实后端):35 张已发布照片(公开列表默认页 30)→ +// 初始 30 + 滚动取第 2 页 5 张;分类 chips 过滤;新照片轮询提示(__viewerPollMs +// 注入缩短间隔,见 PhotoFeed pollIntervalMs 的 dev/test 覆盖钩子)。 + +test.afterAll(async () => { + await cleanupSeededAlbums(); +}); + +/** 5 种宽高比例轮换(瀑布流高度预留的真实形状输入)。 */ +const RATIOS: Array<{ width: number; height: number }> = [ + { width: 480, height: 300 }, + { width: 300, height: 480 }, + { width: 400, height: 400 }, + { width: 360, height: 480 }, + { width: 480, height: 270 }, +]; + +const TOTAL = 35; // 30(第 1 页)+ 5(第 2 页) + +/** 种「瀑布流相册」:2 分类 + 35 张已发布照片(前 20 张 catA、后 15 张 catB; + * publishedAt 逐张递减 60s 保证列表顺序确定)。 */ +async function seedFeedAlbum(alias: string): Promise<{ albumId: string; token: string }> { + const album = await seedAlbum({ + alias, + translations: [{ locale: 'zh-CN', title: '瀑布流测试相册' }], + }); + const catA = await seedCategory(album.id, 0, [{ locale: 'zh-CN', name: '签到墙' }]); + const catB = await seedCategory(album.id, 1, [{ locale: 'zh-CN', name: '开场' }]); + const base = Date.now(); + for (let i = 0; i < TOTAL; i++) { + const ratio = RATIOS[i % RATIOS.length]!; + await seedPhoto(album.id, `${alias}-p${i}`, { + categoryId: i < 20 ? catA : catB, + width: ratio.width, + height: ratio.height, + publishedAt: new Date(base - (i + 1) * 60_000), + }); + } + return { albumId: album.id, token: album.publicToken }; +} + +/** 反复滚到底直至游标耗尽(end-note 出现)或循环上限。 */ +async function scrollToLoadAll(page: Page): Promise { + for (let i = 0; i < 15; i++) { + if ((await page.locator('.end-note').count()) > 0) return; + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(400); + } +} + +test('scroll loads both pages until exhausted and thumbs decode', async ({ page }) => { + test.setTimeout(90_000); + const { token } = await seedFeedAlbum('scroll'); + await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN')); + await page.goto(`/a/${token}`); + + // 第 1 页 30 张 + 哨兵在位(hasMore)。 + await expect(page.locator('.cell')).toHaveCount(30); + await expect(page.locator('.sentinel')).toHaveCount(1); + + // 懒加载:首屏缩略图经签名 URL 取回真实字节(解码成功 naturalWidth > 0)。 + await expect + .poll(async () => + page.locator('img.cell-img').first().evaluate((img) => (img as HTMLImageElement).naturalWidth), + ) + .toBeGreaterThan(0); + + await scrollToLoadAll(page); + + // 第 2 页 5 张追加,游标耗尽 → 哨兵卸载、底部提示出现。 + await expect(page.locator('.cell')).toHaveCount(35); + await expect(page.locator('.end-note')).toHaveText('已经到底了'); + await expect(page.locator('.sentinel')).toHaveCount(0); +}); + +test('category chips filter the feed; All restores pagination', async ({ page }) => { + test.setTimeout(90_000); + const { token } = await seedFeedAlbum('filter'); + await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN')); + await page.goto(`/a/${token}`); + await expect(page.locator('.cell')).toHaveCount(30); + + await page.getByRole('button', { name: '签到墙' }).click(); + await expect(page.locator('.cell')).toHaveCount(20); + await expect(page.locator('.end-note')).toBeVisible(); // 20 ≤ 30 单页即尽 + + await page.getByRole('button', { name: '开场' }).click(); + await expect(page.locator('.cell')).toHaveCount(15); + + await page.getByRole('button', { name: '全部' }).click(); + await expect(page.locator('.cell')).toHaveCount(30); + await scrollToLoadAll(page); + await expect(page.locator('.cell')).toHaveCount(35); +}); + +test('new-photo poll hint appears; click refreshes to include new photos', async ({ page }) => { + test.setTimeout(90_000); + const { albumId, token } = await seedFeedAlbum('poll'); + await page.addInitScript(() => { + localStorage.setItem('viewer.locale', 'zh-CN'); + (window as unknown as { __viewerPollMs?: number }).__viewerPollMs = 500; + }); + await page.goto(`/a/${token}`); + await expect(page.locator('.cell')).toHaveCount(30); + await scrollToLoadAll(page); + await expect(page.locator('.cell')).toHaveCount(35); + await expect(page.locator('.new-photo-bar')).toHaveCount(0); + + // 轮询期间直种 2 张更新的照片(publishedAt 晚于全部既有)。 + const base = Date.now(); + for (let i = 0; i < 2; i++) { + await seedPhoto(albumId, `poll-new-${i}`, { + categoryId: null, + width: 480, + height: 300, + publishedAt: new Date(base + (i + 1) * 60_000), + }); + } + + // 提示条出现(不自动插入——仍 35 张)。 + await expect(page.locator('.new-photo-bar')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.cell')).toHaveCount(35); + + // 点击 → 整表刷新回第 1 页(含新照片),滚动后 37 张。 + await page.locator('.new-photo-bar').click(); + await expect(page.locator('.new-photo-bar')).toHaveCount(0); + await scrollToLoadAll(page); + await expect(page.locator('.cell')).toHaveCount(37); +}); diff --git a/apps/viewer/e2e/helpers.ts b/apps/viewer/e2e/helpers.ts index e3da25f..d27d71e 100644 --- a/apps/viewer/e2e/helpers.ts +++ b/apps/viewer/e2e/helpers.ts @@ -1,10 +1,13 @@ // apps/viewer/e2e/helpers.ts —— Playwright 夹具:真实后端直种(决策见计划 Task 6 // Step 2:真实 API + compose 基础设施,fixture 走 Prisma/Storage 直种,镜像 // services/api/test/public.e2e-spec.ts 的种子形状)。公开端点匿名可达,无需登录态。 +// Task 7 起补 seedPhoto:直种「已发布完成态」照片(photos + thumb/browse 变体行 +// + MinIO 真实 JPEG 对象,行形状同 worker 发布事务落库形态)。 import { randomBytes } from 'node:crypto'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { config } from 'dotenv'; +import sharp from 'sharp'; import { PrismaClient } from '@photo/db-cn'; import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage'; @@ -108,13 +111,111 @@ export async function seedBannerAsset(albumId: string): Promise { }); } +/** 测试内生成真实 JPEG(sharp,同 services/api test 约定;尺寸小控制体积/耗时)。 */ +async function makeJpeg(width: number, height: number): Promise { + return sharp({ + create: { width, height, channels: 3, background: { r: 96, g: 130, b: 182 } }, + }) + .jpeg({ quality: 70 }) + .toBuffer(); +} + +/** cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。 */ +function cuidLike(alias: string): string { + return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`; +} + +/** 与 worker publish.service variantsObjectKey 同布局的变体键。 */ +function variantKey(albumId: string, photoId: string, variant: 'thumb' | 'browse'): string { + return `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`; +} + +export interface SeedPhotoOptions { + categoryId?: string | null; + /** browse 宽高(驱动列表占位与瀑布流比例;thumb 减半生成)。 */ + width?: number; + height?: number; + publishedAt: Date; +} + +/** 直种已发布照片:photos 行(ready/published,entityVersion=2 对齐发布事务)+ + * thumb/browse 变体行 + MinIO 真实对象。publishedAt 必传(列表排序确定性)。 */ +export async function seedPhoto( + albumId: string, + alias: string, + options: SeedPhotoOptions, +): Promise { + const photoId = cuidLike(alias); + const width = options.width ?? 480; + const height = options.height ?? 300; + const thumbWidth = Math.max(1, Math.round(width / 2)); + const thumbHeight = Math.max(1, Math.round(height / 2)); + const [thumb, browse] = await Promise.all([ + makeJpeg(thumbWidth, thumbHeight), + makeJpeg(width, height), + ]); + await storage.put(variantKey(albumId, photoId, 'thumb'), thumb, 'image/jpeg'); + await storage.put(variantKey(albumId, photoId, 'browse'), browse, 'image/jpeg'); + await prisma.photos.create({ + data: { + id: photoId, + albumId, + uploaderId: `u-viewer-e2e-${alias}`, + categoryId: options.categoryId ?? null, + objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`, + bytes: browse.length, + width, + height, + uploadedAt: options.publishedAt, + publishedAt: options.publishedAt, + processingState: 'ready', + visibility: 'published', + entityVersion: 2, + }, + }); + await prisma.photo_variants.createMany({ + data: [ + { + photoId, + variant: 'thumb', + objectKey: variantKey(albumId, photoId, 'thumb'), + bytes: thumb.length, + version: 1, + width: thumbWidth, + height: thumbHeight, + }, + { + photoId, + variant: 'browse', + objectKey: variantKey(albumId, photoId, 'browse'), + bytes: browse.length, + version: 1, + width, + height, + }, + ], + }); + return photoId; +} + /** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删; - * category_translations 只挂 categoryId,先取分类 id 再删翻译)。 + * category_translations 只挂 categoryId,先取分类 id 再删翻译;Task 7 起照片域 + * 同理:先按相册取照片 id 删变体行,再删 photos 主行)。 * MinIO 对象不在此删:services/api e2e 的 reset 会整体清 `photo-live/dev` 前缀, * dev 桶内残余有界(同 api e2e 约定)。 */ export async function cleanupSeededAlbums(): Promise { try { if (seededAlbumIds.length > 0) { + const photos = await prisma.photos.findMany({ + where: { albumId: { in: seededAlbumIds } }, + select: { id: true }, + }); + if (photos.length > 0) { + await prisma.photo_variants.deleteMany({ + where: { photoId: { in: photos.map((p) => p.id) } }, + }); + } + await prisma.photos.deleteMany({ where: { albumId: { in: seededAlbumIds } } }); const categories = await prisma.categories.findMany({ where: { albumId: { in: seededAlbumIds } }, select: { id: true }, diff --git a/apps/viewer/package.json b/apps/viewer/package.json index 5c04701..a10b3d4 100644 --- a/apps/viewer/package.json +++ b/apps/viewer/package.json @@ -27,6 +27,7 @@ "@vue/test-utils": "^2.4.6", "dotenv": "^16.4.5", "jsdom": "^25.0.0", + "sharp": "^0.35.0", "typescript": "^5.6.0", "vite": "~5.4.0", "vitest": "^2.1.0", diff --git a/apps/viewer/src/__tests__/activity.spec.ts b/apps/viewer/src/__tests__/activity.spec.ts index c009229..9e99e03 100644 --- a/apps/viewer/src/__tests__/activity.spec.ts +++ b/apps/viewer/src/__tests__/activity.spec.ts @@ -8,11 +8,16 @@ import ActivityPage from '../pages/ActivityPage.vue'; import { router } from '../router'; import { makeAlbum, makeEnAlbum } from './fixtures'; -// ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum, +// ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum / +// getPublicPhotos(Task 7 起活动页内嵌 PhotoFeed,需喂空页避免真实 fetch), // 保证 ApiError/errors 映射等真实逻辑参与断言。 vi.mock('../api/client', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, getPublicAlbum: vi.fn() }; + return { + ...actual, + getPublicAlbum: vi.fn(), + getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }), + }; }); const mockedGetPublicAlbum = vi.mocked(getPublicAlbum); @@ -63,8 +68,8 @@ describe('activity page (Task 6)', () => { expect(wrapper.find('.meta').text()).toContain('10月1日'); const chips = wrapper.findAll('.chip'); - expect(chips.map((chip) => chip.text())).toEqual(['签到墙', '开场']); - expect(chips.every((chip) => chip.attributes('disabled') !== undefined)).toBe(true); + expect(chips.map((chip) => chip.text())).toEqual(['全部', '签到墙', '开场']); + expect(chips.every((chip) => chip.attributes('disabled') === undefined)).toBe(true); expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线'); // 无 banner 资产 → 不渲染 img;viewCount 占位 null → 不渲染任何计数节点。 diff --git a/apps/viewer/src/__tests__/feed-layout.spec.ts b/apps/viewer/src/__tests__/feed-layout.spec.ts new file mode 100644 index 0000000..b08edfd --- /dev/null +++ b/apps/viewer/src/__tests__/feed-layout.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { + containsNewPhotoId, + layoutWaterfall, + mergeUniqueByPhotoId, +} from '../feed/layout'; + +// Task 7 瀑布流纯函数层:列放置/高度预留/去重/新照片判定(组件行为在 feed.spec)。 +// 数字断言手算依据:containerWidth 320、gap 8、双列 → columnWidth = floor(312/2) = 156。 + +describe('layoutWaterfall (pure placement)', () => { + it('places each item in the shorter column with aspect-preserved height', () => { + const layout = layoutWaterfall( + [ + { width: 200, height: 100 }, // A: ratio .5 → 78 + { width: 100, height: 200 }, // B: ratio 2 → 312 + { width: 156, height: 156 }, // C: ratio 1 → 156 + { width: 300, height: 100 }, // D: ratio 1/3 → 52 + ], + 320, + ); + + expect(layout.columnWidth).toBe(156); + // A→col0(空列同高取索引小);B→col1(0 < 86);C→col0(86 < 320);D→col0(250 < 320)。 + expect(layout.placements.map((p) => p.column)).toEqual([0, 1, 0, 0]); + expect(layout.placements.map((p) => p.top)).toEqual([0, 0, 86, 250]); + expect(layout.placements.map((p) => p.height)).toEqual([78, 312, 156, 52]); + // 最高列 320 去掉末尾间距 → 容器高 312。 + expect(layout.height).toBe(312); + }); + + it('computes column left offsets with the gap and alternates on equal heights', () => { + const layout = layoutWaterfall( + [ + { width: 100, height: 100 }, + { width: 100, height: 100 }, + { width: 100, height: 100 }, + ], + 320, + ); + + expect(layout.placements.map((p) => p.left)).toEqual([0, 164, 0]); + expect(layout.placements.map((p) => p.column)).toEqual([0, 1, 0]); + expect(layout.placements.map((p) => p.top)).toEqual([0, 0, 164]); + // col0 累计 156+8+156+8=328,col1 156+8=164 → 容器高 328-8=320。 + expect(layout.height).toBe(320); + }); + + it('falls back to a square placeholder for degenerate dimensions (no NaN)', () => { + const layout = layoutWaterfall([{ width: 0, height: 500 }, { width: 400, height: 0 }], 320); + expect(layout.placements.map((p) => p.height)).toEqual([156, 156]); + expect(layout.placements.every((p) => Number.isFinite(p.top) && Number.isFinite(p.left))).toBe(true); + }); + + it('returns an empty layout for no items and tolerates zero container width', () => { + const empty = layoutWaterfall([], 320); + expect(empty.placements).toEqual([]); + expect(empty.height).toBe(0); + + const zero = layoutWaterfall([{ width: 100, height: 100 }], 0); + expect(zero.columnWidth).toBe(0); + expect(zero.placements.map((p) => p.height)).toEqual([1]); + }); +}); + +describe('mergeUniqueByPhotoId (page append dedupe)', () => { + it('appends only unseen photoIds and preserves order', () => { + const merged = mergeUniqueByPhotoId( + [{ photoId: 'a' }, { photoId: 'b' }], + [{ photoId: 'b' }, { photoId: 'c' }, { photoId: 'a' }, { photoId: 'c' }], + ); + expect(merged.map((item) => item.photoId)).toEqual(['a', 'b', 'c']); + }); + + it('keeps the existing entry when a duplicate id arrives', () => { + const existing = [{ photoId: 'a', width: 1 }]; + const merged = mergeUniqueByPhotoId(existing, [{ photoId: 'a', width: 2 }]); + expect(merged).toHaveLength(1); + expect(merged[0].width).toBe(1); + }); +}); + +describe('containsNewPhotoId (poll detection)', () => { + it('detects ids outside the known set and returns false when all known', () => { + expect(containsNewPhotoId(['a', 'b'], [{ photoId: 'b' }, { photoId: 'c' }])).toBe(true); + expect(containsNewPhotoId(['a', 'b'], [{ photoId: 'b' }, { photoId: 'a' }])).toBe(false); + // 已知集为空(空相册)时任何已发布照片都是「新照片」。 + expect(containsNewPhotoId([], [{ photoId: 'a' }])).toBe(true); + expect(containsNewPhotoId(['a'], [])).toBe(false); + }); +}); diff --git a/apps/viewer/src/__tests__/feed.spec.ts b/apps/viewer/src/__tests__/feed.spec.ts new file mode 100644 index 0000000..1f9a8b0 --- /dev/null +++ b/apps/viewer/src/__tests__/feed.spec.ts @@ -0,0 +1,380 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ApiError, + getPublicPhoto, + getPublicPhotos, + type PublicPhoto, + type PublicPhotoList, +} from '../api/client'; +import { createViewerI18n, type ViewerI18n } from '../i18n'; +import PhotoFeed from '../components/PhotoFeed.vue'; + +// PhotoFeed(Task 7)组件测试:api client 以真实模块为底、替身 getPublicPhotos / +// getPublicPhoto;IntersectionObserver / ResizeObserver 以可控假件替换(jsdom 无实现), +// clientWidth 以原型 getter mock(jsdom 恒 0)。布局纯函数已在 feed-layout.spec 覆盖。 + +vi.mock('../api/client', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getPublicPhotos: vi.fn(), getPublicPhoto: vi.fn() }; +}); + +const mockedGetPublicPhotos = vi.mocked(getPublicPhotos); +const mockedGetPublicPhoto = vi.mocked(getPublicPhoto); + +/** 可控 IO 假件:记录实例与 init(rootMargin 区分懒加载 400px / 哨兵 600px)。 */ +class FakeIntersectionObserver { + static instances: FakeIntersectionObserver[] = []; + readonly root: Element | Document | null = null; + readonly thresholds: ReadonlyArray = []; + private readonly targets = new Set(); + + constructor( + readonly callback: IntersectionObserverCallback, + readonly init: IntersectionObserverInit = {}, + ) { + FakeIntersectionObserver.instances.push(this); + } + + get rootMargin(): string { + return typeof this.init.rootMargin === 'string' ? this.init.rootMargin : ''; + } + + get observed(): Element[] { + return [...this.targets]; + } + + observe(target: Element): void { + this.targets.add(target); + } + + unobserve(target: Element): void { + this.targets.delete(target); + } + + disconnect(): void { + this.targets.clear(); + } + + takeRecords(): IntersectionObserverEntry[] { + return []; + } + + trigger(targets: Element[], isIntersecting: boolean): void { + this.callback( + targets.map((target) => ({ target, isIntersecting }) as IntersectionObserverEntry), + this as unknown as IntersectionObserver, + ); + } +} + +class FakeResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +function lazyObserver(): FakeIntersectionObserver { + const observer = FakeIntersectionObserver.instances.find((instance) => + instance.rootMargin.includes('400px'), + ); + if (observer === undefined) throw new Error('lazy observer not created'); + return observer; +} + +function sentinelObserver(): FakeIntersectionObserver { + const observer = FakeIntersectionObserver.instances.find((instance) => + instance.rootMargin.includes('600px'), + ); + if (observer === undefined) throw new Error('sentinel observer not created'); + return observer; +} + +function makePhoto(id: string, width = 800, height = 500): PublicPhoto { + return { + photoId: id, + width, + height, + 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 }; +} + +let i18n: ViewerI18n; +const mounted: VueWrapper[] = []; + +function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): VueWrapper { + const wrapper = mount(PhotoFeed, { + global: { plugins: [i18n] }, + props: { + token: props.token ?? 'tok-feed', + locale: props.locale ?? 'zh-CN', + category: props.category ?? null, + }, + }); + mounted.push(wrapper); + return wrapper; +} + +describe('photo feed (Task 7)', () => { + beforeEach(() => { + localStorage.clear(); + i18n = createViewerI18n('zh-CN'); + FakeIntersectionObserver.instances = []; + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + // jsdom 无布局:clientWidth 恒 0 → 原型 getter mock 为 700(columnWidth=346)。 + const proto = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'clientWidth') + ? HTMLElement.prototype + : Element.prototype; + vi.spyOn(proto, 'clientWidth', 'get').mockReturnValue(700); + mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1')); + mockedGetPublicPhoto.mockReset(); + }); + + afterEach(() => { + while (mounted.length > 0) { + mounted.pop()?.unmount(); + } + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('renders first page as absolute-positioned cells with reserved heights', async () => { + const wrapper = mountFeed(); + await flushPromises(); + + expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-feed', { locale: 'zh-CN' }); + const cells = wrapper.findAll('.cell'); + expect(cells).toHaveLength(3); + // 700 容器宽 → columnWidth 346;800x500 比例 → 条高 round(346*.625)=216。 + // 3 条:col0 [224,0] → p2 入 col1 → p3 入 col0(top 224)→ 容器高 224+216=440。 + expect(wrapper.find('.waterfall').attributes('style')).toContain('height: 440px'); + expect(cells[1].attributes('style')).toContain('left: 354px'); + expect(cells[2].attributes('style')).toContain('top: 224px'); + }); + + it('binds img src only after the cell approaches the viewport (lazy swap)', async () => { + const wrapper = mountFeed(); + await flushPromises(); + + expect(wrapper.findAll('img.cell-img')).toHaveLength(0); + const cells = wrapper.findAll('.cell'); + expect(lazyObserver().observed).toHaveLength(3); + + lazyObserver().trigger([cells[0].element, cells[1].element], true); + await vi.waitFor(() => expect(wrapper.findAll('img.cell-img')).toHaveLength(2)); + expect(wrapper.find('img.cell-img').attributes('src')).toBe('http://img.test/p1-thumb.jpg?s=1'); + // 已显示的单元格不再被观察。 + expect(lazyObserver().observed).toHaveLength(1); + }); + + it('loads the next page when the sentinel intersects, dedupes and stops at exhaustion', async () => { + const wrapper = mountFeed(); + await flushPromises(); + + const sentinel = wrapper.find('.sentinel'); + expect(sentinel.exists()).toBe(true); + // 第 2 页带回重复的 p3(游标边界漂移防御)→ 仅追加新 id。 + mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p3'), makePhoto('p4')], null)); + + sentinelObserver().trigger([sentinel.element], true); + await flushPromises(); + + expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith( + 'tok-feed', + expect.objectContaining({ cursor: 'cur-1' }), + ); + expect(wrapper.findAll('.cell')).toHaveLength(4); + // 游标耗尽:哨兵卸载 + 底部提示。 + expect(wrapper.find('.sentinel').exists()).toBe(false); + expect(wrapper.find('.end-note').text()).toBe('已经到底了'); + expect(wrapper.find('.new-photo-bar').exists()).toBe(false); + }); + + it('does not fire a second page fetch while one is in flight', async () => { + const wrapper = mountFeed(); + await flushPromises(); + mockedGetPublicPhotos.mockClear(); + + let resolvePage!: (value: PublicPhotoList) => void; + mockedGetPublicPhotos.mockReturnValueOnce( + new Promise((resolve) => { + resolvePage = resolve; + }), + ); + const sentinel = wrapper.find('.sentinel'); + sentinelObserver().trigger([sentinel.element], true); + sentinelObserver().trigger([sentinel.element], true); + await flushPromises(); + expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(1); + + resolvePage(makeList([makePhoto('p4')], null)); + await flushPromises(); + expect(wrapper.findAll('.cell')).toHaveLength(4); + }); + + it('switching category resets the list and passes the filter through', async () => { + const wrapper = mountFeed(); + await flushPromises(); + mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('c1p1'), makePhoto('c1p2')], null)); + + await wrapper.setProps({ category: 'cat-1' }); + await flushPromises(); + + expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith( + 'tok-feed', + expect.objectContaining({ category: 'cat-1', locale: 'zh-CN' }), + ); + expect(wrapper.findAll('.cell')).toHaveLength(2); + }); + + it('locale switch re-fetches the current category (filter preserved)', async () => { + const wrapper = mountFeed({ category: 'cat-1' }); + await flushPromises(); + + await wrapper.setProps({ locale: 'en' }); + await flushPromises(); + + expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith( + 'tok-feed', + expect.objectContaining({ locale: 'en', category: 'cat-1' }), + ); + }); + + it('renders the localized empty state when the album has no photos', async () => { + mockedGetPublicPhotos.mockResolvedValueOnce(makeList([])); + const wrapper = mountFeed(); + await flushPromises(); + + expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线'); + expect(wrapper.find('.cell').exists()).toBe(false); + }); + + it('initial load failure shows the localized error with retry; retry recovers', async () => { + mockedGetPublicPhotos + .mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network down', 0, null)) + .mockResolvedValueOnce(makeList([makePhoto('p1')], null)); + const wrapper = mountFeed(); + await flushPromises(); + + expect(wrapper.find('.error[role="alert"]').text()).toContain('网络连接失败'); + expect(wrapper.find('.cell').exists()).toBe(false); + + await wrapper.find('.error button').trigger('click'); + await flushPromises(); + expect(wrapper.findAll('.cell')).toHaveLength(1); + }); + + it('retries a failed thumb once with a refreshed signed URL, then hides the img', async () => { + const wrapper = mountFeed(); + await flushPromises(); + lazyObserver().trigger(wrapper.findAll('.cell').map((cell) => cell.element), true); + await vi.waitFor(() => expect(wrapper.findAll('img.cell-img')).toHaveLength(3)); + + mockedGetPublicPhoto.mockResolvedValue({ + ...makePhoto('p1'), + categoryId: null, + thumb: { url: 'http://img.test/p1-thumb.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' }, + }); + await wrapper.findAll('img.cell-img')[0].trigger('error'); + await flushPromises(); + + expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-feed', 'p1'); + expect(wrapper.findAll('img.cell-img')[0].attributes('src')).toBe('http://img.test/p1-thumb.jpg?s=2'); + + // 第二次失败 → 收起该图(占位底色仍在,布局不塌)。 + await wrapper.findAll('img.cell-img')[0].trigger('error'); + await flushPromises(); + expect(wrapper.findAll('img.cell-img')).toHaveLength(2); + expect(wrapper.findAll('.cell')).toHaveLength(3); + expect(mockedGetPublicPhoto).toHaveBeenCalledTimes(1); + }); +}); + +describe('photo feed polling (Task 7)', () => { + beforeEach(() => { + localStorage.clear(); + i18n = createViewerI18n('zh-CN'); + FakeIntersectionObserver.instances = []; + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + const proto = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'clientWidth') + ? HTMLElement.prototype + : Element.prototype; + vi.spyOn(proto, 'clientWidth', 'get').mockReturnValue(700); + mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null)); + mockedGetPublicPhoto.mockReset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + while (mounted.length > 0) { + mounted.pop()?.unmount(); + } + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('poll detects new photos, shows the hint bar; click refreshes to page 1, clears the hint and scrolls top', async () => { + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + const wrapper = mountFeed(); + await flushPromises(); + expect(wrapper.find('.new-photo-bar').exists()).toBe(false); + + // 轮询取到的第 1 页含未知 id p9 → 只亮提示条,不自动插入。 + mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2')], null)); + vi.advanceTimersByTime(30_000); + await flushPromises(); + expect(wrapper.find('.new-photo-bar').text()).toBe('有新照片,点击查看'); + expect(wrapper.findAll('.cell')).toHaveLength(2); + + // 点击提示条:整表刷新(第 1 页含新照片)并回顶。 + mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2')], null)); + await wrapper.find('.new-photo-bar').trigger('click'); + await flushPromises(); + + expect(wrapper.findAll('.cell')).toHaveLength(3); + expect(wrapper.find('.new-photo-bar').exists()).toBe(false); + expect(scrollTo).toHaveBeenCalledWith(0, 0); + }); + + it('keeps polling silent on failure (no error state, no hint)', async () => { + const wrapper = mountFeed(); + await flushPromises(); + + mockedGetPublicPhotos.mockRejectedValueOnce(new ApiError('INTERNAL_ERROR', 'boom', 500, 'req-1')); + vi.advanceTimersByTime(30_000); + await flushPromises(); + + expect(wrapper.find('.error').exists()).toBe(false); + expect(wrapper.find('.new-photo-bar').exists()).toBe(false); + expect(wrapper.findAll('.cell')).toHaveLength(2); + }); + + it('pauses polling while the tab is hidden and resumes when visible again', async () => { + mountFeed(); + await flushPromises(); + mockedGetPublicPhotos.mockClear(); + + const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true); + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(120_000); + await flushPromises(); + expect(mockedGetPublicPhotos).not.toHaveBeenCalled(); + + hidden.mockReturnValue(false); + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(30_000); + await flushPromises(); + expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/viewer/src/__tests__/routing.spec.ts b/apps/viewer/src/__tests__/routing.spec.ts index 361f08b..b62805d 100644 --- a/apps/viewer/src/__tests__/routing.spec.ts +++ b/apps/viewer/src/__tests__/routing.spec.ts @@ -8,9 +8,14 @@ import { makeAlbum } from './fixtures'; // 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经 // api mock 断言——活动页以 token 取数,photoId 供 Task 8 深链定位(不影响取数)。 +// getPublicPhotos 一并替身为空页(Task 7 起活动页内嵌 PhotoFeed)。 vi.mock('../api/client', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, getPublicAlbum: vi.fn() }; + return { + ...actual, + getPublicAlbum: vi.fn(), + getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }), + }; }); const mockedGetPublicAlbum = vi.mocked(getPublicAlbum); diff --git a/apps/viewer/src/api/client.ts b/apps/viewer/src/api/client.ts index ba7a733..e1ad526 100644 --- a/apps/viewer/src/api/client.ts +++ b/apps/viewer/src/api/client.ts @@ -2,6 +2,7 @@ import type { components } from '@photo/contracts'; // 契约生成类型直连(Vite 原生 ESM,无需 mapper) export type PublicAlbum = components['schemas']['PublicAlbum']; +export type PublicPhoto = components['schemas']['PublicPhoto']; export type PublicPhotoList = components['schemas']['PublicPhotoList']; export type PublicPhotoDetail = components['schemas']['PublicPhotoDetail']; diff --git a/apps/viewer/src/components/PhotoFeed.vue b/apps/viewer/src/components/PhotoFeed.vue new file mode 100644 index 0000000..a9d7907 --- /dev/null +++ b/apps/viewer/src/components/PhotoFeed.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/apps/viewer/src/feed/layout.ts b/apps/viewer/src/feed/layout.ts new file mode 100644 index 0000000..f740afe --- /dev/null +++ b/apps/viewer/src/feed/layout.ts @@ -0,0 +1,102 @@ +// apps/viewer/src/feed/layout.ts —— 瀑布流纯函数层(Task 7):双列绝对定位布局 +// 与列表纯数据操作。抽出为无 DOM 依赖的模块以便 vitest 直接覆盖放置/去重逻辑。 +// +// 布局模型(选型见计划 Task 7 Step 1:绝对定位而非 CSS columns——滚动位置可 +// 恢复、条目高按 width/height 比例预留): +// · 列宽 = (容器宽 - (列数-1)*列间距) / 列数,向下取整; +// · 条目高 = round(列宽 * height/width)(宽高来自 browse 变体,服务端必返); +// 宽高非正的脏数据回退正方形容高(不产生 NaN/0 塌陷); +// · 放置规则:总是放入当前「最矮」列,同高取索引小者(确定性输出); +// · 容器高 = 最高列去掉末尾间距(间距只在条目之间生效)。 + +/** 布局输入:仅需宽高(PublicPhoto 满足)。 */ +export interface WaterfallSource { + width: number; + height: number; +} + +/** 单条放置结果:index 对应输入数组下标,left/top/height 为像素值。 */ +export interface WaterfallPlacement { + index: number; + column: number; + left: number; + top: number; + height: number; +} + +export interface WaterfallLayout { + placements: WaterfallPlacement[]; + /** 单列宽度(所有列等宽)。 */ + columnWidth: number; + /** 容器内容总高(px)。 */ + height: number; +} + +export const WATERFALL_COLUMNS = 2; +export const WATERFALL_GAP = 8; + +/** + * 双列(可推广 N 列)瀑布流布局:逐条放入最矮列。O(n) 全量重算——分页追加 + * 也整体重算(n 为已加载条数,P1 量级下开销可忽略),换来 resize/刷新的确定性。 + */ +export function layoutWaterfall( + items: readonly WaterfallSource[], + containerWidth: number, + options: { columns?: number; gap?: number } = {}, +): WaterfallLayout { + const columns = Math.max(1, options.columns ?? WATERFALL_COLUMNS); + const gap = Math.max(0, options.gap ?? WATERFALL_GAP); + const columnWidth = + containerWidth > 0 + ? Math.floor((containerWidth - gap * (columns - 1)) / columns) + : 0; + + const columnHeights = new Array(columns).fill(0); + const placements: WaterfallPlacement[] = []; + + items.forEach((item, index) => { + const ratio = + item.width > 0 && item.height > 0 ? item.height / item.width : 1; + const height = Math.max(1, Math.round(columnWidth * ratio)); + let column = 0; + for (let c = 1; c < columns; c += 1) { + if (columnHeights[c] < columnHeights[column]) column = c; + } + placements.push({ + index, + column, + left: column * (columnWidth + gap), + top: columnHeights[column], + height, + }); + columnHeights[column] += height + gap; + }); + + const height = + placements.length > 0 ? Math.max(...columnHeights) - gap : 0; + return { placements, columnWidth, height }; +} + +/** 追加分页结果时按 photoId 去重(防御:游标边界漂移、轮询与分页竞争)。 */ +export function mergeUniqueByPhotoId( + existing: readonly T[], + incoming: readonly T[], +): T[] { + const seen = new Set(existing.map((item) => item.photoId)); + const merged = [...existing]; + for (const item of incoming) { + if (seen.has(item.photoId)) continue; + seen.add(item.photoId); + merged.push(item); + } + return merged; +} + +/** 轮询判定:incoming 中是否存在 known 集合之外的新照片。 */ +export function containsNewPhotoId( + knownIds: Iterable, + incoming: readonly { photoId: string }[], +): boolean { + const known = new Set(knownIds); + return incoming.some((item) => !known.has(item.photoId)); +} diff --git a/apps/viewer/src/i18n/locales/en.ts b/apps/viewer/src/i18n/locales/en.ts index 6622d11..ee73bed 100644 --- a/apps/viewer/src/i18n/locales/en.ts +++ b/apps/viewer/src/i18n/locales/en.ts @@ -3,10 +3,14 @@ export default { home: { hint: 'Open an album link to start viewing' }, album: { loading: 'Loading…', + loadingMore: 'Loading more…', emptyNotice: 'Photos coming soon', time: 'Time', venue: 'Venue', categories: 'Categories', + all: 'All', + end: 'You have reached the end', + newPhotos: 'New photos — tap to refresh', retry: 'Retry', }, lang: { switch: 'Switch language' }, diff --git a/apps/viewer/src/i18n/locales/zh-CN.ts b/apps/viewer/src/i18n/locales/zh-CN.ts index 0a423ef..fd7e2b1 100644 --- a/apps/viewer/src/i18n/locales/zh-CN.ts +++ b/apps/viewer/src/i18n/locales/zh-CN.ts @@ -3,10 +3,14 @@ export default { home: { hint: '打开相册链接即可观看' }, album: { loading: '加载中…', + loadingMore: '加载更多…', emptyNotice: '照片即将上线', time: '时间', venue: '地点', categories: '分类', + all: '全部', + end: '已经到底了', + newPhotos: '有新照片,点击查看', retry: '重试', }, lang: { switch: '切换语言' }, diff --git a/apps/viewer/src/pages/ActivityPage.vue b/apps/viewer/src/pages/ActivityPage.vue index f6265c4..c2de553 100644 --- a/apps/viewer/src/pages/ActivityPage.vue +++ b/apps/viewer/src/pages/ActivityPage.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, type PublicAlbum } from '../api/client'; import { errorMessageKey } from '../api/errors'; +import PhotoFeed from '../components/PhotoFeed.vue'; // 观众活动页(P1b Task 6):信息区 + 空态 + 时区展示;瀑布流(Task 7)与大图 // (Task 8,复用本组件于 /a/:token/p/:photoId 深链)随后补充。 @@ -92,6 +93,14 @@ const venueText = computed(() => { .filter((part): part is string => part !== null) .join(' · '); }); + +// 分类筛选(Task 7):id 级持有(语言切换只换名称,选中态跨语言保留—— +// PhotoFeed 收到同一 category 值不重置,locale 变化才整表重取)。 +const selectedCategory = ref(null); + +function selectCategory(categoryId: string | null): void { + selectedCategory.value = categoryId; +} @@ -207,18 +231,11 @@ const venueText = computed(() => { color: #374151; padding: 2px 12px; font-size: 0.875rem; + cursor: pointer; } -.chip:disabled { - cursor: default; -} -.feed { - border: 1px dashed #e5e7eb; - border-radius: 8px; - padding: 48px 16px; - text-align: center; -} -.empty-notice { - margin: 0; - color: #9ca3af; +.chip.active { + border-color: #1d4ed8; + background: #eff6ff; + color: #1d4ed8; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfea9e3..a8b2173 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: jsdom: specifier: ^25.0.0 version: 25.0.1 + sharp: + specifier: ^0.35.0 + version: 0.35.4(@types/node@22.20.3) typescript: specifier: ^5.6.0 version: 5.9.3