Files
photo-live/apps/viewer/src/__tests__/ad-carousel.spec.ts
T
浅念 7f9789eb46 polish(viewer): 广告轮播 a11y 补全——aria-current/focus-visible/touch-action/键名对齐
评审跟进(Task 10 a11y 两项 + 三个顺手项):
- 圆点当前项程序化状态 :aria-current(ThumbnailStrip:103 同形),active 圆点
  类名对齐 sibling 约定 ad-dot--active → .active(viewer 无 BEM -- 修饰符)
- .ad-slide/.ad-close/.ad-dot 补 :focus-visible 2px 主色描边
  (AlbumHeader/ThumbnailStrip 同规;主色对暗色衬底/蒙层对比 ≈4:1)
- .ad-carousel 补 touch-action: pan-y(ThumbnailStrip pan-x 对偶:
  水平翻页自持、纵向滚动交还页面)
- sessionStorage 键名 viewer:ads-dismissed → viewer.adsDismissed(sibling
  点号命名约定;仅 ActivityPage 两处引用,测试无引用)
- ad-carousel.spec:类名断言随改 + aria-current 前后迁移断言

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 01:08:13 +08:00

118 lines
4.7 KiB
TypeScript

import { mount, type VueWrapper } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PublicAd } from '../api/client';
import { createViewerI18n } from '../i18n';
import AdCarousel from '../components/ads/AdCarousel.vue';
// 广告轮播组件(Task 10 Step 3):圆点跳页、关闭 emit、slide 点击新标签打开、
// 4s 自动轮播与指针悬停暂停、单条静态(无圆点)。会话级关闭记忆在 ActivityPage
//(组件只 emit close,无 sessionStorage 副作用)。
function makeAd(id: string): PublicAd {
return { adId: id, imageUrl: `https://ad.test/${id}.jpg`, linkUrl: `https://link.test/${id}` };
}
const ADS = [makeAd('a'), makeAd('b'), makeAd('c')];
const mounted: VueWrapper[] = [];
/** window.open 桩(记录调用即可):经工厂推导 spy 类型,绕开 spyOn 泛型约束。 */
function spyWindowOpen() {
return vi.spyOn(window, 'open').mockReturnValue({} as Window);
}
function mountCarousel(ads: PublicAd[] = ADS): VueWrapper {
const wrapper = mount(AdCarousel, {
props: { ads },
global: { plugins: [createViewerI18n('zh-CN')] },
});
mounted.push(wrapper);
return wrapper;
}
function currentSrc(wrapper: VueWrapper): string | null {
const img = wrapper.find('.ad-slide img').element as HTMLImageElement | undefined;
return img?.getAttribute('src') ?? null;
}
describe('AdCarousel', () => {
let openSpy: ReturnType<typeof spyWindowOpen>;
beforeEach(() => {
vi.useFakeTimers();
openSpy = spyWindowOpen();
});
afterEach(() => {
openSpy.mockRestore();
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
});
it('renders the ad badge and switches to the clicked dot', async () => {
const wrapper = mountCarousel();
expect(wrapper.get('.ad-badge').text()).toBe('广告');
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg');
const dots = wrapper.findAll('.ad-dots button');
expect(dots.length).toBe(3);
expect(dots[0].attributes('aria-current')).toBe('true'); // 当前项程序化状态
expect(dots[1].attributes('aria-current')).toBeUndefined();
await dots[1].trigger('click');
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg');
expect(dots[1].classes()).toContain('active');
expect(dots[1].attributes('aria-current')).toBe('true');
expect(dots[0].attributes('aria-current')).toBeUndefined();
});
it('emits close when the ✕ button is clicked', async () => {
const wrapper = mountCarousel();
const close = wrapper.get('.ad-close');
expect(close.attributes('aria-label')).toBe('关闭广告');
await close.trigger('click');
expect(wrapper.emitted('close')?.length).toBe(1);
});
it('opens the current ad link in a new tab via window.open', async () => {
const wrapper = mountCarousel();
await wrapper.get('.ad-slide').trigger('click');
expect(openSpy).toHaveBeenCalledWith('https://link.test/a', '_blank', 'noopener,noreferrer');
});
it('auto-advances every 4s, pauses on pointer enter and resumes on leave', async () => {
const wrapper = mountCarousel();
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg');
await vi.advanceTimersByTimeAsync(4000);
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg');
await wrapper.find('.ad-carousel').trigger('pointerenter');
await vi.advanceTimersByTimeAsync(8000);
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg'); // 悬停期间不切换
await wrapper.find('.ad-carousel').trigger('pointerleave');
await vi.advanceTimersByTimeAsync(4000);
expect(currentSrc(wrapper)).toBe('https://ad.test/c.jpg'); // 离开自动恢复
});
it('switches by horizontal swipe (touch) and cancels the synthetic click', async () => {
const wrapper = mountCarousel();
const root = wrapper.find('.ad-carousel');
await root.trigger('touchstart', { touches: [{ clientX: 200, clientY: 20 }] });
await root.trigger('touchend', { changedTouches: [{ clientX: 80, clientY: 25 }] });
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg'); // 左滑 → 下一张
expect(openSpy).not.toHaveBeenCalled(); // 滑动不触发广告打开
await root.trigger('touchstart', { touches: [{ clientX: 100, clientY: 10 }] });
await root.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 12 }] });
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg'); // 右滑 → 上一张
});
it('renders a single ad statically without dots or auto rotation', async () => {
const wrapper = mountCarousel([makeAd('solo')]);
expect(wrapper.find('.ad-dots').exists()).toBe(false);
await vi.advanceTimersByTimeAsync(12000);
expect(currentSrc(wrapper)).toBe('https://ad.test/solo.jpg');
});
});