import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from '../App.vue'; import { createViewerI18n, type ViewerI18n } from '../i18n'; import { router } from '../router'; // UI-02 V01 外壳与视觉 token: // · token 断言直接读 viewer-tokens.css 源文件(vitest 不加载样式表), // 作用域正确性由渲染分支验证——普通路由挂 .viewer-shell(token 生效), // embed 路由走 .embed-shell 裸分支(不挂类 → 不消费任何 token,隔离红线); // · 顶栏:品牌 PhotoLive + 紧凑语言控件 + 「⋯」轻量菜单内的减少动画入口。 vi.mock('../api/client', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, // embed 路由数据:404 → 静默 gone(无 UI、无轮询),外壳分支足以验证。 // ApiError 经 actual 命名空间构造(工厂被提升,不能引用顶层 import 绑定)。 fetchPublicEmbedSlot: vi.fn().mockRejectedValue( new actual.ApiError('EMBED_SLOT_NOT_FOUND', 'slot gone', 404, 'req-shell'), ), }; }); let i18n: ViewerI18n; let mounted: VueWrapper | null = null; function mountApp(): VueWrapper { const wrapper = mount(App, { global: { plugins: [router, i18n] } }); mounted = wrapper; return wrapper; } describe('viewer shell tokens and embed isolation (UI-02 V01)', () => { beforeEach(() => { localStorage.clear(); i18n = createViewerI18n('zh-CN'); }); afterEach(() => { mounted?.unmount(); mounted = null; document.documentElement.className = ''; }); it('declares all UI-02 §3.1 tokens scoped to .viewer-shell (not :root/global)', () => { const cssPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'styles', 'viewer-tokens.css'); const css = readFileSync(cssPath, 'utf8'); const expected: Record = { '--viewer-primary': '#1A73E8', '--viewer-surface': '#FFFFFF', '--viewer-page-bg': '#F3F6FA', '--viewer-text': '#172033', '--viewer-text-secondary': '#647084', '--viewer-divider': '#E5EAF1', '--viewer-dark-bg': '#0E1218', '--viewer-like-accent': '#FF667D', '--viewer-font-family': "system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif", '--viewer-radius-img': '4px', '--viewer-radius-pill': '22px', '--viewer-chip-bg': '#F5F7FA', '--viewer-pad-info': '16px', '--viewer-pad-photo': '4px', '--viewer-touch-target': '44px', }; for (const [name, value] of Object.entries(expected)) { expect(css, `${name} token`).toContain(`${name}: ${value};`); } // 不得把 token 泄漏到全局根(embed iframe 会继承 :root 变量)。 expect(css).not.toContain(':root'); expect(css).toContain('.viewer-shell'); }); it('normal routes render .viewer-shell with PhotoLive brand top bar and prefs menu', async () => { await router.push('/'); await router.isReady(); const app = mountApp(); expect(app.find('.viewer-shell').exists()).toBe(true); expect(app.find('.embed-shell').exists()).toBe(false); const topbar = app.find('header.topbar'); expect(topbar.exists()).toBe(true); expect(topbar.find('.brand-name').text()).toBe('PhotoLive'); expect(topbar.find('.locale-trigger').exists()).toBe(true); // 「⋯」轻量菜单(§3.2):减少动画入口可发现、可切换。 const more = topbar.find('.icon-btn'); expect(more.attributes('aria-haspopup')).toBe('menu'); await more.trigger('click'); const item = app.find('.prefs-item'); expect(item.text()).toBe('减少动画'); const before = item.attributes('aria-checked'); await item.trigger('click'); expect(app.find('.prefs-item').attributes('aria-checked')).toBe(before === 'true' ? 'false' : 'true'); // 还原模块级偏好单例,避免影响后续用例。 await app.find('.prefs-item').trigger('click'); }); it('embed routes keep the bare shell: no .viewer-shell, no top bar, no outer padding wrapper', async () => { await router.push('/embed/slot-shell-test'); await router.isReady(); const app = mountApp(); await flushPromises(); expect(app.find('.embed-shell').exists()).toBe(true); expect(app.find('.viewer-shell').exists()).toBe(false); expect(app.find('header.topbar').exists()).toBe(false); expect(app.find('.viewer-main').exists()).toBe(false); }); });