Files
test/tests/unit/runtime/expressionEval.test.ts
2026-04-08 21:26:18 +08:00

714 lines
22 KiB
TypeScript
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.
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> = {}): Layer {
return {
id: 'layer-1',
type: 'text',
x: 0,
y: 0,
width: 200,
height: 40,
...overrides,
}
}
function createVariableMap(entries: Record<string, { value: unknown, moduleLabel?: string, propLabel?: string }>): Record<string, CanvasRuntimeVariable> {
const map: Record<string, CanvasRuntimeVariable> = {}
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<string, CanvasRuntimeVariable> = {}
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<string, CanvasRuntimeVariable> = {}
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)
})
})