900ff2a74d
Co-Authored-By: Claude Code <noreply@anthropic.com>
114 lines
4.6 KiB
TypeScript
114 lines
4.6 KiB
TypeScript
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);
|
||
});
|
||
});
|