ddf26a177f
Co-Authored-By: Claude Code <noreply@anthropic.com>
349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
import { mount, type VueWrapper } from '@vue/test-utils';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import type { Mock } from 'vitest';
|
||
import type { PublicEmbedPhoto } from '../api/client';
|
||
import { preloadEmbedImage } from '../embed/images';
|
||
import { createViewerI18n } from '../i18n';
|
||
import EmbedCarousel from '../pages/embed/EmbedCarousel.vue';
|
||
import EmbedStage from '../pages/embed/EmbedStage.vue';
|
||
import EmbedFooterBar from '../pages/embed/EmbedFooterBar.vue';
|
||
|
||
// 嵌入轮播组件(UI-01 Task 6):切换编排(intervalMs 计时独立于取数轮询、
|
||
// 暂停条件全集、reducedMotion 默认暂停 + 手动切换)、Stage(contain/cover、
|
||
// decode 失败保留上一张换下一张、连续失败暂停重试、过渡层不可聚焦)、
|
||
// FooterBar(页码/入口/暂停)。图片预热模块替身控制成败(jsdom 无真实解码)。
|
||
vi.mock('../embed/images', () => ({
|
||
preloadEmbedImage: vi.fn(() => Promise.resolve()),
|
||
}));
|
||
|
||
const mockedPreload = vi.mocked(preloadEmbedImage);
|
||
|
||
function makePhoto(id: string): PublicEmbedPhoto {
|
||
return {
|
||
photoId: id,
|
||
thumb: { url: `https://img.test/${id}.jpg`, expiresAt: '2099-01-01T00:00:00.000Z' },
|
||
width: 240,
|
||
height: 150,
|
||
};
|
||
}
|
||
|
||
const PHOTOS = [makePhoto('a'), makePhoto('b'), makePhoto('c')];
|
||
|
||
const mounted: VueWrapper[] = [];
|
||
|
||
interface CarouselOptions {
|
||
photos?: PublicEmbedPhoto[];
|
||
intervalMs?: number;
|
||
active?: boolean;
|
||
reducedMotion?: boolean;
|
||
albumUrl?: string | null;
|
||
}
|
||
|
||
function mountCarousel(options: CarouselOptions = {}): VueWrapper {
|
||
const wrapper = mount(EmbedCarousel, {
|
||
props: {
|
||
photos: options.photos ?? PHOTOS,
|
||
intervalMs: options.intervalMs ?? 5000,
|
||
imageFit: 'contain',
|
||
active: options.active ?? true,
|
||
reducedMotion: options.reducedMotion ?? false,
|
||
albumUrl: options.albumUrl ?? 'https://viewer.test/a/tok?lang=zh-CN',
|
||
albumTitle: '测试相册',
|
||
},
|
||
global: { plugins: [createViewerI18n('zh-CN')] },
|
||
});
|
||
mounted.push(wrapper);
|
||
return wrapper;
|
||
}
|
||
|
||
/** 推进到下一帧已提交(decode 微任务 + 340ms 交叉淡入兜底定时器)。 */
|
||
async function settle(wrapper: VueWrapper, ms = 0): Promise<void> {
|
||
await vi.advanceTimersByTimeAsync(ms);
|
||
await wrapper.vm.$nextTick();
|
||
await vi.advanceTimersByTimeAsync(400);
|
||
await wrapper.vm.$nextTick();
|
||
}
|
||
|
||
function currentSrc(wrapper: VueWrapper): string | null {
|
||
const img = wrapper.find('.embed-photo.active img').element as HTMLImageElement | undefined;
|
||
return img?.getAttribute('src') ?? null;
|
||
}
|
||
|
||
function indicator(wrapper: VueWrapper): string {
|
||
return wrapper.get('.embed-indicator').text();
|
||
}
|
||
|
||
/** jsdom document.hidden 只读:defineProperty 覆写(用例后还原)。 */
|
||
function setHidden(hidden: boolean): void {
|
||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
|
||
}
|
||
|
||
describe('EmbedCarousel switching orchestration', () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
mockedPreload.mockReset();
|
||
mockedPreload.mockImplementation(() => Promise.resolve());
|
||
});
|
||
|
||
afterEach(() => {
|
||
while (mounted.length > 0) mounted.pop()?.unmount();
|
||
vi.useRealTimers();
|
||
setHidden(false);
|
||
});
|
||
|
||
it('advances one photo per intervalMs cycling the queue', async () => {
|
||
const wrapper = mountCarousel({ intervalMs: 3000 });
|
||
await settle(wrapper, 0);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg');
|
||
expect(indicator(wrapper)).toBe('01 / 03');
|
||
|
||
await settle(wrapper, 3000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg');
|
||
expect(indicator(wrapper)).toBe('02 / 03');
|
||
|
||
await settle(wrapper, 3000);
|
||
await settle(wrapper, 3000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); // 循环
|
||
});
|
||
|
||
it('pauses while the pointer hovers and resumes after leaving (auto pause only)', async () => {
|
||
const wrapper = mountCarousel({ intervalMs: 3000 });
|
||
await settle(wrapper, 0);
|
||
|
||
await wrapper.find('.embed-carousel').trigger('pointerenter');
|
||
await settle(wrapper, 6000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); // 悬停期间不切换
|
||
|
||
await wrapper.find('.embed-carousel').trigger('pointerleave');
|
||
await settle(wrapper, 3000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg'); // 离开自动恢复
|
||
});
|
||
|
||
it('manual pause does not auto-resume; play toggles back', async () => {
|
||
const wrapper = mountCarousel({ intervalMs: 3000 });
|
||
await settle(wrapper, 0);
|
||
|
||
const pauseButton = wrapper.findAll('.embed-foot-btn').find((b) => b.attributes('aria-label') === '暂停轮播');
|
||
expect(pauseButton).toBeDefined();
|
||
await pauseButton!.trigger('click');
|
||
await settle(wrapper, 9000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); // 手动暂停不自动恢复
|
||
|
||
const playButton = wrapper.findAll('.embed-foot-btn').find((b) => b.attributes('aria-label') === '播放轮播');
|
||
await playButton!.trigger('click');
|
||
await settle(wrapper, 3000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg');
|
||
});
|
||
|
||
it('pauses while keyboard focus is inside the component', async () => {
|
||
const wrapper = mountCarousel({ intervalMs: 3000 });
|
||
await settle(wrapper, 0);
|
||
|
||
await wrapper.find('.embed-photo-open').trigger('focusin');
|
||
await settle(wrapper, 9000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg');
|
||
|
||
await wrapper.find('.embed-photo-open').trigger('focusout');
|
||
await settle(wrapper, 3000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg');
|
||
});
|
||
|
||
it('pauses while the page is hidden and resumes on return', async () => {
|
||
const wrapper = mountCarousel({ intervalMs: 3000 });
|
||
await settle(wrapper, 0);
|
||
|
||
setHidden(true);
|
||
document.dispatchEvent(new Event('visibilitychange'));
|
||
await settle(wrapper, 9000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg');
|
||
|
||
setHidden(false);
|
||
document.dispatchEvent(new Event('visibilitychange'));
|
||
await settle(wrapper, 3000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg');
|
||
});
|
||
|
||
it('pauses when inactive (collapsed/invisible) and resumes with active', async () => {
|
||
const wrapper = mountCarousel({ intervalMs: 3000, active: false });
|
||
await settle(wrapper, 9000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg');
|
||
|
||
await wrapper.setProps({ active: true });
|
||
await settle(wrapper, 3000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg');
|
||
});
|
||
|
||
it('reduced motion: paused by default with manual prev/next navigation', async () => {
|
||
const wrapper = mountCarousel({ intervalMs: 3000, reducedMotion: true });
|
||
await settle(wrapper, 9000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); // 默认暂停
|
||
|
||
const next = wrapper.findAll('.embed-foot-btn').find((b) => b.attributes('aria-label') === '下一张');
|
||
const prev = wrapper.findAll('.embed-foot-btn').find((b) => b.attributes('aria-label') === '上一张');
|
||
expect(next).toBeDefined();
|
||
expect(prev).toBeDefined();
|
||
await next!.trigger('click');
|
||
await settle(wrapper, 0);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg'); // 取消过渡:立即提交
|
||
await prev!.trigger('click');
|
||
await settle(wrapper, 0);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg');
|
||
});
|
||
|
||
it('preloads only the next photo ahead (not the whole queue)', async () => {
|
||
const wrapper = mountCarousel({ intervalMs: 3000 });
|
||
await settle(wrapper, 0);
|
||
const urls = (mockedPreload as Mock).mock.calls.map((call) => call[0]);
|
||
// 切换 a 的展示 + 预热 b;c 不请求(仅当前+下一张,§5.2)。
|
||
expect(urls.filter((u) => u === 'https://img.test/a.jpg').length).toBeGreaterThan(0);
|
||
expect(urls).toContain('https://img.test/b.jpg');
|
||
expect(urls).not.toContain('https://img.test/c.jpg');
|
||
});
|
||
|
||
it('decode failure keeps the current photo and skips ahead; 3 consecutive failures pause then retry', async () => {
|
||
mockedPreload.mockImplementation((url: string) =>
|
||
url === 'https://img.test/b.jpg' ? Promise.reject(new Error('decode failed')) : Promise.resolve(),
|
||
);
|
||
// 双照片队列:b 持续失败 → 失败后跳回 a(幂等,不计新成功)→ 连续计满 3。
|
||
const wrapper = mountCarousel({ photos: [makePhoto('a'), makePhoto('b')], intervalMs: 3000 });
|
||
await settle(wrapper, 0);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg');
|
||
expect(indicator(wrapper)).toBe('01 / 02');
|
||
|
||
// 失败 1:保留 a(跳回 a 无新展示,计数不清零)。
|
||
await settle(wrapper, 3000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg');
|
||
// 失败 2、3 → 连续失败保护:暂停自动切换,保留当前帧。
|
||
await settle(wrapper, 3000);
|
||
await settle(wrapper, 3000);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg');
|
||
|
||
// b 恢复可用:t=12000 解除暂停 → interval 重启;15000 周期幂等重定向 a
|
||
// (触发暂停的失败不推进,索引停在 b)→ 18000 周期切 b、18340 提交。
|
||
// settle 到 19200(>18340 且 < 21000 下一周期边界)。
|
||
mockedPreload.mockImplementation(() => Promise.resolve());
|
||
await settle(wrapper, 8200);
|
||
expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg');
|
||
expect(indicator(wrapper)).toBe('02 / 02');
|
||
});
|
||
});
|
||
|
||
describe('EmbedStage', () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
mockedPreload.mockReset();
|
||
mockedPreload.mockImplementation(() => Promise.resolve());
|
||
});
|
||
|
||
afterEach(() => {
|
||
while (mounted.length > 0) mounted.pop()?.unmount();
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
function mountStage(options: { photos?: PublicEmbedPhoto[]; index?: number; imageFit?: 'contain' | 'cover'; reducedMotion?: boolean } = {}): VueWrapper {
|
||
const wrapper = mount(EmbedStage, {
|
||
props: {
|
||
photos: options.photos ?? PHOTOS,
|
||
index: options.index ?? 0,
|
||
imageFit: options.imageFit ?? 'contain',
|
||
reducedMotion: options.reducedMotion ?? false,
|
||
},
|
||
global: { plugins: [createViewerI18n('zh-CN')] },
|
||
});
|
||
mounted.push(wrapper);
|
||
return wrapper;
|
||
}
|
||
|
||
it('applies object-fit from config (contain default, cover optional)', async () => {
|
||
const contain = mountStage({ imageFit: 'contain' });
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
await contain.vm.$nextTick();
|
||
expect((contain.get('.embed-photo img').element as HTMLImageElement).style.objectFit).toBe('contain');
|
||
|
||
const cover = mountStage({ imageFit: 'cover' });
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
await cover.vm.$nextTick();
|
||
expect((cover.get('.embed-photo img').element as HTMLImageElement).style.objectFit).toBe('cover');
|
||
});
|
||
|
||
it('exposes a single tabbable open-photo button; the transition layer is hidden from a11y', async () => {
|
||
const wrapper = mountStage({ index: 1 });
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
await wrapper.vm.$nextTick();
|
||
await wrapper.setProps({ index: 2 });
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
await wrapper.vm.$nextTick();
|
||
|
||
// 当前帧:group/slide 语义 + 序号标签 + 唯一可交互开图按钮。
|
||
const active = wrapper.get('.embed-photo.active');
|
||
expect(active.attributes('role')).toBe('group');
|
||
expect(active.attributes('aria-roledescription')).toBe('slide');
|
||
expect(active.attributes('aria-label')).toBe('第 3 张,共 3 张');
|
||
expect(active.find('button.embed-photo-open').exists()).toBe(true);
|
||
expect(active.get('img').attributes('alt')).toBe('活动照片,第 3 张');
|
||
|
||
// 过渡层(淡入中的 c):在场且 aria-hidden + tabindex=-1 + 无按钮。
|
||
const next = wrapper.find('.embed-photo--next');
|
||
expect(next.exists()).toBe(true);
|
||
expect(next.attributes('aria-hidden')).toBe('true');
|
||
expect(next.attributes('tabindex')).toBe('-1');
|
||
expect(next.find('button').exists()).toBe(false);
|
||
});
|
||
|
||
it('emits open-photo with the photoId when the open button is clicked', async () => {
|
||
const wrapper = mountStage({ index: 0 });
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
await wrapper.vm.$nextTick();
|
||
await wrapper.get('.embed-photo-open').trigger('click');
|
||
expect(wrapper.emitted('open-photo')?.[0]).toEqual(['a']);
|
||
});
|
||
});
|
||
|
||
describe('EmbedFooterBar', () => {
|
||
function mountFooter(props: Partial<InstanceType<typeof EmbedFooterBar>['$props']> = {}): VueWrapper {
|
||
const wrapper = mount(EmbedFooterBar, {
|
||
props: {
|
||
title: '测试相册',
|
||
index: 2,
|
||
count: 12,
|
||
paused: false,
|
||
reducedMotion: false,
|
||
albumUrl: 'https://viewer.test/a/tok?lang=zh-CN',
|
||
...props,
|
||
},
|
||
global: { plugins: [createViewerI18n('zh-CN')] },
|
||
});
|
||
mounted.push(wrapper);
|
||
return wrapper;
|
||
}
|
||
|
||
it('shows the padded current-index / queue-count indicator (not album totals)', () => {
|
||
const wrapper = mountFooter({ index: 2, count: 12 });
|
||
expect(wrapper.get('.embed-indicator').text()).toBe('03 / 12');
|
||
});
|
||
|
||
it('renders the view-album link with noopener/noreferrer and _blank', () => {
|
||
const wrapper = mountFooter();
|
||
const link = wrapper.get('.embed-foot-link');
|
||
expect(link.attributes('href')).toBe('https://viewer.test/a/tok?lang=zh-CN');
|
||
expect(link.attributes('target')).toBe('_blank');
|
||
expect(link.attributes('rel')).toContain('noopener');
|
||
expect(link.attributes('rel')).toContain('noreferrer');
|
||
expect(link.text()).toBe('查看相册');
|
||
});
|
||
|
||
it('exposes the full album title while the visual clamp lives in CSS', () => {
|
||
const wrapper = mountFooter();
|
||
// 两行截断为 scoped CSS(-webkit-line-clamp);完整标题经 title 提示可达。
|
||
expect(wrapper.get('.embed-title').attributes('title')).toBe('测试相册');
|
||
expect(wrapper.get('.embed-title').classes()).toContain('embed-title');
|
||
});
|
||
|
||
it('hides manual prev/next unless reduced motion', () => {
|
||
const plain = mountFooter({ reducedMotion: false });
|
||
expect(plain.findAll('.embed-foot-btn').length).toBe(1); // 仅暂停/播放
|
||
const reduced = mountFooter({ reducedMotion: true });
|
||
expect(reduced.findAll('.embed-foot-btn').length).toBe(3); // prev/next/暂停
|
||
});
|
||
});
|