66d1eb3e9f
- contracts: EmbedSlotSettings optional v2 fields (carousel default) + SlotSettingsPayload; gen + redocly green - api: DTO validation + normalization full-set (cn manage + ov public projection share normalizeStoredSettings) - embed-loader: params v2 parse/merge/url family, loader-v2.js fixed shell (border/radius/#10161E/safe-area, resize ignored, stack gap 12), dual-artifact build+sync; v1 source frozen - viewer: EmbedPage v=2 branch, EmbedStream (viewport/track/real anchors/collapse/empty), stream.ts pure fns, i18n zh/en - admin: types + utils (create default vertical-stream, clampScrollSpeed, v1/v2 snippet selection) - tests: loader 36 (+9 v2), viewer 222, admin 82, api unit 51, v1 embed e2e 20, v2 E01 gate 2 (195x280 desktop+mobile cross-origin, no footer) Co-Authored-By: Claude Code <noreply@anthropic.com>
424 lines
16 KiB
JavaScript
424 lines
16 KiB
JavaScript
// packages/embed-loader/src/params.test.js
|
||
// 纯函数单测:data-* 解析/缺省回退/夹取、显式覆盖表(缺省 ≠ 覆盖)、窄屏强制、
|
||
// URL 拼接、高度夹取、后台设置归一/合并优先级、config 消息裁决、消息类型白名单。
|
||
// DOM/宿主交互行为由 Playwright 宿主页覆盖(计划注明 node 模拟 DOM 太薄)。
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
MESSAGE_TYPES,
|
||
buildEmbedUrl,
|
||
buildEmbedUrlV2,
|
||
clampHeight,
|
||
mergeBackendConfig,
|
||
mergeBackendConfigV2,
|
||
normalizeBackendSettings,
|
||
normalizeBackendSettingsV2,
|
||
normalizeInterval,
|
||
parseConfigMessage,
|
||
parseConfigMessageV2,
|
||
parseEmbedConfig,
|
||
parseEmbedConfigV2,
|
||
SIZE_WIDTH,
|
||
} from './params.js';
|
||
|
||
describe('parseEmbedConfig', () => {
|
||
it('空 dataset 全缺省(slot null → 调用方 no-op)+ UI-01 新字段默认', () => {
|
||
const c = parseEmbedConfig({});
|
||
expect(c).toEqual({
|
||
slot: null,
|
||
lang: 'auto',
|
||
position: 'right',
|
||
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,
|
||
},
|
||
});
|
||
});
|
||
|
||
it('data-slot 保留并去首尾空白;非字符串(缺失)为 null', () => {
|
||
expect(parseEmbedConfig({ slot: ' tok ' }).slot).toBe('tok');
|
||
expect(parseEmbedConfig({}).slot).toBeNull();
|
||
expect(parseEmbedConfig({ slot: ' ' }).slot).toBeNull();
|
||
});
|
||
|
||
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]', () => {
|
||
expect(normalizeInterval('8000')).toBe(8000);
|
||
expect(normalizeInterval('100')).toBe(3000);
|
||
expect(normalizeInterval('999999')).toBe(30000);
|
||
expect(normalizeInterval('abc')).toBe(5000);
|
||
expect(normalizeInterval(undefined)).toBe(5000);
|
||
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('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('显式字段拼接 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', 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');
|
||
});
|
||
});
|
||
|
||
describe('MESSAGE_TYPES(消息类型白名单)', () => {
|
||
it('resize/collapse/expand/config/destroy 全集', () => {
|
||
expect([...MESSAGE_TYPES].sort()).toEqual(['collapse', 'config', 'destroy', 'expand', 'resize'].sort());
|
||
});
|
||
});
|
||
|
||
describe('clampHeight', () => {
|
||
it('正常值原样(取整)返回', () => {
|
||
expect(clampHeight(200)).toBe(200);
|
||
expect(clampHeight(200.6)).toBe(201);
|
||
});
|
||
|
||
it('夹取到 [60, 640]', () => {
|
||
expect(clampHeight(10)).toBe(60);
|
||
expect(clampHeight(9999)).toBe(640);
|
||
});
|
||
|
||
it('缺失/非数值返回 null(忽略该消息)', () => {
|
||
expect(clampHeight(null)).toBeNull();
|
||
expect(clampHeight(undefined)).toBeNull();
|
||
expect(clampHeight('abc')).toBeNull();
|
||
expect(clampHeight(NaN)).toBeNull();
|
||
expect(clampHeight(Infinity)).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── UI-04 v2(vertical-stream)参数面 ──
|
||
|
||
describe('parseEmbedConfigV2', () => {
|
||
it('空 dataset 全缺省:v2 字段默认 + explicit 全 false', () => {
|
||
const c = parseEmbedConfigV2({ slot: 'tok' }, {});
|
||
expect(c.version).toBe(2);
|
||
expect(c.slot).toBe('tok');
|
||
expect(c.lang).toBe('auto');
|
||
expect(c.position).toBe('right');
|
||
expect(c.speed).toBe(16);
|
||
expect(c.reactions).toBe(true);
|
||
expect(c.offsetX).toBe(16);
|
||
expect(c.offsetBottom).toBe(24);
|
||
expect(c.zIndex).toBe(1000);
|
||
expect(c.pollMs).toBeNull();
|
||
expect(c.explicit).toEqual({
|
||
lang: false,
|
||
position: false,
|
||
speed: false,
|
||
reactions: false,
|
||
offsetX: false,
|
||
offsetBottom: false,
|
||
zIndex: false,
|
||
});
|
||
});
|
||
|
||
it('data-speed 夹取 8..32;非法回退 16', () => {
|
||
expect(parseEmbedConfigV2({ slot: 'a', speed: '1' }, {}).speed).toBe(8);
|
||
expect(parseEmbedConfigV2({ slot: 'a', speed: '24' }, {}).speed).toBe(24);
|
||
expect(parseEmbedConfigV2({ slot: 'a', speed: '99' }, {}).speed).toBe(32);
|
||
expect(parseEmbedConfigV2({ slot: 'a', speed: 'abc' }, {}).speed).toBe(16);
|
||
expect(parseEmbedConfigV2({ slot: 'a' }, {}).explicit.speed).toBe(false);
|
||
expect(parseEmbedConfigV2({ slot: 'a', speed: '24' }, {}).explicit.speed).toBe(true);
|
||
});
|
||
|
||
it('data-reactions 仅 on/off;其他回退 true', () => {
|
||
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'off' }, {}).reactions).toBe(false);
|
||
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'on' }, {}).reactions).toBe(true);
|
||
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'yes' }, {}).reactions).toBe(true);
|
||
});
|
||
|
||
it('v1 布局字段(size/interval/imageFit)不在 v2 配置面(195×280 硬性)', () => {
|
||
const c = parseEmbedConfigV2({ slot: 'a', size: 'md', interval: '8000', imageFit: 'cover' }, {});
|
||
expect(c.size).toBeUndefined();
|
||
expect(c.intervalMs).toBeUndefined();
|
||
expect(c.imageFit).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('normalizeBackendSettingsV2 / mergeBackendConfigV2', () => {
|
||
it('v2 字段归一:displayMode 白名单 / speed 夹取 / reactions 布尔回退', () => {
|
||
const s = normalizeBackendSettingsV2({
|
||
position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false,
|
||
imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000,
|
||
displayMode: 'vertical-stream', scrollSpeed: 20, reactionsEnabled: false,
|
||
});
|
||
expect(s.displayMode).toBe('vertical-stream');
|
||
expect(s.scrollSpeed).toBe(20);
|
||
expect(s.reactionsEnabled).toBe(false);
|
||
expect(normalizeBackendSettingsV2({ displayMode: 'weird', scrollSpeed: 999, reactionsEnabled: 'x' }).displayMode).toBe('carousel');
|
||
expect(normalizeBackendSettingsV2({ scrollSpeed: 999 }).scrollSpeed).toBe(32);
|
||
expect(normalizeBackendSettingsV2({ reactionsEnabled: 'x' }).reactionsEnabled).toBe(true);
|
||
expect(normalizeBackendSettingsV2(null)).toBeNull();
|
||
});
|
||
|
||
it('合并优先级:data-* 显式保持,缺省字段取后台值', () => {
|
||
const config = parseEmbedConfigV2({ slot: 'a', speed: '24' }, {});
|
||
const merged = mergeBackendConfigV2(config, {
|
||
position: 'left', size: 'md', intervalMs: 5000, lang: 'en', collapsedDefault: true,
|
||
imageFit: 'contain', offsetX: 40, offsetBottom: 80, zIndex: 2000,
|
||
displayMode: 'vertical-stream', scrollSpeed: 12, reactionsEnabled: false,
|
||
});
|
||
expect(merged.speed).toBe(24); // 显式 data-speed 胜出
|
||
expect(merged.reactions).toBe(false); // 后台(未显式)
|
||
expect(merged.position).toBe('left');
|
||
expect(merged.lang).toBe('en');
|
||
expect(merged.offsetX).toBe(40);
|
||
expect(merged.offsetBottom).toBe(80);
|
||
expect(merged.zIndex).toBe(2000);
|
||
});
|
||
});
|
||
|
||
describe('parseConfigMessageV2 / buildEmbedUrlV2', () => {
|
||
it('config 消息:版本闸(低于等于忽略)+ 非法忽略', () => {
|
||
const settings = { position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false };
|
||
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 2, settings }, 1)).not.toBeNull();
|
||
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 1, settings }, 1)).toBeNull();
|
||
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 'x', settings }, 0)).toBeNull();
|
||
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 2, settings: null }, 0)).toBeNull();
|
||
});
|
||
|
||
it('URL:v=2 恒在;speed/reactions 仅显式拼接;poll 透传', () => {
|
||
const base = parseEmbedConfigV2({ slot: 'tok' }, {});
|
||
expect(buildEmbedUrlV2('https://e.test', 'tok', base)).toBe(
|
||
'https://e.test/embed/tok?v=2&lang=auto&pos=right',
|
||
);
|
||
const explicit = parseEmbedConfigV2({ slot: 'tok', speed: '20', reactions: 'off', poll: '500' }, {});
|
||
expect(buildEmbedUrlV2('https://e.test', 'tok', explicit)).toBe(
|
||
'https://e.test/embed/tok?v=2&lang=auto&pos=right&speed=20&reactions=off&poll=500',
|
||
);
|
||
});
|
||
|
||
it('槽 token 编码防注入(路径段转义)', () => {
|
||
const c = parseEmbedConfigV2({ slot: 'a/b?c' }, {});
|
||
expect(buildEmbedUrlV2('https://e.test', 'a/b?c', c)).toContain('/embed/a%2Fb%3Fc?');
|
||
});
|
||
});
|