feat(viewer): 相册页广告轮播——4s 自动/暂停/滑动/徽标/会话级关闭
Task 10(广告位轮播实施计划): - ads/carousel.ts 纯逻辑:nextIndex 环形推进(末→首、单条恒 0)+ swipeStep 滑动判定(≥40px 且水平分量严格大于垂直),carousel.spec 直测 7 例 - AdCarousel.vue:10:3 轮播,4s 自动推进(本地常量 ROTATE_MS);pointerenter/ pointerdown 暂停、pointerleave 恢复;圆点跳页;touchstart/touchend 滑动翻页 (cancel 合成 click);单条静态无圆点;左上角常驻「广告」徽标 + 右上角 ≥44px ✕ 关闭(emit close,组件无副作用);视觉一律 var(--viewer-*)(半透明层经 color-mix 自 token 派生) - client.ts:PublicAd/PublicAdListResponse 契约类型 + getPublicAds(同形 apiRequest);__tests__/ad-carousel.spec.ts 组件级 6 例(圆点/关闭/window.open spy/假时钟自动轮播与暂停/滑动/单条静态) - ActivityPage:load() 成功分支独立拉取广告(失败静默、会话级关闭记忆 viewer:ads-dismissed 先行、沿用 sequence 防跨相册竞态);AlbumHeader 与 PhotoFeed 之间挂 AdCarousel;i18n zh-CN/en 补 ads.* 词条 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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);
|
||||
await dots[1].trigger('click');
|
||||
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg');
|
||||
expect(dots[1].classes()).toContain('ad-dot--active');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user