feat(viewer): UI-02 visual foundation, top bar and album info
V01+V02 (Task 2 of the UI-02 viewer redesign plan): - viewer-tokens.css: UI-02 §3.1 design tokens scoped to .viewer-shell (never :root), so embed routes consume none of them - App.vue: normal routes get a sticky white top bar (PhotoLive brand + compact locale switcher + lightweight "..." menu holding the reduce-motion toggle); global 16px content padding removed so banner and photos run edge-to-edge; embed shell branch untouched - LocaleSwitcher: compact "EN/中文 + caret" trigger with a proper menu (current language checked), 44px touch target, Escape/outside close - AlbumHeader (new): banner (2.4:1 mobile, 320px desktop cap, object-fit contain, collapses on missing/failed), 2-line expandable title + share button (clipboard copy with selectable-text fallback), 3-line expandable pre-line description, meta row (Intl-formatted view count with em-dash fallback for unknown, album-timezone dates with full range on title, venue), Latest/Popular 44px tabs and category pills from the API with horizontal scroll + overflow fade - ActivityPage: delegates the info area to AlbumHeader; sequence reset/sort-lock/category-by-id semantics unchanged; desktop content capped at 1120px - i18n: viewer.* strings (brand/share/views/readMore/latest/popular/ allCategories/noPhotos/newPhotos/reduceMotion/settings) in zh-CN+en - tests: viewer-shell (token source + normal/embed isolation) and album-header (8 cases) specs; activity/i18n specs updated for the menu switcher and the em-dash unknown view count; e2e selectors refreshed Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ 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 { ApiError, getPublicAlbum, getPublicPhotos, type PublicAlbum } from '../api/client';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import ActivityPage from '../pages/ActivityPage.vue';
|
||||
import { router } from '../router';
|
||||
@@ -32,6 +32,7 @@ vi.mock('../realtime/ws', () => {
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||||
|
||||
// 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例
|
||||
// (见 i18n/index.ts 注释),复用单例会让后续用例的 locale 写入失效;残留组件的
|
||||
@@ -51,6 +52,14 @@ function mountApp(): VueWrapper {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/** UI-02 V01 紧凑语言控件:先展开菜单再点目标语言选项(SUPPORTED_LOCALES 序)。 */
|
||||
async function pickLanguage(wrapper: VueWrapper, option: '中文' | 'English'): Promise<void> {
|
||||
await wrapper.find('.locale-trigger').trigger('click');
|
||||
const target = wrapper.findAll('.locale-option').find((btn) => btn.text() === option);
|
||||
expect(target).toBeDefined();
|
||||
await target!.trigger('click');
|
||||
}
|
||||
|
||||
describe('activity page (Task 6)', () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
@@ -83,9 +92,9 @@ describe('activity page (Task 6)', () => {
|
||||
expect(chips.every((chip) => chip.attributes('disabled') === undefined)).toBe(true);
|
||||
|
||||
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
|
||||
// 无 banner 资产 → 不渲染 img;viewCount 占位 null → 不渲染任何计数节点。
|
||||
// 无 banner 资产 → 不渲染 img;viewCount 未知(null)→ 浏览项显示「—」而非 0(UI-02 §4.1)。
|
||||
expect(wrapper.find('img.banner').exists()).toBe(false);
|
||||
expect(wrapper.find('.view-count').exists()).toBe(false);
|
||||
expect(wrapper.find('.view-count').text()).toBe('—');
|
||||
expect(document.title).toBe('2026 公司年会');
|
||||
});
|
||||
|
||||
@@ -125,7 +134,7 @@ describe('activity page (Task 6)', () => {
|
||||
expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'zh-CN');
|
||||
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
|
||||
|
||||
await wrapper.find('button.locale-switcher').trigger('click');
|
||||
await pickLanguage(wrapper, 'English');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'en');
|
||||
@@ -220,4 +229,48 @@ describe('activity page (Task 6)', () => {
|
||||
await flushPromises();
|
||||
expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026');
|
||||
});
|
||||
|
||||
// —— UI-02 V02:排序/分类经 AlbumHeader 上抛,沿用本页序列重置机制 ——
|
||||
|
||||
it('selecting the Popular tab refetches the sequence with sort=hot (active state moves)', async () => {
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockClear();
|
||||
|
||||
const tabs = wrapper.findAll('.sort-tab');
|
||||
expect(tabs.map((tab) => tab.text())).toEqual(['最新', '热门']);
|
||||
expect(tabs[0].classes()).toContain('active');
|
||||
expect(tabs[0].attributes('aria-pressed')).toBe('true');
|
||||
|
||||
await tabs[1].trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-activity', expect.objectContaining({ sort: 'hot' }));
|
||||
expect(wrapper.findAll('.sort-tab')[1].classes()).toContain('active');
|
||||
});
|
||||
|
||||
it('category chips filter the sequence and the selection survives a locale switch', async () => {
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockClear();
|
||||
|
||||
await wrapper.findAll('.categories .chip')[1].trigger('click'); // 「签到墙」= cat-sign
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-activity', expect.objectContaining({ category: 'cat-sign' }));
|
||||
|
||||
mockedGetPublicAlbum.mockImplementation((_token, locale) =>
|
||||
Promise.resolve(locale === 'en' ? makeEnAlbum() : makeAlbum()),
|
||||
);
|
||||
i18n.global.locale.value = 'en';
|
||||
await flushPromises();
|
||||
|
||||
// 语言只换显示与内容请求,分类 id 跨语言保留(§4.2)。
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
|
||||
'tok-activity',
|
||||
expect.objectContaining({ category: 'cat-sign', locale: 'en' }),
|
||||
);
|
||||
const chips = wrapper.findAll('.categories .chip');
|
||||
expect(chips[1].classes()).toContain('active');
|
||||
expect(chips[1].text()).toBe('Check-in Wall');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { MockInstance } from 'vitest';
|
||||
import AlbumHeader from '../components/album/AlbumHeader.vue';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import { router } from '../router';
|
||||
import { makeAlbum } from './fixtures';
|
||||
|
||||
// UI-02 V02 AlbumHeader 单测:字段渲染/缺失省略、浏览数 Intl 格式与未知回退、
|
||||
// 相册时区日期、标题/说明钳制展开、排序 tab 与分类胶囊事件、分享复制。
|
||||
// (筛选决策与序列重置在 activity.spec.ts 集成覆盖。)
|
||||
let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
function mountHeader(props: {
|
||||
album: ReturnType<typeof makeAlbum>;
|
||||
viewCount: number | null;
|
||||
sortMode?: 'latest' | 'hot';
|
||||
selectedCategory?: string | null;
|
||||
sortLocked?: boolean;
|
||||
}): VueWrapper {
|
||||
const wrapper = mount(AlbumHeader, {
|
||||
props: {
|
||||
sortMode: 'latest',
|
||||
selectedCategory: null,
|
||||
...props,
|
||||
},
|
||||
global: { plugins: [router, i18n] },
|
||||
});
|
||||
mounted.push(wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function stubClipboard(writeText: (text: string) => Promise<void>): MockInstance {
|
||||
const fn = vi.fn(writeText);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: fn },
|
||||
configurable: true,
|
||||
});
|
||||
return fn as unknown as MockInstance;
|
||||
}
|
||||
|
||||
describe('album header info area (UI-02 V02)', () => {
|
||||
let scrollSpy: MockInstance | null = null;
|
||||
let clientSpy: MockInstance | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) mounted.pop()?.unmount();
|
||||
scrollSpy?.mockRestore();
|
||||
scrollSpy = null;
|
||||
clientSpy?.mockRestore();
|
||||
clientSpy = null;
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
});
|
||||
|
||||
it('renders banner, title, multi-line description, Intl view count, venue and sort/category toolbar', () => {
|
||||
const album = 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' },
|
||||
viewCount: 1286,
|
||||
});
|
||||
const wrapper = mountHeader({ album, viewCount: 1286 });
|
||||
|
||||
const img = wrapper.find('img.banner');
|
||||
expect(img.exists()).toBe(true);
|
||||
expect(img.attributes('src')).toContain('/albums/a1/assets/banner.jpg?sig=1');
|
||||
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
|
||||
// pre-line:换行保留;文本插值(非 HTML 注入)。
|
||||
expect(wrapper.find('.description').text()).toBe('年度盛会\n多行介绍');
|
||||
// Intl.NumberFormat 分组 + 词条拼接(§4.1)。
|
||||
expect(wrapper.find('.view-count').text()).toBe('1,286 次浏览');
|
||||
expect(wrapper.find('.meta').text()).toContain('大宴会厅 · 上海市浦东新区世纪大道 100 号');
|
||||
// Asia/Shanghai(夹具时区):UTC 10-01 → 本地 10月1日。
|
||||
expect(wrapper.find('.meta').text()).toContain('10月1日');
|
||||
|
||||
// 工具栏:排序 tab + 分类胶囊(All + 接口分类)。
|
||||
expect(wrapper.findAll('.sort-tab').map((tab) => tab.text())).toEqual(['最新', '热门']);
|
||||
expect(wrapper.findAll('.categories .chip').map((chip) => chip.text())).toEqual(['全部', '签到墙', '开场']);
|
||||
// jsdom 无布局:未溢出 → 不渲染展开按钮。
|
||||
expect(wrapper.findAll('.text-expand')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('omits missing fields naturally: no banner/description/venue, views shows —, no categories row', () => {
|
||||
const album = makeAlbum({
|
||||
banner: null,
|
||||
viewCount: null,
|
||||
categories: [],
|
||||
translation: { locale: 'zh-CN', title: '无字段活动', description: null, venue: null, address: null },
|
||||
});
|
||||
const wrapper = mountHeader({ album, viewCount: null });
|
||||
|
||||
expect(wrapper.find('img.banner').exists()).toBe(false);
|
||||
expect(wrapper.find('.description').exists()).toBe(false);
|
||||
expect(wrapper.findAll('.meta-item')).toHaveLength(2); // 浏览(—)+ 日期;地点整项省略
|
||||
expect(wrapper.find('.view-count').text()).toBe('—');
|
||||
expect(wrapper.find('.categories').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('collapses the banner on image load failure (info area stays usable)', async () => {
|
||||
const album = makeAlbum({
|
||||
banner: { url: 'http://127.0.0.1:9000/expired.jpg?sig=stale', expiresAt: '2020-01-01T00:00:00.000Z' },
|
||||
});
|
||||
const wrapper = mountHeader({ album, viewCount: 5 });
|
||||
expect(wrapper.find('img.banner').exists()).toBe(true);
|
||||
|
||||
await wrapper.find('img.banner').trigger('error');
|
||||
expect(wrapper.find('img.banner').exists()).toBe(false);
|
||||
expect(wrapper.find('h1.title').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('formats the date in the album timezone (not the device timezone) with full range on title', () => {
|
||||
// 测试机时区任意(含 UTC+8):按设备时区会得到 10月1日——断言纽约日界回退。
|
||||
const wrapper = mountHeader({ album: makeAlbum({ timezone: 'America/New_York' }), viewCount: null });
|
||||
|
||||
const dateItem = wrapper.find('.meta-item[title]');
|
||||
expect(dateItem.exists()).toBe(true);
|
||||
// UTC 10-01 02:00/12:00 → New York(EDT)09-30 22:00 / 10-01 08:00。
|
||||
expect(dateItem.text()).toContain('9月30');
|
||||
expect(dateItem.text()).toContain('10月1');
|
||||
const full = dateItem.attributes('title');
|
||||
expect(full).toContain('22:00');
|
||||
expect(full).toContain('08:00');
|
||||
expect(full).toContain('GMT-4');
|
||||
});
|
||||
|
||||
it('expands and collapses the clamped description and title (buttons only when overflowing)', async () => {
|
||||
scrollSpy = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(120);
|
||||
clientSpy = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(60);
|
||||
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
|
||||
await nextTick(); // 溢出测量在 post watcher 中执行,等微任务落定
|
||||
|
||||
const expands = wrapper.findAll('.text-expand');
|
||||
expect(expands).toHaveLength(2); // 标题 + 说明均溢出
|
||||
expect(wrapper.find('.title').classes()).toContain('clamped');
|
||||
expect(wrapper.find('.description').classes()).toContain('clamped');
|
||||
|
||||
await expands[1].trigger('click'); // 说明「展开」
|
||||
expect(wrapper.find('.description').classes()).not.toContain('clamped');
|
||||
expect(wrapper.find('.title').classes()).toContain('clamped');
|
||||
expect(wrapper.findAll('.text-expand')[1].text()).toBe('收起');
|
||||
|
||||
await wrapper.findAll('.text-expand')[1].trigger('click'); // 说明「收起」
|
||||
expect(wrapper.find('.description').classes()).toContain('clamped');
|
||||
|
||||
await wrapper.findAll('.text-expand')[0].trigger('click'); // 标题「展开」
|
||||
expect(wrapper.find('.title').classes()).not.toContain('clamped');
|
||||
});
|
||||
|
||||
it('sort tabs: latest active by default, clicking popular emits select-sort hot, locked disables', async () => {
|
||||
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
|
||||
|
||||
const tabs = wrapper.findAll('.sort-tab');
|
||||
expect(tabs[0].classes()).toContain('active');
|
||||
expect(tabs[0].attributes('aria-pressed')).toBe('true');
|
||||
expect(tabs[1].classes()).not.toContain('active');
|
||||
|
||||
await tabs[1].trigger('click');
|
||||
expect(wrapper.emitted('selectSort')).toEqual([['hot']]);
|
||||
|
||||
const locked = mountHeader({ album: makeAlbum(), viewCount: null, sortLocked: true });
|
||||
expect(locked.findAll('.sort-tab').every((tab) => tab.attributes('disabled') !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('category chips: selected state from prop, clicks emit the category id and All emits null', async () => {
|
||||
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null, selectedCategory: 'cat-sign' });
|
||||
|
||||
const chips = wrapper.findAll('.categories .chip');
|
||||
expect(chips[1].classes()).toContain('active');
|
||||
expect(chips[1].attributes('aria-pressed')).toBe('true');
|
||||
expect(chips[0].classes()).not.toContain('active');
|
||||
|
||||
await chips[2].trigger('click');
|
||||
await chips[0].trigger('click');
|
||||
expect(wrapper.emitted('selectCategory')).toEqual([['cat-open'], [null]]);
|
||||
});
|
||||
|
||||
it('share copies the album url to the clipboard and reports success; failure keeps a selectable url', async () => {
|
||||
const writeText = stubClipboard(() => Promise.resolve());
|
||||
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
|
||||
|
||||
await wrapper.find('.share-btn').trigger('click');
|
||||
await Promise.resolve();
|
||||
expect(writeText).toHaveBeenCalledWith('http://localhost:3000/a/tokfixture0000000000aaaa');
|
||||
expect(wrapper.find('.share-status').text()).toContain('链接已复制');
|
||||
expect(wrapper.find('.share-url').exists()).toBe(false);
|
||||
|
||||
// 复制失败:轻提示 + 可选中文本(§5.4 回退语义,完整 navigator.share 属 Task 5)。
|
||||
writeText.mockImplementation(() => Promise.reject(new Error('denied')));
|
||||
await wrapper.find('.share-btn').trigger('click');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(wrapper.find('.share-status').text()).toContain('复制失败');
|
||||
expect(wrapper.find('.share-url').text()).toBe('http://localhost:3000/a/tokfixture0000000000aaaa');
|
||||
});
|
||||
});
|
||||
@@ -28,15 +28,27 @@ describe('viewer i18n', () => {
|
||||
expect(wrapper.text()).toContain('打开相册链接即可观看');
|
||||
expect(document.title).toBe('现场相册');
|
||||
|
||||
await wrapper.find('button.locale-switcher').trigger('click');
|
||||
// UI-02 V01 紧凑语言控件:展开菜单 → 选择 English。
|
||||
await wrapper.find('.locale-trigger').trigger('click');
|
||||
await nextTick();
|
||||
const english = wrapper.findAll('.locale-option').find((btn) => btn.text() === 'English');
|
||||
expect(english).toBeDefined();
|
||||
await english!.trigger('click');
|
||||
await nextTick();
|
||||
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');
|
||||
|
||||
// 切回 zh-CN 同样生效
|
||||
await wrapper.find('button.locale-switcher').trigger('click');
|
||||
// 切回 zh-CN 同样生效(菜单内当前语言 English 为勾选态)。
|
||||
await wrapper.find('.locale-trigger').trigger('click');
|
||||
await nextTick();
|
||||
const options = wrapper.findAll('.locale-option');
|
||||
const englishNow = options.find((btn) => btn.text() === 'English');
|
||||
expect(englishNow!.attributes('aria-checked')).toBe('true');
|
||||
const chinese = options.find((btn) => btn.text() === '中文');
|
||||
expect(chinese!.attributes('aria-checked')).toBe('false');
|
||||
await chinese!.trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toContain('打开相册链接即可观看');
|
||||
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-CN');
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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': '20px',
|
||||
'--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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user