import type { CanvasRuntimeVariable } from '@/components/editor/canvas/context/runtime' import type { Layer, LayerBindingDefinition } from '@/components/editor/canvas/types' import { describe, expect, it, vi } from 'vitest' import { resolveLayerAppearance, resolveLayerBindingValue, resolveLayerDisplayValue, } from '@/components/editor/components/runtime' // ──── 工具函数:构造测试用 Layer ──── function createLayer(overrides: Partial = {}): Layer { return { id: 'layer-1', type: 'text', x: 0, y: 0, width: 200, height: 40, ...overrides, } } function createVariableMap(entries: Record): Record { const map: Record = {} for (const [path, { value, moduleLabel, propLabel }] of Object.entries(entries)) { map[path] = { path, moduleLabel: moduleLabel ?? path.split('.')[0] ?? 'MOD', moduleName: 'Module', propLabel: propLabel ?? path.split('.')[1] ?? path, propName: 'Property', type: 'analog', value, } } return map } // ============================================ // resolveLayerDisplayValue // ============================================ describe('resolveLayerDisplayValue — 图层显示值解析', () => { const emptyVarMap: Record = {} describe('text 类型图层', () => { it('无绑定时返回 config.content 的值', () => { const layer = createLayer({ type: 'text', config: { content: '静态文本' }, }) expect(resolveLayerDisplayValue(layer, emptyVarMap)).toBe('静态文本') }) it('无绑定且无 config 时返回空字符串', () => { const layer = createLayer({ type: 'text' }) expect(resolveLayerDisplayValue(layer, emptyVarMap)).toBe('') }) it('有绑定时优先使用绑定值', () => { const layer = createLayer({ type: 'text', config: { content: '静态文本' }, bindings: { value: 'MOD.temperature' }, }) const varMap = createVariableMap({ 'MOD.temperature': { value: '高温告警' }, }) expect(resolveLayerDisplayValue(layer, varMap)).toBe('高温告警') }) }) describe('number 类型图层', () => { it('无绑定时显示默认数值 0 并保留 2 位小数', () => { const layer = createLayer({ type: 'number' }) expect(resolveLayerDisplayValue(layer, emptyVarMap)).toBe('0.00') }) it('有绑定时显示变量值并格式化', () => { const layer = createLayer({ type: 'number', config: { decimals: 1 }, bindings: { value: 'MOD.temperature' }, }) const varMap = createVariableMap({ 'MOD.temperature': { value: 36.567 }, }) expect(resolveLayerDisplayValue(layer, varMap)).toBe('36.6') }) it('应正确应用前缀和后缀', () => { const layer = createLayer({ type: 'number', config: { decimals: 0, prefix: '温度:', suffix: '℃' }, bindings: { value: 'MOD.temp' }, }) const varMap = createVariableMap({ 'MOD.temp': { value: 25 } }) expect(resolveLayerDisplayValue(layer, varMap)).toBe('温度:25℃') }) it('非数值绑定应回退为 0', () => { const layer = createLayer({ type: 'number', config: { decimals: 2 }, bindings: { value: 'MOD.invalid' }, }) const varMap = createVariableMap({ 'MOD.invalid': { value: 'not-a-number' } }) expect(resolveLayerDisplayValue(layer, varMap)).toBe('0.00') }) it('小数位 decimals 配置为 0 时不显示小数部分', () => { const layer = createLayer({ type: 'number', config: { decimals: 0 }, bindings: { value: 'MOD.count' }, }) const varMap = createVariableMap({ 'MOD.count': { value: 42.999 } }) expect(resolveLayerDisplayValue(layer, varMap)).toBe('43') }) }) describe('rect 类型图层', () => { it('无绑定且无 value 时返回空字符串', () => { const layer = createLayer({ type: 'rect' }) expect(resolveLayerDisplayValue(layer, emptyVarMap)).toBe('') }) }) describe('null / undefined 值处理', () => { it('绑定值为 null 时返回空字符串', () => { const layer = createLayer({ type: 'text', bindings: { value: 'MOD.nullVar' }, }) const varMap = createVariableMap({ 'MOD.nullVar': { value: null } }) // 变量存在但值为 null → 在 resolveLayerBindingValue 中返回 null // resolveLayerDisplayValue 中 null 走 String 转换前会被拦截返回 '' expect(resolveLayerDisplayValue(layer, varMap)).toBe('') }) }) }) // ============================================ // resolveLayerBindingValue // ============================================ describe('resolveLayerBindingValue — 绑定值解析', () => { describe('variable 类型绑定', () => { it('字符串绑定应解析为变量引用', () => { const layer = createLayer({ bindings: { value: 'MOD.pressure' }, }) const varMap = createVariableMap({ 'MOD.pressure': { value: 101.3 } }) expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe(101.3) }) it('变量不存在时应返回 undefined', () => { const layer = createLayer({ bindings: { value: 'MOD.nonexistent' }, }) expect(resolveLayerBindingValue(layer, 'value', {})).toBeUndefined() }) it('无绑定定义时应返回 undefined', () => { const layer = createLayer() expect(resolveLayerBindingValue(layer, 'value', {})).toBeUndefined() }) it('对象格式的 variable 绑定应正确解析', () => { const binding: LayerBindingDefinition = { id: 'b1', type: 'variable', value: 'MOD.level', priority: 0, } const layer = createLayer({ bindings: { value: binding }, }) const varMap = createVariableMap({ 'MOD.level': { value: 85 } }) expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe(85) }) }) describe('expression 类型绑定', () => { it('简单数学表达式应正确计算', () => { const layer = createLayer({ bindings: { value: { id: 'e1', type: 'expression', value: 'vars["MOD.a"] + vars["MOD.b"]', variables: ['MOD.a', 'MOD.b'], priority: 0, }, }, }) const varMap = createVariableMap({ 'MOD.a': { value: 10 }, 'MOD.b': { value: 20 }, }) expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe(30) }) it('三元表达式应正确计算', () => { const layer = createLayer({ bindings: { value: { id: 'e2', type: 'expression', value: 'vars["MOD.flag"] ? "开启" : "关闭"', variables: ['MOD.flag'], priority: 0, }, }, }) const varMap = createVariableMap({ 'MOD.flag': { value: true } }) expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe('开启') }) it('使用 Math 内置对象的表达式', () => { const layer = createLayer({ bindings: { value: { id: 'e3', type: 'expression', value: 'Math.round(vars["MOD.val"] * 100) / 100', variables: ['MOD.val'], priority: 0, }, }, }) const varMap = createVariableMap({ 'MOD.val': { value: 3.14159 } }) expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe(3.14) }) it('无效表达式应返回 undefined 且不抛异常', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const layer = createLayer({ bindings: { value: { id: 'e4', type: 'expression', value: '!!!invalid syntax{{{', priority: 0, }, }, }) expect(resolveLayerBindingValue(layer, 'value', {})).toBeUndefined() expect(consoleSpy).toHaveBeenCalled() consoleSpy.mockRestore() }) it('通过模块标签访问变量的表达式', () => { const layer = createLayer({ bindings: { value: { id: 'e5', type: 'expression', value: 'MOD["temperature"] * 1.8 + 32', variables: ['MOD.temperature'], priority: 0, }, }, }) const varMap = createVariableMap({ 'MOD.temperature': { value: 100, moduleLabel: 'MOD', propLabel: 'temperature' }, }) // 100 * 1.8 + 32 = 212 (摄氏转华氏) expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe(212) }) }) describe('绑定优先级选择', () => { it('多条绑定规则取优先级最高的', () => { const layer = createLayer({ bindings: { value: [ { id: 'low', type: 'variable', value: 'MOD.low', priority: 1, }, { id: 'high', type: 'variable', value: 'MOD.high', priority: 10, }, { id: 'mid', type: 'variable', value: 'MOD.mid', priority: 5, }, ], }, }) const varMap = createVariableMap({ 'MOD.low': { value: 'low' }, 'MOD.high': { value: 'high' }, 'MOD.mid': { value: 'mid' }, }) expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe('high') }) it('优先级相同时取最后出现的', () => { const layer = createLayer({ bindings: { value: [ { id: 'a', type: 'variable', value: 'MOD.first', priority: 0, }, { id: 'b', type: 'variable', value: 'MOD.second', priority: 0, }, ], }, }) const varMap = createVariableMap({ 'MOD.first': { value: 'first' }, 'MOD.second': { value: 'second' }, }) // 同优先级时,后者 priority 不大于前者,所以前者保持选中 // 源码: priority > selectedPriority → 仅严格大于才替换,所以 first 胜出 expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe('first') }) }) describe('enabled/disabled 绑定过滤', () => { it('enabled: false 的绑定应被跳过', () => { const layer = createLayer({ bindings: { value: [ { id: 'disabled', type: 'variable', value: 'MOD.disabled_val', priority: 100, enabled: false, }, { id: 'enabled', type: 'variable', value: 'MOD.enabled_val', priority: 1, }, ], }, }) const varMap = createVariableMap({ 'MOD.disabled_val': { value: '不应出现' }, 'MOD.enabled_val': { value: '应该出现' }, }) expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe('应该出现') }) it('所有绑定都 disabled 时应返回 undefined', () => { const layer = createLayer({ bindings: { value: [ { id: 'd1', type: 'variable', value: 'MOD.a', priority: 1, enabled: false, }, { id: 'd2', type: 'variable', value: 'MOD.b', priority: 2, enabled: false, }, ], }, }) expect(resolveLayerBindingValue(layer, 'value', {})).toBeUndefined() }) it('绑定值为空字符串时应被跳过', () => { const layer = createLayer({ bindings: { value: [ { id: 'empty', type: 'variable', value: '', priority: 100, }, { id: 'valid', type: 'variable', value: 'MOD.val', priority: 1, }, ], }, }) const varMap = createVariableMap({ 'MOD.val': { value: 42 } }) expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe(42) }) it('绑定值为纯空格时应被跳过', () => { const layer = createLayer({ bindings: { value: { id: 'spaces', type: 'variable', value: ' ', priority: 0, }, }, }) expect(resolveLayerBindingValue(layer, 'value', {})).toBeUndefined() }) }) }) // ============================================ // resolveLayerAppearance // ============================================ describe('resolveLayerAppearance — 外观样式解析', () => { const emptyVarMap: Record = {} const autoTextColor = '#111827' describe('文字颜色解析', () => { it('无 style 和 config 时使用 autoTextColor', () => { const layer = createLayer({ type: 'text' }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.textColor).toBe(autoTextColor) }) it('style.text.color 优先于 autoTextColor', () => { const layer = createLayer({ type: 'text', style: { text: { color: '#FF0000' } }, }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.textColor).toBe('#FF0000') }) it('config.textColor 作为备选', () => { const layer = createLayer({ type: 'text', config: { textColor: '#00FF00' }, }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.textColor).toBe('#00FF00') }) }) describe('背景颜色解析', () => { it('rect 类型无配置时使用默认填充色 #D7EBFF', () => { const layer = createLayer({ type: 'rect' }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.backgroundColor).not.toBeNull() expect(result.backgroundColor!.startsWith('#D7EBFF')).toBe(true) }) it('text 类型无配置时背景为 null', () => { const layer = createLayer({ type: 'text' }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.backgroundColor).toBeNull() }) it('通过 style.fill.color 设置背景色', () => { const layer = createLayer({ type: 'text', style: { fill: { color: '#AABBCC' } }, }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.backgroundColor).not.toBeNull() expect(result.backgroundColor!.startsWith('#AABBCC')).toBe(true) }) }) describe('边框样式解析', () => { it('无配置时边框宽度为 0', () => { const layer = createLayer({ type: 'rect' }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.borderWidth).toBe(0) expect(result.borderStyle).toBe('solid') }) it('应正确读取 style.border 属性', () => { const layer = createLayer({ type: 'rect', style: { border: { color: '#333', width: 2, style: 'dashed', radius: 8 }, }, }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.borderColor).toBe('#333') expect(result.borderWidth).toBe(2) expect(result.borderStyle).toBe('dashed') expect(result.borderRadius).toBe(8) }) it('负数边框宽度应被 clamp 为 0', () => { const layer = createLayer({ type: 'rect', style: { border: { width: -5 } }, }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.borderWidth).toBe(0) }) }) describe('字体样式解析', () => { it('text 类型默认字号为 18', () => { const layer = createLayer({ type: 'text' }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.fontSize).toBe(18) }) it('number 类型默认字号为 24', () => { const layer = createLayer({ type: 'number' }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.fontSize).toBe(24) }) it('字号最小值为 10', () => { const layer = createLayer({ type: 'text', style: { text: { fontSize: 5 } }, }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.fontSize).toBe(10) }) it('number 类型默认字重为 700', () => { const layer = createLayer({ type: 'number' }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.fontWeight).toBe(700) }) it('text 类型默认字重为 500', () => { const layer = createLayer({ type: 'text' }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.fontWeight).toBe(500) }) it('默认对齐方式为 center / middle', () => { const layer = createLayer({ type: 'text' }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.textAlign).toBe('center') expect(result.verticalAlign).toBe('middle') }) }) describe('阴影样式解析', () => { it('无阴影配置时 boxShadow 为 none', () => { const layer = createLayer({ type: 'rect' }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.boxShadow).toBe('none') }) it('有阴影配置时应生成 CSS box-shadow 值', () => { const layer = createLayer({ type: 'rect', style: { shadow: { color: '#000000', blur: 10, offsetX: 2, offsetY: 4, enabled: true, }, }, }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.boxShadow).toBe('2px 4px 10px #000000') }) it('阴影 enabled 为 false 时返回 none', () => { const layer = createLayer({ type: 'rect', style: { shadow: { color: '#000000', blur: 10, offsetX: 2, offsetY: 4, enabled: false, }, }, }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.boxShadow).toBe('none') }) }) describe('通过绑定覆盖外观属性', () => { it('绑定值应覆盖静态样式属性', () => { const layer = createLayer({ type: 'text', style: { text: { color: '#111111' } }, bindings: { 'style.text.color': 'MOD.textColor', }, }) const varMap = createVariableMap({ 'MOD.textColor': { value: '#FF5500' }, }) const result = resolveLayerAppearance(layer, autoTextColor, varMap) expect(result.textColor).toBe('#FF5500') }) }) describe('displayValue 包含在返回结果中', () => { it('resolveLayerAppearance 结果包含 displayValue', () => { const layer = createLayer({ type: 'text', config: { content: '测试内容' }, }) const result = resolveLayerAppearance(layer, autoTextColor, emptyVarMap) expect(result.displayValue).toBe('测试内容') }) }) }) // ============================================ // 表达式安全沙盒 // ============================================ describe('表达式安全沙盒', () => { it('应拒绝包含 constructor 的表达式', () => { const layer = createLayer({ bindings: { value: { id: 'attack1', type: 'expression', value: 'this.constructor.constructor("return globalThis")()', priority: 0, }, }, }) expect(resolveLayerBindingValue(layer, 'value', {})).toBeUndefined() }) it('应拒绝包含 window 的表达式', () => { const layer = createLayer({ bindings: { value: { id: 'attack2', type: 'expression', value: 'window.location.href', priority: 0, }, }, }) expect(resolveLayerBindingValue(layer, 'value', {})).toBeUndefined() }) it('应拒绝包含 __proto__ 的表达式', () => { const layer = createLayer({ bindings: { value: { id: 'attack3', type: 'expression', value: '({}).__proto__', priority: 0, }, }, }) expect(resolveLayerBindingValue(layer, 'value', {})).toBeUndefined() }) it('正常表达式应不受影响', () => { const layer = createLayer({ bindings: { value: { id: 'safe', type: 'expression', value: 'vars["MOD.a"] + 10', variables: ['MOD.a'], priority: 0, }, }, }) const varMap = createVariableMap({ 'MOD.a': { value: 5 } }) expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe(15) }) it('使用 Math 的表达式应正常工作', () => { const layer = createLayer({ bindings: { value: { id: 'math', type: 'expression', value: 'Math.max(vars["MOD.a"], vars["MOD.b"])', variables: ['MOD.a', 'MOD.b'], priority: 0, }, }, }) const varMap = createVariableMap({ 'MOD.a': { value: 3 }, 'MOD.b': { value: 7 }, }) expect(resolveLayerBindingValue(layer, 'value', varMap)).toBe(7) }) })