feat(embed,viewer): UI-01 complete carousel, loader config and accessibility

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 18:55:15 +08:00
parent 1f67b7c818
commit ddf26a177f
22 changed files with 2274 additions and 326 deletions
@@ -0,0 +1,348 @@
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<void> {
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<InstanceType<typeof EmbedFooterBar>['$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/暂停
});
});
+120 -37
View File
@@ -2,22 +2,24 @@ import { mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
import { ApiError, type PublicEmbedPhoto, type PublicEmbedSlot } from '../api/client';
import { ApiError, type EmbedSlotSettings, type PublicEmbedPhoto, type PublicEmbedSlot } from '../api/client';
import { postEmbedMessage } from '../embed/postMessage';
import {
clampEmbedHeight,
clampEmbedIntervalMs,
classifyEmbedResponse,
hasExpiringThumb,
mergePhotoQueue,
pollIntervalAfterFailures,
resolveEmbedLocale,
} from '../embed/feed';
import { useEmbedFeed, type EmbedFeed, type UseEmbedFeedOptions } from '../embed/useEmbedFeed';
// 嵌入轮播数据流(P4b Task 4):纯函数矩阵(A22 旧响应丢弃/队列差分/参数夹取/
// 退避)+ useEmbedFeed 编排(304 ETag 链路、404 静默 collapse、可见性暂停恢复、
// 轮播切换门控)。postMessage 模块替身(jsdom 无父窗,postEmbedMessage 断言上行
// 载荷;onEmbedExpand 捕获 handler 供用例模拟宿主 expand);IntersectionObserver/
// 嵌入轮播数据流(P4b Task 4;UI-01 解耦重构):纯函数矩阵(A22 旧响应丢弃/
// 队列差分/参数夹取/退避/304 过期签名判定)+ useEmbedFeed 编排(ETag 链路、
// 404 静默 destroy、config 消息、可见性暂停恢复、轮询 30s 基准与轮播解耦)。
// postMessage 模块替身(jsdom 无父窗,postEmbedMessage 断言上行载荷;
// onEmbedExpand 捕获 handler 供用例模拟宿主 expand);IntersectionObserver/
// ResizeObserver 在 jsdom 缺席,composable 已按特性探测跳过(高度上报走
// clampEmbedHeight 纯函数覆盖)。
const expandHandlers = vi.hoisted(() => new Set<() => void>());
@@ -32,19 +34,35 @@ vi.mock('../embed/postMessage', () => ({
const mockedPost = vi.mocked(postEmbedMessage);
function makePhoto(id: string, url?: string): PublicEmbedPhoto {
function makePhoto(id: string, url?: string, expiresAt?: string): PublicEmbedPhoto {
return {
photoId: id,
thumb: { url: url ?? `https://img.test/${id}.jpg`, expiresAt: '2026-10-01T00:00:00.000Z' },
thumb: { url: url ?? `https://img.test/${id}.jpg`, expiresAt: expiresAt ?? '2099-01-01T00:00:00.000Z' },
width: 240,
height: 150,
};
}
function makeSettings(overrides: Partial<EmbedSlotSettings> = {}): EmbedSlotSettings {
return {
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
...overrides,
};
}
function makeSlot(overrides: Partial<PublicEmbedSlot> = {}): PublicEmbedSlot {
return {
slotToken: 'slottest0000000000000000aa',
configVersion: 1,
settings: makeSettings(),
album: {
publicToken: 'tokfixture0000000000aaaa',
title: '嵌入测试相册',
@@ -132,20 +150,33 @@ describe('embed feed pure helpers', () => {
expect(pollIntervalAfterFailures(5, 30000)).toBe(150000);
expect(pollIntervalAfterFailures(9, 30000)).toBe(150000);
});
it('detects expiring thumbs with skew for the 304 force-refresh path (UI-01 §5.5)', () => {
const now = Date.parse('2026-09-21T12:00:00.000Z');
const fresh = [makePhoto('a', undefined, '2026-09-21T13:00:00.000Z')];
const stale = [makePhoto('a', undefined, '2026-09-21T12:00:20.000Z')]; // 20s 后过期 ≤ 30s skew
const expired = [makePhoto('a', undefined, '2026-09-21T11:00:00.000Z')];
const unparsable = [makePhoto('a', undefined, 'not-a-date')];
expect(hasExpiringThumb([], now)).toBe(false);
expect(hasExpiringThumb(fresh, now)).toBe(false);
expect(hasExpiringThumb(stale, now)).toBe(true);
expect(hasExpiringThumb(expired, now)).toBe(true);
expect(hasExpiringThumb(unparsable, now)).toBe(false);
});
});
// —— useEmbedFeed 编排 ——
const mounted: VueWrapper[] = [];
function mountFeed(options: { fetcher: Mock; langParam?: string; intervalParamMs?: number | null }): EmbedFeed {
function mountFeed(options: { fetcher: Mock; langParam?: string; pollParamMs?: number | null }): EmbedFeed {
let feed!: EmbedFeed;
const Host = defineComponent({
setup() {
feed = useEmbedFeed({
slotToken: 'slote2etest0000000000aa',
langParam: options.langParam ?? 'auto',
intervalParamMs: options.intervalParamMs ?? null,
pollParamMs: options.pollParamMs ?? null,
rootEl: ref(null),
fetcher: options.fetcher as UseEmbedFeedOptions['fetcher'],
});
@@ -183,6 +214,7 @@ describe('useEmbedFeed orchestration', () => {
expect(feed.photos.map((p) => p.photoId)).toEqual(['p1', 'p2']);
expect(feed.album?.title).toBe('嵌入测试相册');
expect(feed.resolvedLocale).toBe('zh-CN'); // auto → defaultLocale
expect(feed.settings).toEqual(makeSettings());
// 下一轮带上 If-None-Match;304(data null)→ 队列零变化、不重置版本。
fetcher.mockResolvedValue({ data: null, etag: '"e1"' });
@@ -192,6 +224,28 @@ describe('useEmbedFeed orchestration', () => {
expect(feed.status).toBe('ready');
});
it('polls at the contract 30s base (carousel interval never drives fetches, UI-01 §5.5)', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
expect(fetcher).toHaveBeenCalledTimes(1);
// 3s/8s 处零请求(若轮播间隔渗入轮询此处即失败);30s 到点第 2 次。
await vi.advanceTimersByTimeAsync(3000);
await vi.advanceTimersByTimeAsync(5000);
expect(fetcher).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(22_000);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it('honors the dev poll override for accelerated polling', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
mountFeed({ fetcher, pollParamMs: 2000 });
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(2000);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it('merges an equal-version response: new photo appended at tail, urls refreshed', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher });
@@ -206,18 +260,25 @@ describe('useEmbedFeed orchestration', () => {
await vi.advanceTimersByTimeAsync(30_000);
expect(feed.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p3']);
expect(feed.photos[0]!.thumb.url).toBe('https://img.test/p1-new.jpg');
expect(feed.index).toBe(0); // 合并不打断当前展示
});
it('fully resets when configVersion rises (rebind, A22)', async () => {
it('fully resets when configVersion rises and forwards settings via config message (UI-01)', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
feed.postResize(); // no-op(rootEl null)——仅确认不抛
// 首包(reset 分支)即上行 config(宿主 loader 应用后台设置)。
expect(mockedPost).toHaveBeenCalledWith({
photoliveEmbed: true,
type: 'config',
configVersion: 1,
settings: makeSettings(),
});
const updated = makeSettings({ position: 'left', offsetX: 60, imageFit: 'cover', zIndex: 2500 });
fetcher.mockResolvedValue({
data: makeSlot({
configVersion: 2,
settings: updated,
album: { publicToken: 'tokother00000000000000bbbb', title: '换绑相册', defaultLocale: 'en' },
photos: [makePhoto('q1')],
}),
@@ -226,7 +287,14 @@ describe('useEmbedFeed orchestration', () => {
await vi.advanceTimersByTimeAsync(30_000);
expect(feed.photos.map((p) => p.photoId)).toEqual(['q1']); // 旧照片不残留
expect(feed.album?.title).toBe('换绑相册');
expect(feed.resolvedLocale).toBe('en'); // 相册 defaultLocale 变化生效
expect(feed.resolvedLocale).toBe('en');
expect(feed.settings).toEqual(updated);
expect(mockedPost).toHaveBeenCalledWith({
photoliveEmbed: true,
type: 'config',
configVersion: 2,
settings: updated,
});
});
it('discards a stale in-flight response (seq guard)', async () => {
@@ -253,13 +321,13 @@ describe('useEmbedFeed orchestration', () => {
expect(feed.photos.map((p) => p.photoId)).toEqual(['new1', 'new2']);
});
it('goes silent on 404: no UI state, collapse posted to the host loader', async () => {
it('goes silent on 404: destroy posted (host removes card and handle), polling stops', async () => {
const fetcher = vi.fn().mockRejectedValue(new ApiError('NOT_FOUND', '展示位不存在或不可用', 404, null));
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
expect(feed.status).toBe('gone');
expect(mockedPost).toHaveBeenCalledWith({ photoliveEmbed: true, type: 'collapse' });
expect(mockedPost).toHaveBeenCalledWith({ photoliveEmbed: true, type: 'destroy' });
// gone 后彻底停止轮询。
await vi.advanceTimersByTimeAsync(300_000);
expect(fetcher).toHaveBeenCalledTimes(1);
@@ -287,53 +355,68 @@ describe('useEmbedFeed orchestration', () => {
it('pauses polling while hidden and refreshes immediately on return (A23)', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
mountFeed({ fetcher });
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
expect(feed.active).toBe(true);
const callsBeforeHide = fetcher.mock.calls.length;
setHidden(true);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(120_000);
expect(fetcher).toHaveBeenCalledTimes(callsBeforeHide); // 隐藏期间零轮询
expect(feed.active).toBe(false); // 轮播切换闸门同步关闭
setHidden(false);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(0);
expect(fetcher).toHaveBeenCalledTimes(callsBeforeHide + 1); // 恢复立即校准一次
expect(feed.active).toBe(true);
});
it('advances the carousel on intervalMs and freezes it while hidden', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher, intervalParamMs: 3000 });
it('force-refreshes past the etag when a 304 coincides with expiring signed urls (UI-01 §5.5)', async () => {
// 首包 URL 已临期(1 分钟后过期 < 30s skew)。
const expiring = new Date(Date.now() + 60_000).toISOString();
const first = makeSlot({ photos: [makePhoto('p1', 'https://img.test/p1-old.jpg', expiring)] });
const refreshed = makeSlot({
photos: [makePhoto('p1', 'https://img.test/p1-new.jpg', new Date(Date.now() + 3600_000).toISOString())],
});
const fetcher = vi.fn().mockResolvedValue({ data: first, etag: '"e1"' });
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
expect(feed.index).toBe(0);
expect(fetcher).toHaveBeenCalledTimes(1);
expect(feed.photos[0]!.thumb.url).toBe('https://img.test/p1-old.jpg');
await vi.advanceTimersByTimeAsync(3000);
expect(feed.index).toBe(1);
await vi.advanceTimersByTimeAsync(3000);
expect(feed.index).toBe(0); // 两张循环
setHidden(true);
document.dispatchEvent(new Event('visibilitychange'));
// 30s 轮询 304(内容未变)但签名临期 → 绕过 ETag 立即强取一次新 URL。
fetcher
.mockResolvedValueOnce({ data: null, etag: '"e1"' }) // 轮询 → 304
.mockResolvedValueOnce({ data: refreshed, etag: '"e1"' }); // 强取 → 200 新签名
await vi.advanceTimersByTimeAsync(30_000);
expect(feed.index).toBe(0); // 暂停期间不切换
expect(fetcher).toHaveBeenCalledTimes(3);
expect(fetcher).toHaveBeenNthCalledWith(2, 'slote2etest0000000000aa', { lang: 'auto', etag: '"e1"' });
expect(fetcher).toHaveBeenNthCalledWith(3, 'slote2etest0000000000aa', { lang: 'auto', etag: null }); // 绕过 ETag
expect(feed.photos[0]!.thumb.url).toBe('https://img.test/p1-new.jpg');
// 强取后恢复正常节奏(30s 基准,不重复强取)。
fetcher.mockResolvedValue({ data: null, etag: '"e1"' });
await vi.advanceTimersByTimeAsync(500);
expect(fetcher).toHaveBeenCalledTimes(3);
});
it('collapse stops timers and expand from the parent resumes with a resize repost', async () => {
it('collapse stops polling and posting; expand resumes with an immediate refresh', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher, intervalParamMs: 3000 });
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
const callsAfterLoad = fetcher.mock.calls.length;
const calls = fetcher.mock.calls.length;
feed.requestCollapse();
expect(mockedPost).toHaveBeenCalledWith({ photoliveEmbed: true, type: 'collapse' });
await vi.advanceTimersByTimeAsync(60_000);
expect(fetcher).toHaveBeenCalledTimes(callsAfterLoad); // 收起期间零轮询
expect(feed.active).toBe(false);
await vi.advanceTimersByTimeAsync(120_000);
expect(fetcher).toHaveBeenCalledTimes(calls); // 收起期间零轮询
// 宿主把手 expand(onEmbedExpand 捕获的 handler):恢复(立即校准 + 重发高度,
// rootEl null → resize 跳过不抛)。
expandHandlers.forEach((handler) => handler());
for (const handler of expandHandlers) handler(); // 宿主把手 expand
await vi.advanceTimersByTimeAsync(0);
expect(fetcher).toHaveBeenCalledTimes(callsAfterLoad + 1);
expect(fetcher).toHaveBeenCalledTimes(calls + 1); // 恢复立即校准
expect(feed.active).toBe(true);
});
});