diff --git a/apps/viewer/e2e/album.spec.ts b/apps/viewer/e2e/album.spec.ts new file mode 100644 index 0000000..aa88ede --- /dev/null +++ b/apps/viewer/e2e/album.spec.ts @@ -0,0 +1,131 @@ +import { expect, test } from '@playwright/test'; +import { + cleanupSeededAlbums, + makePublicToken, + seedAlbum, + seedBannerAsset, + seedCategory, +} from './helpers'; + +// Task 6 活动页 Playwright(真实后端:API 8080 由 playwright.config webServer 拉起 +// 或复用已在跑的实例;种子走 Prisma/MinIO 直种,见 helpers.ts)。 +// 语言前置:无头 Chromium 默认 en-US——用 addInitScript 预置 localStorage,避免 +// 依赖浏览器语言;zh 断言由此确定性地落在 zh-CN UI。 + +test.afterAll(async () => { + await cleanupSeededAlbums(); +}); + +test('renders album info in zh with timezone-adjusted time, chips and empty state', async ({ page }) => { + const album = await seedAlbum({ + alias: 'main', + translations: [ + { + locale: 'zh-CN', + title: '2026 公司年会', + description: '年度盛会', + venue: '大宴会厅', + address: '上海市浦东新区世纪大道 100 号', + }, + { locale: 'en', title: 'Annual Party 2026', description: 'Yearly gala' }, + ], + }); + await seedCategory(album.id, 0, [ + { locale: 'zh-CN', name: '签到墙' }, + { locale: 'en', name: 'Check-in Wall' }, + ]); + await seedCategory(album.id, 1, [{ locale: 'zh-CN', name: '开场' }]); + + await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN')); + await page.goto(`/a/${album.publicToken}`); + + await expect(page.locator('h1.title')).toHaveText('2026 公司年会'); + await expect(page.locator('.description')).toHaveText('年度盛会'); + // Asia/Shanghai:UTC 10-01 02:00/12:00 → 本地 10-01(不含 9/30 越日错误)。 + const time = page.locator('.meta'); + await expect(time).toContainText('10月1日'); + await expect(time).not.toContainText('9月30'); + 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(page.locator('.empty-notice')).toHaveText('照片即将上线'); + // 该相册无 banner 资产 → 不渲染图。 + await expect(page.locator('img.banner')).toHaveCount(0); + await expect(page).toHaveTitle('2026 公司年会'); +}); + +test('switching locale re-fetches and shows the English translation', async ({ page }) => { + const album = await seedAlbum({ + alias: 'switch', + translations: [ + { locale: 'zh-CN', title: '双语测试相册', description: '中文介绍' }, + { locale: 'en', title: 'Bilingual Test Album', description: 'English intro', venue: 'Main Hall' }, + ], + }); + + await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN')); + await page.goto(`/a/${album.publicToken}`); + await expect(page.locator('h1.title')).toHaveText('双语测试相册'); + + await page.locator('button.locale-switcher').click(); + await expect(page.locator('h1.title')).toHaveText('Bilingual Test Album'); + await expect(page.locator('.description')).toHaveText('English intro'); + await expect(page.locator('.meta')).toContainText('Main Hall'); + await expect(page.locator('.empty-notice')).toHaveText('Photos coming soon'); + await expect(page).toHaveTitle('Bilingual Test Album'); +}); + +test('en UI with a zh-only album falls back to zh content while chrome stays en', async ({ page }) => { + const album = await seedAlbum({ + alias: 'fallback', + translations: [{ locale: 'zh-CN', title: '只有中文的相册', venue: '大宴会厅' }], + }); + + await page.addInitScript(() => localStorage.setItem('viewer.locale', 'en')); + await page.goto(`/a/${album.publicToken}`); + + // 内容回退:resolvedLocale=zh-CN(服务端返回 zh 翻译)。 + await expect(page.locator('h1.title')).toHaveText('只有中文的相册'); + await expect(page.locator('.meta')).toContainText('大宴会厅'); + // UI 语言仍为用户选择(en)。 + await expect(page.locator('.empty-notice')).toHaveText('Photos coming soon'); +}); + +test('banner signed URL loads a real image', async ({ page }) => { + const album = await seedAlbum({ + alias: 'banner', + translations: [{ locale: 'zh-CN', title: '有封面的相册' }], + }); + await seedBannerAsset(album.id); + + await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN')); + await page.goto(`/a/${album.publicToken}`); + + const banner = page.locator('img.banner'); + await expect(banner).toBeVisible(); + // 签名 URL 可取回真实字节(加载完成 naturalWidth > 0;失败则保持 0)。 + await expect + .poll(async () => banner.evaluate((img) => (img as HTMLImageElement).naturalWidth)) + .toBeGreaterThan(0); +}); + +test('offline and ghost tokens show the localized unavailable error', async ({ page }) => { + const offline = await seedAlbum({ + alias: 'offline', + status: 'offline', + translations: [{ locale: 'zh-CN', title: '已下线相册' }], + }); + + await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN')); + await page.goto(`/a/${offline.publicToken}`); + await expect(page.locator('.error[role="alert"]')).toContainText('相册不存在或已下线'); + await expect(page.locator('h1.title')).toHaveCount(0); + + // 幽灵 token:同码同文案(防枚举)。 + await page.goto(`/a/${makePublicToken()}`); + await expect(page.locator('.error[role="alert"]')).toContainText('相册不存在或已下线'); +}); diff --git a/apps/viewer/e2e/helpers.ts b/apps/viewer/e2e/helpers.ts new file mode 100644 index 0000000..e3da25f --- /dev/null +++ b/apps/viewer/e2e/helpers.ts @@ -0,0 +1,135 @@ +// apps/viewer/e2e/helpers.ts —— Playwright 夹具:真实后端直种(决策见计划 Task 6 +// Step 2:真实 API + compose 基础设施,fixture 走 Prisma/Storage 直种,镜像 +// services/api/test/public.e2e-spec.ts 的种子形状)。公开端点匿名可达,无需登录态。 +import { randomBytes } from 'node:crypto'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { config } from 'dotenv'; +import { PrismaClient } from '@photo/db-cn'; +import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage'; + +// 仓库根 .env 唯一来源(同 services/api test/load-env.ts 约定)。必须在 +// PrismaClient / createStorageFromEnv 构造前执行——两者均在首次查询/调用时读 env。 +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); +config({ path: join(repoRoot, '.env') }); + +const prisma = new PrismaClient(); +const storage: MinioStorageAdapter = createStorageFromEnv(); + +const KEY_ROOT = 'photo-live/dev'; + +/** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */ +export function makePublicToken(): string { + const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; + let token = ''; + const bytes = randomBytes(24); + for (let i = 0; i < 24; i++) { + token += alphabet[bytes[i] % alphabet.length]; + } + return token; +} + +export interface SeedAlbumOptions { + alias: string; + defaultLocale?: string; + translations: Array<{ + locale: string; + title: string; + description?: string; + venue?: string; + address?: string; + }>; + status?: string; + publicEnabled?: boolean; + timezone?: string; + startAt?: Date; + endAt?: Date | null; +} + +const seededAlbumIds: string[] = []; + +/** 经 Prisma 直种相册 + 翻译(行形状同 api 公开 e2e fixture)。 */ +export async function seedAlbum(options: SeedAlbumOptions): Promise<{ id: string; publicToken: string }> { + const album = await prisma.albums.create({ + data: { + publicToken: makePublicToken(), + ownerId: `u-viewer-e2e-${options.alias}`, + startAt: options.startAt ?? new Date('2026-10-01T02:00:00.000Z'), + endAt: options.endAt === undefined ? new Date('2026-10-01T12:00:00.000Z') : options.endAt, + timezone: options.timezone ?? 'Asia/Shanghai', + status: options.status ?? 'active', + defaultLocale: options.defaultLocale ?? 'zh-CN', + publicEnabled: options.publicEnabled ?? true, + }, + select: { id: true, publicToken: true }, + }); + seededAlbumIds.push(album.id); + await prisma.album_translations.createMany({ + data: options.translations.map((t) => ({ + albumId: album.id, + locale: t.locale, + title: t.title, + description: t.description ?? null, + venue: t.venue ?? null, + address: t.address ?? null, + })), + }); + return album; +} + +/** 直种分类 + 翻译(名称按 locale 解析),返回 id。 */ +export async function seedCategory( + albumId: string, + sortOrder: number, + translations: Array<{ locale: string; name: string }>, +): Promise { + const category = await prisma.categories.create({ + data: { albumId, sortOrder }, + select: { id: true }, + }); + await prisma.category_translations.createMany({ + data: translations.map((t) => ({ categoryId: category.id, locale: t.locale, name: t.name })), + }); + return category.id; +} + +/** 640x200 纯色 JPEG(sharp 生成,仅作真实可取回对象——公开端点只做签名,不校验内容)。 */ +export const BANNER_JPEG = Buffer.from( + '/9j/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCADIAoADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAH/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAQF/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AgDSRgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/2Q==', + 'base64', +); + +/** 直种 banner 资产行 + MinIO 真实对象(kind=banner, state=effective, locale 全局)。 */ +export async function seedBannerAsset(albumId: string): Promise { + const objectKey = `${KEY_ROOT}/albums/${albumId}/assets/banner.jpg`; + await storage.put(objectKey, BANNER_JPEG, 'image/jpeg'); + await prisma.album_assets.create({ + data: { albumId, kind: 'banner', locale: null, objectKey, state: 'effective' }, + }); +} + +/** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删; + * category_translations 只挂 categoryId,先取分类 id 再删翻译)。 + * MinIO 对象不在此删:services/api e2e 的 reset 会整体清 `photo-live/dev` 前缀, + * dev 桶内残余有界(同 api e2e 约定)。 */ +export async function cleanupSeededAlbums(): Promise { + try { + if (seededAlbumIds.length > 0) { + const categories = await prisma.categories.findMany({ + where: { albumId: { in: seededAlbumIds } }, + select: { id: true }, + }); + const categoryIds = categories.map((c) => c.id); + if (categoryIds.length > 0) { + await prisma.category_translations.deleteMany({ where: { categoryId: { in: categoryIds } } }); + } + await prisma.categories.deleteMany({ where: { albumId: { in: seededAlbumIds } } }); + await prisma.album_assets.deleteMany({ where: { albumId: { in: seededAlbumIds } } }); + await prisma.album_translations.deleteMany({ where: { albumId: { in: seededAlbumIds } } }); + await prisma.albums.deleteMany({ where: { id: { in: seededAlbumIds } } }); + seededAlbumIds.length = 0; + } + } finally { + await prisma.$disconnect(); + } +} diff --git a/apps/viewer/package.json b/apps/viewer/package.json index 8104a09..5c04701 100644 --- a/apps/viewer/package.json +++ b/apps/viewer/package.json @@ -20,8 +20,12 @@ "vue-router": "~4.4.0" }, "devDependencies": { + "@photo/db-cn": "workspace:*", + "@photo/storage": "workspace:*", + "@types/node": "^22.10.0", "@vitejs/plugin-vue": "^5.2.0", "@vue/test-utils": "^2.4.6", + "dotenv": "^16.4.5", "jsdom": "^25.0.0", "typescript": "^5.6.0", "vite": "~5.4.0", diff --git a/apps/viewer/playwright.config.ts b/apps/viewer/playwright.config.ts index e9862f4..c0f6647 100644 --- a/apps/viewer/playwright.config.ts +++ b/apps/viewer/playwright.config.ts @@ -1,7 +1,8 @@ import { defineConfig, devices } from '@playwright/test'; -// 冒烟占位(Task 5):真实旅程(活动页/瀑布流/大图/admin 联调)随 Task 6-8/11 扩充。 -// webServer 自动拉起 vite dev(5175,/v1 代理 → 127.0.0.1:8080),本地已有实例则复用。 +// Task 6 起 e2e 打真实后端:webServer 先等 API(8080,仓库根 `pnpm dev:api` +// 构建+启动;已在跑的实例直接复用),再等 viewer dev(5175,/v1 代理 → 8080)。 +// compose 基础设施(MySQL/MinIO/Redis)需预先 `docker compose up -d`(计划约定)。 export default defineConfig({ testDir: './e2e', timeout: 30_000, @@ -12,10 +13,19 @@ export default defineConfig({ trace: 'on-first-retry', }, projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], - webServer: { - command: 'pnpm run dev', - url: 'http://127.0.0.1:5175', - reuseExistingServer: true, - timeout: 60_000, - }, + webServer: [ + { + command: 'pnpm dev:api', + cwd: '../..', + url: 'http://127.0.0.1:8080/v1/health', + reuseExistingServer: true, + timeout: 180_000, // 首次含 tsc 全量构建 + }, + { + command: 'pnpm run dev', + url: 'http://127.0.0.1:5175', + reuseExistingServer: true, + timeout: 60_000, + }, + ], }); diff --git a/apps/viewer/src/__tests__/activity.spec.ts b/apps/viewer/src/__tests__/activity.spec.ts new file mode 100644 index 0000000..c009229 --- /dev/null +++ b/apps/viewer/src/__tests__/activity.spec.ts @@ -0,0 +1,207 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick } from 'vue'; +import App from '../App.vue'; +import { ApiError, getPublicAlbum, type PublicAlbum } from '../api/client'; +import { createViewerI18n, type ViewerI18n } from '../i18n'; +import ActivityPage from '../pages/ActivityPage.vue'; +import { router } from '../router'; +import { makeAlbum, makeEnAlbum } from './fixtures'; + +// ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum, +// 保证 ApiError/errors 映射等真实逻辑参与断言。 +vi.mock('../api/client', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getPublicAlbum: vi.fn() }; +}); + +const mockedGetPublicAlbum = vi.mocked(getPublicAlbum); + +// 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例 +// (见 i18n/index.ts 注释),复用单例会让后续用例的 locale 写入失效;残留组件的 +// locale watch 也会在后续用例切换语言时再度触发取数,污染调用序断言。 +let i18n: ViewerI18n; +const mounted: VueWrapper[] = []; + +function mountPage(): VueWrapper { + const wrapper = mount(ActivityPage, { global: { plugins: [router, i18n] } }); + mounted.push(wrapper); + return wrapper; +} + +function mountApp(): VueWrapper { + const wrapper = mount(App, { global: { plugins: [router, i18n] } }); + mounted.push(wrapper); + return wrapper; +} + +describe('activity page (Task 6)', () => { + beforeEach(async () => { + localStorage.clear(); + i18n = createViewerI18n('zh-CN'); + document.title = ''; + mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum()); + await router.push('/a/tok-activity'); + await router.isReady(); + }); + + afterEach(() => { + while (mounted.length > 0) { + mounted.pop()?.unmount(); + } + }); + + it('renders album info in zh: title, pre-wrap description, venue · address, categories chips and empty notice', async () => { + const wrapper = mountPage(); + await flushPromises(); + + expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-activity', 'zh-CN'); + expect(wrapper.find('h1.title').text()).toBe('2026 公司年会'); + expect(wrapper.find('.description').text()).toBe('年度盛会\n多行介绍'); + expect(wrapper.find('.meta').text()).toContain('大宴会厅 · 上海市浦东新区世纪大道 100 号'); + // 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'); + 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 → 不渲染任何计数节点。 + expect(wrapper.find('img.banner').exists()).toBe(false); + expect(wrapper.find('.view-count').exists()).toBe(false); + expect(document.title).toBe('2026 公司年会'); + }); + + it('formats the time range in the album timezone (America/New_York shifts UTC date back one day)', async () => { + mockedGetPublicAlbum.mockResolvedValue( + makeAlbum({ timezone: 'America/New_York' }), + ); + const wrapper = mountPage(); + await flushPromises(); + + // UTC 10-01 02:00 → New York(EDT, UTC-4)09-30 22:00;endAt 12:00Z → 10-01 08:00。 + const time = wrapper.find('.meta').text(); + expect(time).toContain('9月30'); + expect(time).toContain('10月1'); + expect(time).not.toContain('9月29'); + }); + + it('renders a single date when endAt is missing (no range separator)', async () => { + mockedGetPublicAlbum.mockResolvedValue( + makeAlbum({ timezone: 'America/New_York', endAt: null }), + ); + const wrapper = mountPage(); + await flushPromises(); + + const time = wrapper.find('.meta').text(); + expect(time).toContain('9月30'); + expect(time).not.toContain('–'); // 区间分隔符不应出现 + expect(time).not.toContain('10月1'); + }); + + it('switching locale re-fetches with ?locale= and shows resolved English content', async () => { + mockedGetPublicAlbum.mockImplementation((_token, locale) => + Promise.resolve(locale === 'en' ? makeEnAlbum() : makeAlbum()), + ); + const wrapper = mountApp(); + await flushPromises(); + expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'zh-CN'); + expect(wrapper.find('h1.title').text()).toBe('2026 公司年会'); + + await wrapper.find('button.locale-switcher').trigger('click'); + await flushPromises(); + + expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'en'); + expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026'); + expect(wrapper.find('.empty-notice').text()).toBe('Photos coming soon'); + expect(document.title).toBe('Annual Party 2026'); + }); + + it('ALBUM_NOT_PUBLIC renders the localized message with retry; retry recovers', async () => { + mockedGetPublicAlbum + .mockRejectedValueOnce(new ApiError('ALBUM_NOT_PUBLIC', 'album not public', 404, 'req-1')) + .mockResolvedValueOnce(makeAlbum()); + const wrapper = mountPage(); + await flushPromises(); + + expect(wrapper.find('.error[role="alert"]').text()).toContain('相册不存在或已下线'); + expect(wrapper.find('h1.title').exists()).toBe(false); + + await wrapper.find('.error button').trigger('click'); + await flushPromises(); + expect(wrapper.find('h1.title').text()).toBe('2026 公司年会'); + }); + + it('renders banner img with the signed url when present, nothing when absent', async () => { + mockedGetPublicAlbum.mockResolvedValue( + makeAlbum({ banner: { url: 'http://127.0.0.1:9000/photo-live/dev/albums/a1/assets/banner.jpg?sig=1', expiresAt: '2026-10-01T00:00:00.000Z' } }), + ); + const withBanner = mountPage(); + await flushPromises(); + const img = withBanner.find('img.banner'); + expect(img.exists()).toBe(true); + expect(img.attributes('src')).toContain('/albums/a1/assets/banner.jpg?sig=1'); + + mockedGetPublicAlbum.mockResolvedValue(makeAlbum()); + const withoutBanner = mountPage(); + await flushPromises(); + expect(withoutBanner.find('img.banner').exists()).toBe(false); + }); + + it('hides the banner on img load failure (layout adapts, no broken image)', async () => { + mockedGetPublicAlbum.mockResolvedValue( + makeAlbum({ banner: { url: 'http://127.0.0.1:9000/photo-live/dev/expired.jpg?sig=stale', expiresAt: '2020-01-01T00:00:00.000Z' } }), + ); + const wrapper = mountPage(); + await flushPromises(); + expect(wrapper.find('img.banner').exists()).toBe(true); + + await wrapper.find('img.banner').trigger('error'); + await nextTick(); + expect(wrapper.find('img.banner').exists()).toBe(false); + }); + + it('shows the localized loading state until the request settles', async () => { + let resolve!: (value: PublicAlbum) => void; + mockedGetPublicAlbum.mockReturnValue( + new Promise((res) => { + resolve = res; + }), + ); + const wrapper = mountPage(); + await flushPromises(); + expect(wrapper.find('.status').text()).toBe('加载中…'); + expect(wrapper.find('h1.title').exists()).toBe(false); + + resolve(makeAlbum()); + await flushPromises(); + expect(wrapper.find('.status').exists()).toBe(false); + expect(wrapper.find('h1.title').text()).toBe('2026 公司年会'); + }); + + it('discards stale responses when the locale changes quickly (last request wins)', async () => { + const pending: Array<(value: PublicAlbum | PromiseLike) => void> = []; + mockedGetPublicAlbum.mockImplementation( + () => + new Promise((res) => { + pending.push(res); + }), + ); + const wrapper = mountPage(); + await flushPromises(); + + // zh 请求在途时切到 en(触发第二个请求),en 先返回、zh 后返回——zh 为过期响应。 + i18n.global.locale.value = 'en'; + await flushPromises(); + expect(pending).toHaveLength(2); + + pending[1](makeEnAlbum()); + await flushPromises(); + expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026'); + + pending[0](makeAlbum()); + await flushPromises(); + expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026'); + }); +}); diff --git a/apps/viewer/src/__tests__/fixtures.ts b/apps/viewer/src/__tests__/fixtures.ts new file mode 100644 index 0000000..d6a29d7 --- /dev/null +++ b/apps/viewer/src/__tests__/fixtures.ts @@ -0,0 +1,49 @@ +import type { PublicAlbum } from '../api/client'; + +// 组件测试共享夹具:公开相册响应的最小完整形状(对齐契约 PublicAlbum 必返字段)。 +// vi.mock 工厂需在各 spec 文件内联(hoisting 限制),此处只承载数据构造。 +export function makeAlbum( + overrides: Partial & { translation?: Partial } = {}, +): PublicAlbum { + const { translation, ...rest } = overrides; + return { + publicToken: 'tokfixture0000000000aaaa', + resolvedLocale: 'zh-CN', + defaultLocale: 'zh-CN', + timezone: 'Asia/Shanghai', + startAt: '2026-10-01T02:00:00.000Z', + endAt: '2026-10-01T12:00:00.000Z', + translation: { + locale: 'zh-CN', + title: '2026 公司年会', + description: '年度盛会\n多行介绍', + venue: '大宴会厅', + address: '上海市浦东新区世纪大道 100 号', + ...translation, + }, + categories: [ + { id: 'cat-sign', name: '签到墙', sortOrder: 0 }, + { id: 'cat-open', name: '开场', sortOrder: 1 }, + ], + banner: null, + viewCount: null, + ...rest, + }; +} + +export function makeEnAlbum(): PublicAlbum { + return makeAlbum({ + resolvedLocale: 'en', + translation: { + locale: 'en', + title: 'Annual Party 2026', + description: 'Yearly gala', + venue: 'Grand Ballroom', + address: '100 Century Ave, Pudong, Shanghai', + }, + categories: [ + { id: 'cat-sign', name: 'Check-in Wall', sortOrder: 0 }, + { id: 'cat-open', name: 'Opening', sortOrder: 1 }, + ], + }); +} diff --git a/apps/viewer/src/__tests__/i18n.spec.ts b/apps/viewer/src/__tests__/i18n.spec.ts index 100aac3..4b616dd 100644 --- a/apps/viewer/src/__tests__/i18n.spec.ts +++ b/apps/viewer/src/__tests__/i18n.spec.ts @@ -1,27 +1,36 @@ -import { mount } from '@vue/test-utils'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { mount, type VueWrapper } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { nextTick } from 'vue'; import App from '../App.vue'; -import { LOCALE_STORAGE_KEY, i18n } from '../i18n'; +import { LOCALE_STORAGE_KEY, createViewerI18n } from '../i18n'; import { router } from '../router'; +// i18n 切换机制(顶栏 chrome 层):活动页级的「切换语言 → 重取内容」由 +// activity.spec.ts 覆盖,此处聚焦首页 chrome(无需 api mock)。 +// 逐挂载独立 i18n + 逐用例卸载(背景见 activity.spec.ts 头注)。 +let i18n: ReturnType; +let wrapper: VueWrapper; + describe('viewer i18n', () => { - beforeEach(() => { + beforeEach(async () => { localStorage.clear(); - i18n.global.locale.value = 'zh-CN'; + i18n = createViewerI18n('zh-CN'); + await router.push('/'); + await router.isReady(); + wrapper = mount(App, { global: { plugins: [router, i18n] } }); + }); + + afterEach(() => { + wrapper.unmount(); }); it('switching locale re-renders messages and persists the choice', async () => { - await router.push('/a/tok-i18n'); - await router.isReady(); - const wrapper = mount(App, { global: { plugins: [router, i18n] } }); - - expect(wrapper.text()).toContain('照片即将上线'); + expect(wrapper.text()).toContain('打开相册链接即可观看'); expect(document.title).toBe('现场相册'); await wrapper.find('button.locale-switcher').trigger('click'); await nextTick(); - expect(wrapper.text()).toContain('Photos coming soon'); + expect(wrapper.text()).toContain('Open an album link to start viewing'); expect(document.title).toBe('Live Gallery'); expect(document.documentElement.getAttribute('lang')).toBe('en'); expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('en'); @@ -29,7 +38,7 @@ describe('viewer i18n', () => { // 切回 zh-CN 同样生效 await wrapper.find('button.locale-switcher').trigger('click'); await nextTick(); - expect(wrapper.text()).toContain('照片即将上线'); + expect(wrapper.text()).toContain('打开相册链接即可观看'); expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-CN'); }); }); diff --git a/apps/viewer/src/__tests__/routing.spec.ts b/apps/viewer/src/__tests__/routing.spec.ts index f005e9f..361f08b 100644 --- a/apps/viewer/src/__tests__/routing.spec.ts +++ b/apps/viewer/src/__tests__/routing.spec.ts @@ -1,28 +1,58 @@ -import { mount } from '@vue/test-utils'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from '../App.vue'; -import { i18n } from '../i18n'; +import { getPublicAlbum } from '../api/client'; +import { createViewerI18n } from '../i18n'; import { router } from '../router'; +import { makeAlbum } from './fixtures'; + +// 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经 +// api mock 断言——活动页以 token 取数,photoId 供 Task 8 深链定位(不影响取数)。 +vi.mock('../api/client', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getPublicAlbum: vi.fn() }; +}); + +const mockedGetPublicAlbum = vi.mocked(getPublicAlbum); + +// 逐挂载独立 i18n + 逐用例卸载(背景见 activity.spec.ts 头注)。 +let i18n: ReturnType; +const mounted: VueWrapper[] = []; describe('viewer routing', () => { - beforeEach(() => { - i18n.global.locale.value = 'zh-CN'; + beforeEach(async () => { + i18n = createViewerI18n('zh-CN'); + mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum()); }); - it('activity route resolves token param and renders empty notice', async () => { + afterEach(() => { + while (mounted.length > 0) { + mounted.pop()?.unmount(); + } + }); + + it('activity route resolves token param and loads the album', async () => { await router.push('/a/tok-abc'); await router.isReady(); const wrapper = mount(App, { global: { plugins: [router, i18n] } }); - expect(wrapper.text()).toContain('tok-abc'); + mounted.push(wrapper); + await flushPromises(); + + expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-abc', 'zh-CN'); + expect(wrapper.text()).toContain('2026 公司年会'); expect(wrapper.text()).toContain('照片即将上线'); - expect(wrapper.text()).not.toContain('photo-'); }); it('deep link resolves token and photoId params on the activity page', async () => { await router.push('/a/tok-abc/p/photo-42'); - const wrapper = mount(App, { global: { plugins: [router, i18n] } }); await router.isReady(); - expect(wrapper.text()).toContain('tok-abc'); - expect(wrapper.text()).toContain('photo-42'); + const wrapper = mount(App, { global: { plugins: [router, i18n] } }); + mounted.push(wrapper); + await flushPromises(); + + expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-abc', 'zh-CN'); + expect(wrapper.text()).toContain('2026 公司年会'); + // photoId 仅用于 Task 8 大图定位,不进入信息区文案。 + expect(wrapper.text()).not.toContain('photo-42'); }); }); diff --git a/apps/viewer/src/components/LocaleSwitcher.vue b/apps/viewer/src/components/LocaleSwitcher.vue index 36551fc..4022c22 100644 --- a/apps/viewer/src/components/LocaleSwitcher.vue +++ b/apps/viewer/src/components/LocaleSwitcher.vue @@ -1,7 +1,7 @@ diff --git a/apps/viewer/src/i18n/index.ts b/apps/viewer/src/i18n/index.ts index 5a479f4..116b1cf 100644 --- a/apps/viewer/src/i18n/index.ts +++ b/apps/viewer/src/i18n/index.ts @@ -25,19 +25,21 @@ function resolveInitialLocale(): ViewerLocale { return FALLBACK_LOCALE; } -export const i18n = createI18n({ - legacy: false, - locale: resolveInitialLocale(), - fallbackLocale: FALLBACK_LOCALE, - messages: { 'zh-CN': zhCN, en }, -}); - -// 切换语言并持久化;写失败不阻断切换。 -export function setLocale(locale: ViewerLocale): void { - i18n.global.locale.value = locale; - try { - localStorage.setItem(LOCALE_STORAGE_KEY, locale); - } catch { - // 忽略持久化失败 - } +/** + * 构建 viewer i18n 实例(运行时单例与测试各自构造)。 + * 组件测试必须每次挂载用新实例:vue-i18n 的 install 会包装 app.unmount 调 + * i18n.dispose()——共享单例被任一测试卸载后全局 composer 反应性即永久失效 + * (locale 写入不再传播)。运行时 main.ts 只挂载一次,不受影响。 + */ +export function createViewerI18n(initialLocale: ViewerLocale = resolveInitialLocale()) { + return createI18n({ + legacy: false, + locale: initialLocale, + fallbackLocale: FALLBACK_LOCALE, + messages: { 'zh-CN': zhCN, en }, + }); } + +export type ViewerI18n = ReturnType; + +export const i18n = createViewerI18n(); diff --git a/apps/viewer/src/i18n/locales/en.ts b/apps/viewer/src/i18n/locales/en.ts index fea7be5..6622d11 100644 --- a/apps/viewer/src/i18n/locales/en.ts +++ b/apps/viewer/src/i18n/locales/en.ts @@ -1,12 +1,20 @@ export default { app: { title: 'Live Gallery' }, home: { hint: 'Open an album link to start viewing' }, - album: { emptyNotice: 'Photos coming soon' }, + album: { + loading: 'Loading…', + emptyNotice: 'Photos coming soon', + time: 'Time', + venue: 'Venue', + categories: 'Categories', + retry: 'Retry', + }, lang: { switch: 'Switch language' }, route: { tokenLabel: 'Album token', photoLabel: 'Photo' }, errors: { albumNotFound: 'Album not found', - albumNotAvailable: 'Album unavailable', + // ALBUM_NOT_PUBLIC 专用:offline / disabled / ghost token 同码(防枚举)。 + albumNotAvailable: 'Album not found or offline', photoNotFound: 'Photo not found', notFound: 'Page not found', authExpired: 'Session expired', diff --git a/apps/viewer/src/i18n/locales/zh-CN.ts b/apps/viewer/src/i18n/locales/zh-CN.ts index 50c7393..0a423ef 100644 --- a/apps/viewer/src/i18n/locales/zh-CN.ts +++ b/apps/viewer/src/i18n/locales/zh-CN.ts @@ -1,12 +1,21 @@ export default { app: { title: '现场相册' }, home: { hint: '打开相册链接即可观看' }, - album: { emptyNotice: '照片即将上线' }, + album: { + loading: '加载中…', + emptyNotice: '照片即将上线', + time: '时间', + venue: '地点', + categories: '分类', + retry: '重试', + }, lang: { switch: '切换语言' }, route: { tokenLabel: '相册令牌', photoLabel: '照片' }, errors: { albumNotFound: '相册不存在', - albumNotAvailable: '相册不可用', + // ALBUM_NOT_PUBLIC 专用:offline / publicEnabled=false / 幽灵 token 同码(防枚举), + // 文案需同时覆盖「不存在」与「已下线」两种用户可理解成因。 + albumNotAvailable: '相册不存在或已下线', photoNotFound: '照片不存在', notFound: '页面不存在', authExpired: '登录已过期', diff --git a/apps/viewer/src/pages/ActivityPage.vue b/apps/viewer/src/pages/ActivityPage.vue index 84be01f..f6265c4 100644 --- a/apps/viewer/src/pages/ActivityPage.vue +++ b/apps/viewer/src/pages/ActivityPage.vue @@ -1,21 +1,224 @@ + + diff --git a/apps/viewer/tsconfig.json b/apps/viewer/tsconfig.json index 659ce6a..4514cd2 100644 --- a/apps/viewer/tsconfig.json +++ b/apps/viewer/tsconfig.json @@ -13,7 +13,7 @@ "isolatedModules": true, "noEmit": true, "skipLibCheck": true, - "types": ["vite/client"] + "types": ["vite/client", "node"] }, "include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfaf7f3..bfea9e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,12 +51,24 @@ importers: specifier: ~4.4.0 version: 4.4.5(vue@3.4.38(typescript@5.9.3)) devDependencies: + '@photo/db-cn': + specifier: workspace:* + version: link:../../packages/db-cn + '@photo/storage': + specifier: workspace:* + version: link:../../packages/storage + '@types/node': + specifier: ^22.10.0 + version: 22.20.3 '@vitejs/plugin-vue': specifier: ^5.2.0 version: 5.2.4(vite@5.4.21(@types/node@22.20.3))(vue@3.4.38(typescript@5.9.3)) '@vue/test-utils': specifier: ^2.4.6 version: 2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.4.38(vue@3.4.38(typescript@5.9.3)))(vue@3.4.38(typescript@5.9.3)) + dotenv: + specifier: ^16.4.5 + version: 16.6.1 jsdom: specifier: ^25.0.0 version: 25.0.1