Files
photo-live/apps/viewer/src/__tests__/embed-carousel.spec.ts
T
2026-09-21 18:55:15 +08:00

349 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/暂停
});
});