Files
photo-live/packages/embed-loader/src/params.test.js
T
浅念 66d1eb3e9f feat(embed,ui04): E01 v2 contract skeleton — displayMode/scrollSpeed/reactionsEnabled, v2 loader entry and fixed 195x280 shell
- 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>
2026-09-22 12:50:28 +08:00

424 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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?');
});
});