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:
浅念
2026-09-29 00:58:48 +08:00
parent 80405cfcc1
commit f1c13bff7c
8 changed files with 428 additions and 0 deletions
@@ -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');
});
});