Files
photo-live/apps/viewer/src/__tests__/viewer-shell.spec.ts
T

114 lines
4.6 KiB
TypeScript
Raw Normal View History

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<typeof import('../api/client')>();
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<string, string> = {
'--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);
});
});