// 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?'); }); });