7f9789eb46
评审跟进(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>
118 lines
4.7 KiB
TypeScript
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');
|
|
});
|
|
});
|