This commit is contained in:
2026-04-08 21:26:18 +08:00
commit 8fdc7ac0c3
401 changed files with 53093 additions and 0 deletions

View File

@@ -0,0 +1,713 @@
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)
})
})