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 { 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['$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/暂停 }); });