feat(embed,viewer): UI-01 complete carousel, loader config and accessibility
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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/暂停
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user