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
+231 -19
View File
@@ -1,16 +1,22 @@
// packages/embed-loader/src/params.test.js
// 纯函数单测:data-* 解析/缺省回退/夹取、窄屏强制、URL 拼接、高度夹取。
// DOM/宿主交互行为由 Task 4 Playwright 宿主页覆盖(计划注明 node 模拟 DOM 太薄)。
// 纯函数单测:data-* 解析/缺省回退/夹取、显式覆盖表(缺省 ≠ 覆盖)、窄屏强制、
// URL 拼接、高度夹取、后台设置归一/合并优先级、config 消息裁决、消息类型白名单。
// DOM/宿主交互行为由 Playwright 宿主页覆盖(计划注明 node 模拟 DOM 太薄)。
import { describe, expect, it } from 'vitest';
import {
MESSAGE_TYPES,
buildEmbedUrl,
clampHeight,
mergeBackendConfig,
normalizeBackendSettings,
normalizeInterval,
parseConfigMessage,
parseEmbedConfig,
SIZE_WIDTH,
} from './params.js';
describe('parseEmbedConfig', () => {
it('空 dataset 全缺省(slot null → 调用方 no-op)', () => {
it('空 dataset 全缺省(slot null → 调用方 no-op)+ UI-01 新字段默认', () => {
const c = parseEmbedConfig({});
expect(c).toEqual({
slot: null,
@@ -19,6 +25,22 @@ describe('parseEmbedConfig', () => {
intervalMs: 5000,
size: 'md',
collapsedDefault: false,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
pollMs: null,
narrow: false,
explicit: {
lang: false,
position: false,
interval: false,
size: false,
imageFit: false,
offsetX: false,
offsetBottom: false,
zIndex: false,
},
});
});
@@ -28,12 +50,15 @@ describe('parseEmbedConfig', () => {
expect(parseEmbedConfig({ slot: ' ' }).slot).toBeNull();
});
it('lang/position 白名单外静默回退缺省', () => {
it('lang/position/imageFit 白名单外静默回退缺省', () => {
expect(parseEmbedConfig({ slot: 'a', lang: 'zh-CN' }).lang).toBe('zh-CN');
expect(parseEmbedConfig({ slot: 'a', lang: 'en' }).lang).toBe('en');
expect(parseEmbedConfig({ slot: 'a', lang: 'fr' }).lang).toBe('auto');
expect(parseEmbedConfig({ slot: 'a', position: 'left' }).position).toBe('left');
expect(parseEmbedConfig({ slot: 'a', position: 'top' }).position).toBe('right');
expect(parseEmbedConfig({ slot: 'a', imageFit: 'cover' }).imageFit).toBe('cover');
expect(parseEmbedConfig({ slot: 'a', imageFit: 'fill' }).imageFit).toBe('contain');
expect(parseEmbedConfig({ slot: 'a', imageFit: 'cover' }).explicit.imageFit).toBe(true);
});
it('interval 解析失败取缺省、越界夹取到 [3000, 30000]', () => {
@@ -45,35 +70,222 @@ describe('parseEmbedConfig', () => {
expect(parseEmbedConfig({ slot: 'a', interval: '2500' }).intervalMs).toBe(3000);
});
it('offsetX/offsetBottom/zIndex 数值夹取到契约范围,非法回退缺省', () => {
expect(parseEmbedConfig({ slot: 'a', offsetX: '200' }).offsetX).toBe(120);
expect(parseEmbedConfig({ slot: 'a', offsetX: '-5' }).offsetX).toBe(0);
expect(parseEmbedConfig({ slot: 'a', offsetX: '64' }).offsetX).toBe(64);
expect(parseEmbedConfig({ slot: 'a', offsetBottom: '999' }).offsetBottom).toBe(240);
expect(parseEmbedConfig({ slot: 'a', offsetBottom: 'abc' }).offsetBottom).toBe(24);
expect(parseEmbedConfig({ slot: 'a', zIndex: '99999999999' }).zIndex).toBe(2147483000);
expect(parseEmbedConfig({ slot: 'a', zIndex: '0' }).zIndex).toBe(1);
expect(parseEmbedConfig({ slot: 'a', zIndex: '3000' }).zIndex).toBe(3000);
});
it('窄屏(<480px):强制 sm 尺寸 + 默认收起,覆盖 dataset 尺寸语义', () => {
const c = parseEmbedConfig({ slot: 'a', size: 'md' }, { narrow: true });
expect(c.size).toBe('sm');
expect(c.narrow).toBe(true);
expect(c.explicit.size).toBe(false);
});
it('md 尺寸宽 320(UI-01 §5.1 视觉变更),sm 280', () => {
expect(SIZE_WIDTH.md).toBe(320);
expect(SIZE_WIDTH.sm).toBe(280);
});
});
describe('explicit 覆盖表(缺省 ≠ 显式覆盖)', () => {
it('仅真正提供的字段进 explicit(空串视为缺省)', () => {
const c = parseEmbedConfig({ slot: 'a', offsetX: '40', lang: '', imageFit: 'cover' });
expect(c.explicit.offsetX).toBe(true);
expect(c.explicit.imageFit).toBe(true);
expect(c.explicit.lang).toBe(false);
expect(c.explicit.interval).toBe(false);
});
});
describe('normalizeBackendSettings(iframe config 消息 / 公开端 settings)', () => {
it('合法输入原样通过', () => {
expect(
normalizeBackendSettings({
position: 'left',
size: 'sm',
intervalMs: 8000,
lang: 'en',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 2000,
}),
).toEqual({
position: 'left',
size: 'sm',
intervalMs: 8000,
lang: 'en',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 2000,
});
});
it('非法值逐字段回退缺省(枚举/布尔/数值夹取)', () => {
expect(
normalizeBackendSettings({
position: 'top',
size: 'xl',
intervalMs: 'x',
lang: 1,
collapsedDefault: 'yes',
imageFit: null,
offsetX: 999,
offsetBottom: -1,
zIndex: 0,
}),
).toEqual({
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false,
imageFit: 'contain',
offsetX: 120,
offsetBottom: 0,
zIndex: 1,
});
});
it('非对象输入返回 null(整体忽略)', () => {
expect(normalizeBackendSettings(null)).toBeNull();
expect(normalizeBackendSettings('x')).toBeNull();
expect(normalizeBackendSettings(undefined)).toBeNull();
});
});
describe('mergeBackendConfig(优先级:data-* 显式 > 后台 > 默认)', () => {
const backend = {
position: 'left',
size: 'sm',
intervalMs: 9000,
lang: 'en',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 48,
offsetBottom: 96,
zIndex: 2500,
};
it('未显式覆盖的字段采用后台值', () => {
const c = mergeBackendConfig(parseEmbedConfig({ slot: 'a' }), backend);
expect(c.position).toBe('left');
expect(c.intervalMs).toBe(9000);
expect(c.imageFit).toBe('cover');
expect(c.offsetX).toBe(48);
expect(c.offsetBottom).toBe(96);
expect(c.zIndex).toBe(2500);
expect(c.collapsedDefault).toBe(true);
});
it('非窄屏时 collapsedDefault 恒 false(收起默认由 iframe 侧按服务端设置下发)', () => {
expect(parseEmbedConfig({ slot: 'a' }, { narrow: false }).collapsedDefault).toBe(false);
it('data-* 显式覆盖的字段不被后台刷新冲掉', () => {
const c = mergeBackendConfig(
parseEmbedConfig({ slot: 'a', position: 'right', offsetX: '10', imageFit: 'contain' }),
backend,
);
expect(c.position).toBe('right');
expect(c.offsetX).toBe(10);
expect(c.imageFit).toBe('contain');
expect(c.intervalMs).toBe(9000); // 未显式 → 后台
});
it('后台缺字段/非法 → 契约默认(旧投影行兼容)', () => {
const c = mergeBackendConfig(parseEmbedConfig({ slot: 'a' }), { position: 'left' });
expect(c.imageFit).toBe('contain');
expect(c.offsetX).toBe(16);
expect(c.zIndex).toBe(1000);
expect(c.collapsedDefault).toBe(false);
});
it('后台非法输入 → 返回原配置(忽略整条消息)', () => {
const base = parseEmbedConfig({ slot: 'a', offsetX: '10' });
expect(mergeBackendConfig(base, null)).toBe(base);
});
it('窄屏强制优先于后台:size 恒 sm、collapsedDefault 恒 true', () => {
const c = mergeBackendConfig(parseEmbedConfig({ slot: 'a' }, { narrow: true }), {
...backend,
size: 'md',
collapsedDefault: false,
});
expect(c.size).toBe('sm');
expect(c.collapsedDefault).toBe(true);
});
});
describe('parseConfigMessage(版本闸 + 校验)', () => {
const settings = {
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
};
it('高版本 + 合法 settings → 归一结果', () => {
const r = parseConfigMessage({ photoliveEmbed: true, type: 'config', configVersion: 3, settings }, 1);
expect(r).toEqual({ configVersion: 3, settings });
});
it('版本不高(旧请求/等值)→ null(旧配置不得覆盖新配置)', () => {
expect(parseConfigMessage({ configVersion: 1, settings }, 1)).toBeNull();
expect(parseConfigMessage({ configVersion: 2, settings }, 5)).toBeNull();
});
it('首条消息(当前版本 0/null)接受', () => {
expect(parseConfigMessage({ configVersion: 1, settings }, 0)).not.toBeNull();
expect(parseConfigMessage({ configVersion: 1, settings }, null)).not.toBeNull();
});
it('形状非法(缺版本/缺 settings/非对象)→ null', () => {
expect(parseConfigMessage({ settings }, 0)).toBeNull();
expect(parseConfigMessage({ configVersion: 2 }, 0)).toBeNull();
expect(parseConfigMessage(null, 0)).toBeNull();
expect(parseConfigMessage('x', 0)).toBeNull();
});
});
describe('buildEmbedUrl', () => {
it('拼接 lang/pos/interval 查询参数', () => {
const url = buildEmbedUrl('https://ov.example.com', 'tok', {
lang: 'zh-CN',
position: 'left',
intervalMs: 7000,
});
expect(url).toBe('https://ov.example.com/embed/tok?lang=zh-CN&pos=left&interval=7000');
it('显式字段拼接 interval/fit;缺省不拼(后台设置经 iframe 响应生效)', () => {
const explicit = parseEmbedConfig({ slot: 'a', interval: '7000', imageFit: 'cover' });
expect(buildEmbedUrl('https://ov.example.com', 'tok', explicit)).toBe(
'https://ov.example.com/embed/tok?lang=auto&pos=right&interval=7000&fit=cover',
);
const bare = parseEmbedConfig({ slot: 'a' });
expect(buildEmbedUrl('https://ov.example.com', 'tok', bare)).toBe(
'https://ov.example.com/embed/tok?lang=auto&pos=right',
);
});
it('poll 显式时透传(dev/e2e 加速;生产构建忽略)', () => {
const c = parseEmbedConfig({ slot: 'a', poll: '2000' });
expect(buildEmbedUrl('https://ov.example.com', 'tok', c)).toContain('&poll=2000');
});
it('slot token 编码(防路径注入)', () => {
const url = buildEmbedUrl('https://ov.example.com', 'a/b?c=1', {
lang: 'auto',
position: 'right',
intervalMs: 5000,
});
const url = buildEmbedUrl('https://ov.example.com', 'a/b?c=1', parseEmbedConfig({ slot: 'a/b?c=1' }));
expect(url.startsWith('https://ov.example.com/embed/a%2Fb%3Fc%3D1?')).toBe(true);
expect(url).toContain('lang=auto&pos=right&interval=5000');
expect(url).toContain('lang=auto&pos=right');
});
});
describe('MESSAGE_TYPES(消息类型白名单)', () => {
it('resize/collapse/expand/config/destroy 全集', () => {
expect([...MESSAGE_TYPES].sort()).toEqual(['collapse', 'config', 'destroy', 'expand', 'resize'].sort());
});
});