all
This commit is contained in:
229
tests/unit/composables/useComponentTemplates.test.ts
Normal file
229
tests/unit/composables/useComponentTemplates.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { Layer } from '@/components/editor/canvas/types'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useComponentTemplates } from '@/composables/useComponentTemplates'
|
||||
|
||||
const RE_TPL_PREFIX = /^tpl_/
|
||||
const RE_DEFAULT_NAME = /^模板 \d+$/
|
||||
|
||||
// 模拟 @vueuse/core 的 useLocalStorage
|
||||
// useComponentTemplates 通过 useLocalStorage 管理持久化,
|
||||
// 测试中用 ref 替代以隔离 localStorage 副作用
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useLocalStorage: (_key: string, defaultValue: any) => ref(defaultValue),
|
||||
}
|
||||
})
|
||||
|
||||
function createMockLayer(overrides: Partial<Layer> = {}): Layer {
|
||||
return {
|
||||
id: `layer_${Math.random().toString(36).slice(2, 6)}`,
|
||||
type: 'rect',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 50,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useComponentTemplates', () => {
|
||||
let tpl: ReturnType<typeof useComponentTemplates>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
tpl = useComponentTemplates()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// ──── saveTemplate() ────
|
||||
|
||||
describe('saveTemplate() 保存模板', () => {
|
||||
it('应保存模板并返回模板对象', () => {
|
||||
const layers = [createMockLayer()]
|
||||
const result = tpl.saveTemplate('测试模板', layers)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.name).toBe('测试模板')
|
||||
expect(result!.layers).toHaveLength(1)
|
||||
expect(result!.id).toMatch(RE_TPL_PREFIX)
|
||||
expect(result!.createdAt).toBeTypeOf('number')
|
||||
})
|
||||
|
||||
it('空图层数组时应返回 null', () => {
|
||||
const result = tpl.saveTemplate('空模板', [])
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('名称为空字符串时应使用默认名称', () => {
|
||||
const layers = [createMockLayer()]
|
||||
const result = tpl.saveTemplate('', layers)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.name).toMatch(RE_DEFAULT_NAME)
|
||||
})
|
||||
|
||||
it('名称含前后空格时应自动 trim', () => {
|
||||
const layers = [createMockLayer()]
|
||||
const result = tpl.saveTemplate(' 模板名称 ', layers)
|
||||
|
||||
expect(result!.name).toBe('模板名称')
|
||||
})
|
||||
|
||||
it('应深拷贝图层数据,修改原图层不影响模板', () => {
|
||||
const layer = createMockLayer({ x: 10 })
|
||||
const layers = [layer]
|
||||
const result = tpl.saveTemplate('深拷贝测试', layers)
|
||||
|
||||
// 修改原图层
|
||||
layer.x = 999
|
||||
|
||||
expect(result!.layers[0].x).toBe(10)
|
||||
})
|
||||
|
||||
it('多次保存应生成不同的 id', () => {
|
||||
const layers = [createMockLayer()]
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
const t1 = tpl.saveTemplate('模板1', layers)
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:01Z'))
|
||||
const t2 = tpl.saveTemplate('模板2', layers)
|
||||
|
||||
expect(t1!.id).not.toBe(t2!.id)
|
||||
})
|
||||
})
|
||||
|
||||
// ──── templates(sortedTemplates)────
|
||||
|
||||
describe('templates 排序', () => {
|
||||
it('空状态返回空数组', () => {
|
||||
expect(tpl.templates.value).toEqual([])
|
||||
})
|
||||
|
||||
it('应按 createdAt 降序排列(新模板在前)', () => {
|
||||
const layers = [createMockLayer()]
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
tpl.saveTemplate('旧模板', layers)
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-02T00:00:00Z'))
|
||||
tpl.saveTemplate('新模板', layers)
|
||||
|
||||
expect(tpl.templates.value).toHaveLength(2)
|
||||
expect(tpl.templates.value[0].name).toBe('新模板')
|
||||
expect(tpl.templates.value[1].name).toBe('旧模板')
|
||||
})
|
||||
})
|
||||
|
||||
// ──── getTemplate() ────
|
||||
|
||||
describe('getTemplate() 按 id 获取模板', () => {
|
||||
it('存在时返回模板对象', () => {
|
||||
const layers = [createMockLayer()]
|
||||
const saved = tpl.saveTemplate('查找测试', layers)!
|
||||
|
||||
const found = tpl.getTemplate(saved.id)
|
||||
|
||||
expect(found).not.toBeNull()
|
||||
expect(found!.id).toBe(saved.id)
|
||||
expect(found!.name).toBe('查找测试')
|
||||
})
|
||||
|
||||
it('不存在时返回 null', () => {
|
||||
expect(tpl.getTemplate('nonexistent_id')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ──── removeTemplate() ────
|
||||
|
||||
describe('removeTemplate() 删除模板', () => {
|
||||
it('应按 id 删除指定模板', () => {
|
||||
const layers = [createMockLayer()]
|
||||
const t1 = tpl.saveTemplate('模板1', layers)!
|
||||
const t2 = tpl.saveTemplate('模板2', layers)!
|
||||
|
||||
tpl.removeTemplate(t1.id)
|
||||
|
||||
expect(tpl.templates.value).toHaveLength(1)
|
||||
expect(tpl.templates.value[0].id).toBe(t2.id)
|
||||
})
|
||||
|
||||
it('删除不存在的 id 不应报错', () => {
|
||||
const layers = [createMockLayer()]
|
||||
tpl.saveTemplate('模板', layers)
|
||||
|
||||
expect(() => tpl.removeTemplate('nonexistent')).not.toThrow()
|
||||
expect(tpl.templates.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ──── renameTemplate() ────
|
||||
|
||||
describe('renameTemplate() 重命名模板', () => {
|
||||
it('应更新指定模板的名称', () => {
|
||||
const layers = [createMockLayer()]
|
||||
const saved = tpl.saveTemplate('原始名称', layers)!
|
||||
|
||||
tpl.renameTemplate(saved.id, '新名称')
|
||||
|
||||
const updated = tpl.getTemplate(saved.id)
|
||||
expect(updated!.name).toBe('新名称')
|
||||
})
|
||||
|
||||
it('名称含空格时应自动 trim', () => {
|
||||
const layers = [createMockLayer()]
|
||||
const saved = tpl.saveTemplate('原始', layers)!
|
||||
|
||||
tpl.renameTemplate(saved.id, ' 修改后 ')
|
||||
|
||||
expect(tpl.getTemplate(saved.id)!.name).toBe('修改后')
|
||||
})
|
||||
|
||||
it('重命名不存在的 id 不应报错', () => {
|
||||
expect(() => tpl.renameTemplate('nonexistent', '新名称')).not.toThrow()
|
||||
})
|
||||
|
||||
it('重命名不应影响其他字段', () => {
|
||||
const layers = [createMockLayer({ x: 42 })]
|
||||
const saved = tpl.saveTemplate('原始', layers)!
|
||||
const originalCreatedAt = saved.createdAt
|
||||
|
||||
tpl.renameTemplate(saved.id, '新名称')
|
||||
|
||||
const updated = tpl.getTemplate(saved.id)!
|
||||
expect(updated.createdAt).toBe(originalCreatedAt)
|
||||
expect(updated.layers[0].x).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
// ──── 边界情况 ────
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('保存含多个图层的模板', () => {
|
||||
const layers = [
|
||||
createMockLayer({ x: 0, y: 0 }),
|
||||
createMockLayer({ x: 100, y: 100 }),
|
||||
createMockLayer({ x: 200, y: 200 }),
|
||||
]
|
||||
const result = tpl.saveTemplate('多图层模板', layers)
|
||||
|
||||
expect(result!.layers).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('图层含嵌套 config 对象时应深拷贝', () => {
|
||||
const config = { fill: 'red', nested: { value: 1 } }
|
||||
const layer = createMockLayer({ config })
|
||||
const result = tpl.saveTemplate('嵌套配置', [layer])
|
||||
|
||||
// 修改原始 config
|
||||
config.nested.value = 999
|
||||
|
||||
expect(result!.layers[0].config!.nested.value).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
182
tests/unit/composables/useOperationLog.test.ts
Normal file
182
tests/unit/composables/useOperationLog.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useOperationLog } from '@/composables/useOperationLog'
|
||||
|
||||
describe('useOperationLog', () => {
|
||||
let opLog: ReturnType<typeof useOperationLog>
|
||||
|
||||
beforeEach(() => {
|
||||
opLog = useOperationLog()
|
||||
opLog.clear()
|
||||
opLog.filterType.value = ''
|
||||
opLog.searchText.value = ''
|
||||
})
|
||||
|
||||
// ──── log() ────
|
||||
|
||||
describe('log() 添加日志条目', () => {
|
||||
it('应添加一条日志到 entries 列表头部', () => {
|
||||
opLog.log('创建', '图层A', '新建矩形图层')
|
||||
|
||||
expect(opLog.entries.value).toHaveLength(1)
|
||||
expect(opLog.entries.value[0]).toMatchObject({
|
||||
action: '创建',
|
||||
target: '图层A',
|
||||
detail: '新建矩形图层',
|
||||
})
|
||||
expect(opLog.entries.value[0].id).toBeTruthy()
|
||||
expect(opLog.entries.value[0].timestamp).toBeTruthy()
|
||||
})
|
||||
|
||||
it('新日志应排在列表最前面(降序)', () => {
|
||||
opLog.log('创建', '图层A', '第一条')
|
||||
opLog.log('删除', '图层B', '第二条')
|
||||
|
||||
expect(opLog.entries.value[0].action).toBe('删除')
|
||||
expect(opLog.entries.value[1].action).toBe('创建')
|
||||
})
|
||||
|
||||
it('每条日志应有唯一 id', () => {
|
||||
opLog.log('创建', '图层A', '详情A')
|
||||
opLog.log('创建', '图层B', '详情B')
|
||||
|
||||
const ids = opLog.entries.value.map(e => e.id)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
})
|
||||
|
||||
it('超过 500 条时应截断旧日志', () => {
|
||||
for (let i = 0; i < 510; i++) {
|
||||
opLog.log('操作', `目标${i}`, `详情${i}`)
|
||||
}
|
||||
|
||||
expect(opLog.entries.value).toHaveLength(500)
|
||||
// 最新的应该在最前面
|
||||
expect(opLog.entries.value[0].detail).toBe('详情509')
|
||||
})
|
||||
})
|
||||
|
||||
// ──── clear() ────
|
||||
|
||||
describe('clear() 清空日志', () => {
|
||||
it('应清空所有条目', () => {
|
||||
opLog.log('创建', '图层', '详情')
|
||||
opLog.log('删除', '图层', '详情')
|
||||
expect(opLog.entries.value.length).toBeGreaterThan(0)
|
||||
|
||||
opLog.clear()
|
||||
|
||||
expect(opLog.entries.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('清空后 filteredEntries 也应为空', () => {
|
||||
opLog.log('创建', '图层', '详情')
|
||||
opLog.clear()
|
||||
|
||||
expect(opLog.filteredEntries.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ──── filteredEntries ────
|
||||
|
||||
describe('filteredEntries 过滤逻辑', () => {
|
||||
beforeEach(() => {
|
||||
opLog.log('创建', '图层A', '新建矩形')
|
||||
opLog.log('删除', '图层B', '删除圆形')
|
||||
opLog.log('修改', '图层C', '修改颜色属性')
|
||||
opLog.log('创建', '图层D', '新建文本')
|
||||
})
|
||||
|
||||
it('无过滤条件时应返回全部条目', () => {
|
||||
expect(opLog.filteredEntries.value).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('按 filterType 过滤 action', () => {
|
||||
opLog.filterType.value = '创建'
|
||||
|
||||
expect(opLog.filteredEntries.value).toHaveLength(2)
|
||||
expect(opLog.filteredEntries.value.every(e => e.action === '创建')).toBe(true)
|
||||
})
|
||||
|
||||
it('按 searchText 模糊搜索 detail', () => {
|
||||
opLog.searchText.value = '矩形'
|
||||
|
||||
expect(opLog.filteredEntries.value).toHaveLength(1)
|
||||
expect(opLog.filteredEntries.value[0].detail).toBe('新建矩形')
|
||||
})
|
||||
|
||||
it('按 searchText 模糊搜索 target', () => {
|
||||
opLog.searchText.value = '图层B'
|
||||
|
||||
expect(opLog.filteredEntries.value).toHaveLength(1)
|
||||
expect(opLog.filteredEntries.value[0].target).toBe('图层B')
|
||||
})
|
||||
|
||||
it('searchText 不区分大小写', () => {
|
||||
opLog.log('创建', 'LayerX', 'New Rectangle')
|
||||
opLog.searchText.value = 'rectangle'
|
||||
|
||||
expect(opLog.filteredEntries.value).toHaveLength(1)
|
||||
expect(opLog.filteredEntries.value[0].detail).toBe('New Rectangle')
|
||||
})
|
||||
|
||||
it('同时使用 filterType 和 searchText', () => {
|
||||
opLog.filterType.value = '创建'
|
||||
opLog.searchText.value = '文本'
|
||||
|
||||
expect(opLog.filteredEntries.value).toHaveLength(1)
|
||||
expect(opLog.filteredEntries.value[0].target).toBe('图层D')
|
||||
})
|
||||
|
||||
it('过滤条件无匹配时返回空数组', () => {
|
||||
opLog.filterType.value = '不存在的操作'
|
||||
|
||||
expect(opLog.filteredEntries.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ──── exportJSON() ────
|
||||
|
||||
describe('exportJSON() 导出日志', () => {
|
||||
it('应创建下载链接并触发点击', () => {
|
||||
opLog.log('创建', '图层', '详情')
|
||||
|
||||
const mockClick = vi.fn()
|
||||
const mockAppendChild = vi.spyOn(document.body, 'appendChild').mockImplementation(node => node)
|
||||
const mockRemoveChild = vi.spyOn(document.body, 'removeChild').mockImplementation(node => node)
|
||||
const mockCreateElement = vi.spyOn(document, 'createElement').mockReturnValue({
|
||||
get href() { return '' },
|
||||
set href(_: string) {},
|
||||
get download() { return '' },
|
||||
set download(_: string) {},
|
||||
click: mockClick,
|
||||
} as unknown as HTMLAnchorElement)
|
||||
const mockRevokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const mockCreateObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test')
|
||||
|
||||
opLog.exportJSON()
|
||||
|
||||
expect(mockCreateObjectURL).toHaveBeenCalled()
|
||||
expect(mockClick).toHaveBeenCalled()
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test')
|
||||
|
||||
mockAppendChild.mockRestore()
|
||||
mockRemoveChild.mockRestore()
|
||||
mockCreateElement.mockRestore()
|
||||
mockRevokeObjectURL.mockRestore()
|
||||
mockCreateObjectURL.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// ──── 单例共享 ────
|
||||
|
||||
describe('单例模式', () => {
|
||||
it('多次调用 useOperationLog() 应共享同一份状态', () => {
|
||||
const log1 = useOperationLog()
|
||||
const log2 = useOperationLog()
|
||||
|
||||
log1.log('测试', '目标', '详情')
|
||||
|
||||
expect(log2.entries.value).toHaveLength(log1.entries.value.length)
|
||||
expect(log2.entries.value[0].id).toBe(log1.entries.value[0].id)
|
||||
})
|
||||
})
|
||||
})
|
||||
304
tests/unit/composables/useRuntimeConsole.test.ts
Normal file
304
tests/unit/composables/useRuntimeConsole.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useRuntimeConsole } from '@/composables/useRuntimeConsole'
|
||||
|
||||
describe('useRuntimeConsole', () => {
|
||||
let console_: ReturnType<typeof useRuntimeConsole>
|
||||
|
||||
beforeEach(() => {
|
||||
console_ = useRuntimeConsole()
|
||||
console_.clear()
|
||||
console_.levelFilter.value = ''
|
||||
console_.categoryFilter.value = ''
|
||||
console_.searchText.value = ''
|
||||
})
|
||||
|
||||
// ──── log() ────
|
||||
|
||||
describe('log() 添加运行日志', () => {
|
||||
it('应添加一条日志到列表头部', () => {
|
||||
console_.log('INFO', '系统', '启动完成')
|
||||
|
||||
expect(console_.entries.value).toHaveLength(1)
|
||||
expect(console_.entries.value[0]).toMatchObject({
|
||||
level: 'INFO',
|
||||
category: '系统',
|
||||
message: '启动完成',
|
||||
})
|
||||
})
|
||||
|
||||
it('应支持 quality 和 source 可选参数', () => {
|
||||
console_.log('WARN', '信号', '信号质量下降', 'BAD', 'PLC-01')
|
||||
|
||||
const entry = console_.entries.value[0]
|
||||
expect(entry.quality).toBe('BAD')
|
||||
expect(entry.source).toBe('PLC-01')
|
||||
})
|
||||
|
||||
it('quality 和 source 未提供时应为 undefined', () => {
|
||||
console_.log('INFO', '系统', '正常运行')
|
||||
|
||||
const entry = console_.entries.value[0]
|
||||
expect(entry.quality).toBeUndefined()
|
||||
expect(entry.source).toBeUndefined()
|
||||
})
|
||||
|
||||
it('新日志应排在最前面', () => {
|
||||
console_.log('INFO', '系统', '第一条')
|
||||
console_.log('ERROR', '告警', '第二条')
|
||||
|
||||
expect(console_.entries.value[0].message).toBe('第二条')
|
||||
expect(console_.entries.value[1].message).toBe('第一条')
|
||||
})
|
||||
|
||||
it('超过 1000 条时应截断旧日志', () => {
|
||||
for (let i = 0; i < 1010; i++) {
|
||||
console_.log('INFO', '系统', `消息${i}`)
|
||||
}
|
||||
|
||||
expect(console_.entries.value).toHaveLength(1000)
|
||||
expect(console_.entries.value[0].message).toBe('消息1009')
|
||||
})
|
||||
|
||||
it('每条日志应有唯一 id 和时间戳', () => {
|
||||
console_.log('INFO', '系统', '消息A')
|
||||
console_.log('INFO', '系统', '消息B')
|
||||
|
||||
const [a, b] = console_.entries.value
|
||||
expect(a.id).not.toBe(b.id)
|
||||
expect(a.timestamp).toBeTruthy()
|
||||
expect(b.timestamp).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ──── 便捷方法 info/warn/error ────
|
||||
|
||||
describe('info() 便捷方法', () => {
|
||||
it('应以 INFO 级别记录', () => {
|
||||
console_.info('系统', '信息消息')
|
||||
|
||||
expect(console_.entries.value[0].level).toBe('INFO')
|
||||
expect(console_.entries.value[0].message).toBe('信息消息')
|
||||
})
|
||||
|
||||
it('应支持 quality 参数', () => {
|
||||
console_.info('信号', '信号正常', 'GOOD')
|
||||
|
||||
expect(console_.entries.value[0].quality).toBe('GOOD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('warn() 便捷方法', () => {
|
||||
it('应以 WARN 级别记录', () => {
|
||||
console_.warn('系统', '警告消息')
|
||||
|
||||
expect(console_.entries.value[0].level).toBe('WARN')
|
||||
})
|
||||
|
||||
it('应支持 quality 参数', () => {
|
||||
console_.warn('信号', '信号不确定', 'UNCERTAIN')
|
||||
|
||||
expect(console_.entries.value[0].quality).toBe('UNCERTAIN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error() 便捷方法', () => {
|
||||
it('应以 ERROR 级别记录', () => {
|
||||
console_.error('系统', '错误消息')
|
||||
|
||||
expect(console_.entries.value[0].level).toBe('ERROR')
|
||||
})
|
||||
|
||||
it('应支持 source 参数', () => {
|
||||
console_.error('通信', '连接超时', 'PLC-02')
|
||||
|
||||
expect(console_.entries.value[0].source).toBe('PLC-02')
|
||||
})
|
||||
|
||||
it('quality 应为 undefined(error 不传 quality)', () => {
|
||||
console_.error('系统', '错误消息', 'source')
|
||||
|
||||
expect(console_.entries.value[0].quality).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ──── clear() ────
|
||||
|
||||
describe('clear() 清空日志', () => {
|
||||
it('应清空所有条目', () => {
|
||||
console_.info('系统', '消息1')
|
||||
console_.warn('告警', '消息2')
|
||||
console_.clear()
|
||||
|
||||
expect(console_.entries.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ──── filteredEntries ────
|
||||
|
||||
describe('filteredEntries 过滤逻辑', () => {
|
||||
beforeEach(() => {
|
||||
console_.info('系统', '系统启动')
|
||||
console_.warn('信号', '信号波动')
|
||||
console_.error('通信', '连接中断', 'PLC-01')
|
||||
console_.info('信号', '信号恢复正常')
|
||||
})
|
||||
|
||||
it('无过滤条件时应返回全部', () => {
|
||||
expect(console_.filteredEntries.value).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('按 levelFilter 过滤级别', () => {
|
||||
console_.levelFilter.value = 'INFO'
|
||||
|
||||
expect(console_.filteredEntries.value).toHaveLength(2)
|
||||
expect(console_.filteredEntries.value.every(e => e.level === 'INFO')).toBe(true)
|
||||
})
|
||||
|
||||
it('按 categoryFilter 过滤分类', () => {
|
||||
console_.categoryFilter.value = '信号'
|
||||
|
||||
expect(console_.filteredEntries.value).toHaveLength(2)
|
||||
expect(console_.filteredEntries.value.every(e => e.category === '信号')).toBe(true)
|
||||
})
|
||||
|
||||
it('按 searchText 模糊搜索 message', () => {
|
||||
console_.searchText.value = '连接'
|
||||
|
||||
expect(console_.filteredEntries.value).toHaveLength(1)
|
||||
expect(console_.filteredEntries.value[0].message).toBe('连接中断')
|
||||
})
|
||||
|
||||
it('按 searchText 模糊搜索 source', () => {
|
||||
console_.searchText.value = 'PLC'
|
||||
|
||||
expect(console_.filteredEntries.value).toHaveLength(1)
|
||||
expect(console_.filteredEntries.value[0].source).toBe('PLC-01')
|
||||
})
|
||||
|
||||
it('按 searchText 模糊搜索 category', () => {
|
||||
console_.searchText.value = '通信'
|
||||
|
||||
expect(console_.filteredEntries.value).toHaveLength(1)
|
||||
expect(console_.filteredEntries.value[0].category).toBe('通信')
|
||||
})
|
||||
|
||||
it('searchText 不区分大小写', () => {
|
||||
console_.log('INFO', 'System', 'Connection OK', undefined, 'Device-A')
|
||||
console_.searchText.value = 'connection'
|
||||
|
||||
expect(console_.filteredEntries.value).toHaveLength(1)
|
||||
expect(console_.filteredEntries.value[0].message).toBe('Connection OK')
|
||||
})
|
||||
|
||||
it('多个过滤条件组合使用', () => {
|
||||
console_.levelFilter.value = 'INFO'
|
||||
console_.categoryFilter.value = '信号'
|
||||
|
||||
expect(console_.filteredEntries.value).toHaveLength(1)
|
||||
expect(console_.filteredEntries.value[0].message).toBe('信号恢复正常')
|
||||
})
|
||||
|
||||
it('三个过滤条件同时使用', () => {
|
||||
console_.levelFilter.value = 'INFO'
|
||||
console_.categoryFilter.value = '信号'
|
||||
console_.searchText.value = '恢复'
|
||||
|
||||
expect(console_.filteredEntries.value).toHaveLength(1)
|
||||
expect(console_.filteredEntries.value[0].message).toBe('信号恢复正常')
|
||||
})
|
||||
|
||||
it('过滤条件无匹配时返回空数组', () => {
|
||||
console_.levelFilter.value = 'ERROR'
|
||||
console_.categoryFilter.value = '系统'
|
||||
|
||||
expect(console_.filteredEntries.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ──── categories ────
|
||||
|
||||
describe('categories 计算属性', () => {
|
||||
it('空日志时返回空数组', () => {
|
||||
expect(console_.categories.value).toEqual([])
|
||||
})
|
||||
|
||||
it('应返回去重排序后的分类列表', () => {
|
||||
console_.info('系统', '消息1')
|
||||
console_.info('信号', '消息2')
|
||||
console_.warn('系统', '消息3')
|
||||
console_.error('通信', '消息4')
|
||||
|
||||
expect(console_.categories.value).toEqual(['信号', '系统', '通信'])
|
||||
})
|
||||
})
|
||||
|
||||
// ──── levelCounts ────
|
||||
|
||||
describe('levelCounts 计算属性', () => {
|
||||
it('空日志时所有计数为零', () => {
|
||||
expect(console_.levelCounts.value).toEqual({ INFO: 0, WARN: 0, ERROR: 0 })
|
||||
})
|
||||
|
||||
it('应正确统计各级别数量', () => {
|
||||
console_.info('系统', '消息1')
|
||||
console_.info('系统', '消息2')
|
||||
console_.warn('告警', '消息3')
|
||||
console_.error('通信', '消息4')
|
||||
console_.error('通信', '消息5')
|
||||
console_.error('通信', '消息6')
|
||||
|
||||
expect(console_.levelCounts.value).toEqual({
|
||||
INFO: 2,
|
||||
WARN: 1,
|
||||
ERROR: 3,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ──── exportJSON() ────
|
||||
|
||||
describe('exportJSON() 导出日志', () => {
|
||||
it('应创建 Blob 下载并清理', () => {
|
||||
console_.info('系统', '测试消息')
|
||||
|
||||
const mockClick = vi.fn()
|
||||
const mockAppendChild = vi.spyOn(document.body, 'appendChild').mockImplementation(node => node)
|
||||
const mockRemoveChild = vi.spyOn(document.body, 'removeChild').mockImplementation(node => node)
|
||||
const mockCreateElement = vi.spyOn(document, 'createElement').mockReturnValue({
|
||||
get href() { return '' },
|
||||
set href(_: string) {},
|
||||
get download() { return '' },
|
||||
set download(_: string) {},
|
||||
click: mockClick,
|
||||
} as unknown as HTMLAnchorElement)
|
||||
const mockRevokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const mockCreateObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test')
|
||||
|
||||
console_.exportJSON()
|
||||
|
||||
expect(mockCreateObjectURL).toHaveBeenCalled()
|
||||
expect(mockClick).toHaveBeenCalled()
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test')
|
||||
|
||||
mockAppendChild.mockRestore()
|
||||
mockRemoveChild.mockRestore()
|
||||
mockCreateElement.mockRestore()
|
||||
mockRevokeObjectURL.mockRestore()
|
||||
mockCreateObjectURL.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// ──── 单例共享 ────
|
||||
|
||||
describe('单例模式', () => {
|
||||
it('多次调用 useRuntimeConsole() 应共享同一份状态', () => {
|
||||
const c1 = useRuntimeConsole()
|
||||
const c2 = useRuntimeConsole()
|
||||
|
||||
c1.info('系统', '测试')
|
||||
|
||||
expect(c2.entries.value).toHaveLength(c1.entries.value.length)
|
||||
expect(c2.entries.value[0].id).toBe(c1.entries.value[0].id)
|
||||
})
|
||||
})
|
||||
})
|
||||
713
tests/unit/runtime/expressionEval.test.ts
Normal file
713
tests/unit/runtime/expressionEval.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
477
tests/unit/schema/componentSchema.test.ts
Normal file
477
tests/unit/schema/componentSchema.test.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
import {
|
||||
barComponentSchema,
|
||||
buttonComponentConfigSchema,
|
||||
buttonComponentSchema,
|
||||
componentBaseSchema,
|
||||
componentEventSchema,
|
||||
componentTypeSchema,
|
||||
numberComponentConfigSchema,
|
||||
numberComponentSchema,
|
||||
rectComponentSchema,
|
||||
textComponentConfigSchema,
|
||||
textComponentSchema,
|
||||
} from '@cslab-dcs/schema/canvas/component'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// ──── 测试辅助函数 ────
|
||||
|
||||
function createValidBase(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
type: 'rect',
|
||||
name: '测试组件',
|
||||
position: { x: 100, y: 200 },
|
||||
size: { width: 300, height: 150 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// componentTypeSchema — 组件类型枚举
|
||||
// ============================================
|
||||
|
||||
describe('componentTypeSchema — 组件类型枚举', () => {
|
||||
it('应接受所有合法的组件类型', () => {
|
||||
const validTypes = ['rect', 'number', 'text', 'bar', 'button', 'pidController', 'canvasSwitcher', 'custom']
|
||||
for (const type of validTypes) {
|
||||
expect(componentTypeSchema.safeParse(type).success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('应拒绝无效的组件类型', () => {
|
||||
expect(componentTypeSchema.safeParse('invalid').success).toBe(false)
|
||||
expect(componentTypeSchema.safeParse('').success).toBe(false)
|
||||
expect(componentTypeSchema.safeParse(123).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// componentBaseSchema — 组件基础属性
|
||||
// ============================================
|
||||
|
||||
describe('componentBaseSchema — 组件基础属性', () => {
|
||||
it('最小合法数据应通过验证', () => {
|
||||
const result = componentBaseSchema.safeParse(createValidBase())
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('id 必须是合法的 UUID', () => {
|
||||
const result = componentBaseSchema.safeParse(createValidBase({ id: 'not-a-uuid' }))
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('id 不能为空', () => {
|
||||
const result = componentBaseSchema.safeParse(createValidBase({ id: '' }))
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('name 不能为空字符串', () => {
|
||||
const result = componentBaseSchema.safeParse(createValidBase({ name: '' }))
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('position 必须包含 x 和 y', () => {
|
||||
const result = componentBaseSchema.safeParse(createValidBase({ position: { x: 10 } }))
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('size 的 width 和 height 必须为正数', () => {
|
||||
const negativeWidth = componentBaseSchema.safeParse(createValidBase({ size: { width: -10, height: 100 } }))
|
||||
expect(negativeWidth.success).toBe(false)
|
||||
|
||||
const zeroHeight = componentBaseSchema.safeParse(createValidBase({ size: { width: 100, height: 0 } }))
|
||||
expect(zeroHeight.success).toBe(false)
|
||||
})
|
||||
|
||||
it('默认值:zIndex 应为 0', () => {
|
||||
const result = componentBaseSchema.parse(createValidBase())
|
||||
expect(result.zIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('默认值:opacity 应为 1', () => {
|
||||
const result = componentBaseSchema.parse(createValidBase())
|
||||
expect(result.opacity).toBe(1)
|
||||
})
|
||||
|
||||
it('默认值:visible 应为 true', () => {
|
||||
const result = componentBaseSchema.parse(createValidBase())
|
||||
expect(result.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('opacity 必须在 0-1 范围内', () => {
|
||||
const tooHigh = componentBaseSchema.safeParse(createValidBase({ opacity: 1.5 }))
|
||||
expect(tooHigh.success).toBe(false)
|
||||
|
||||
const tooLow = componentBaseSchema.safeParse(createValidBase({ opacity: -0.1 }))
|
||||
expect(tooLow.success).toBe(false)
|
||||
|
||||
const valid = componentBaseSchema.safeParse(createValidBase({ opacity: 0.5 }))
|
||||
expect(valid.success).toBe(true)
|
||||
})
|
||||
|
||||
it('zIndex 必须是整数', () => {
|
||||
const floatZIndex = componentBaseSchema.safeParse(createValidBase({ zIndex: 1.5 }))
|
||||
expect(floatZIndex.success).toBe(false)
|
||||
})
|
||||
|
||||
it('可选属性 bindings / style / events / metadata 为空时通过验证', () => {
|
||||
const data = createValidBase()
|
||||
const result = componentBaseSchema.safeParse(data)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('events 数组应验证每个事件的格式', () => {
|
||||
const data = createValidBase({
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
trigger: 'click',
|
||||
action: { type: 'callMethod' },
|
||||
},
|
||||
],
|
||||
})
|
||||
const result = componentBaseSchema.safeParse(data)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// componentEventSchema — 事件 Schema
|
||||
// ============================================
|
||||
|
||||
describe('componentEventSchema — 事件 Schema', () => {
|
||||
it('合法事件应通过验证', () => {
|
||||
const event = {
|
||||
id: 'evt-1',
|
||||
trigger: 'click',
|
||||
action: { type: 'callMethod' },
|
||||
}
|
||||
expect(componentEventSchema.safeParse(event).success).toBe(true)
|
||||
})
|
||||
|
||||
it('trigger 只能是 click / dblclick', () => {
|
||||
const invalid = {
|
||||
id: 'evt-1',
|
||||
trigger: 'hover',
|
||||
action: { type: 'callMethod' },
|
||||
}
|
||||
expect(componentEventSchema.safeParse(invalid).success).toBe(false)
|
||||
})
|
||||
|
||||
it('dblclick 触发器应通过验证', () => {
|
||||
const event = {
|
||||
id: 'evt-1',
|
||||
trigger: 'dblclick',
|
||||
action: { type: 'callMethod' },
|
||||
}
|
||||
expect(componentEventSchema.safeParse(event).success).toBe(true)
|
||||
})
|
||||
|
||||
it('action.type 不能为空字符串', () => {
|
||||
const invalid = {
|
||||
id: 'evt-1',
|
||||
trigger: 'click',
|
||||
action: { type: '' },
|
||||
}
|
||||
expect(componentEventSchema.safeParse(invalid).success).toBe(false)
|
||||
})
|
||||
|
||||
it('可选属性 condition 应通过验证', () => {
|
||||
const event = {
|
||||
id: 'evt-1',
|
||||
trigger: 'click',
|
||||
condition: 'vars.status === "running"',
|
||||
action: { type: 'navigate', payload: { target: 'page2' } },
|
||||
}
|
||||
const result = componentEventSchema.safeParse(event)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// rectComponentSchema — 矩形组件
|
||||
// ============================================
|
||||
|
||||
describe('rectComponentSchema — 矩形组件', () => {
|
||||
it('合法矩形组件应通过验证', () => {
|
||||
const data = createValidBase({ type: 'rect' })
|
||||
expect(rectComponentSchema.safeParse(data).success).toBe(true)
|
||||
})
|
||||
|
||||
it('type 必须是 rect', () => {
|
||||
const data = createValidBase({ type: 'text' })
|
||||
expect(rectComponentSchema.safeParse(data).success).toBe(false)
|
||||
})
|
||||
|
||||
it('可选 config.fillColor', () => {
|
||||
const data = createValidBase({
|
||||
type: 'rect',
|
||||
config: { fillColor: '#FF0000' },
|
||||
})
|
||||
const result = rectComponentSchema.safeParse(data)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.config?.fillColor).toBe('#FF0000')
|
||||
}
|
||||
})
|
||||
|
||||
it('无 config 时也应通过', () => {
|
||||
const data = createValidBase({ type: 'rect' })
|
||||
expect(rectComponentSchema.safeParse(data).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// numberComponentSchema / numberComponentConfigSchema — 数字组件
|
||||
// ============================================
|
||||
|
||||
describe('numberComponentConfigSchema — 数字组件配置', () => {
|
||||
it('空对象应通过(全部可选或有默认值)', () => {
|
||||
const result = numberComponentConfigSchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('默认值:decimals 应为 2', () => {
|
||||
const result = numberComponentConfigSchema.parse({})
|
||||
expect(result.decimals).toBe(2)
|
||||
})
|
||||
|
||||
it('默认值:thousandsSeparator 应为 false', () => {
|
||||
const result = numberComponentConfigSchema.parse({})
|
||||
expect(result.thousandsSeparator).toBe(false)
|
||||
})
|
||||
|
||||
it('默认值:showTrend 应为 false', () => {
|
||||
const result = numberComponentConfigSchema.parse({})
|
||||
expect(result.showTrend).toBe(false)
|
||||
})
|
||||
|
||||
it('decimals 必须在 0-10 范围内', () => {
|
||||
expect(numberComponentConfigSchema.safeParse({ decimals: -1 }).success).toBe(false)
|
||||
expect(numberComponentConfigSchema.safeParse({ decimals: 11 }).success).toBe(false)
|
||||
expect(numberComponentConfigSchema.safeParse({ decimals: 5 }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('decimals 必须是整数', () => {
|
||||
expect(numberComponentConfigSchema.safeParse({ decimals: 1.5 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('prefix 和 suffix 应为字符串', () => {
|
||||
const result = numberComponentConfigSchema.safeParse({
|
||||
prefix: 'T=',
|
||||
suffix: '℃',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('numberComponentSchema — 数字组件', () => {
|
||||
it('合法数字组件应通过验证', () => {
|
||||
const data = createValidBase({
|
||||
type: 'number',
|
||||
config: { decimals: 3, suffix: '℃' },
|
||||
})
|
||||
expect(numberComponentSchema.safeParse(data).success).toBe(true)
|
||||
})
|
||||
|
||||
it('type 必须是 number', () => {
|
||||
const data = createValidBase({ type: 'rect' })
|
||||
expect(numberComponentSchema.safeParse(data).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// textComponentSchema / textComponentConfigSchema — 文本组件
|
||||
// ============================================
|
||||
|
||||
describe('textComponentConfigSchema — 文本组件配置', () => {
|
||||
it('空对象应通过', () => {
|
||||
expect(textComponentConfigSchema.safeParse({}).success).toBe(true)
|
||||
})
|
||||
|
||||
it('默认值:content 应为空字符串', () => {
|
||||
const result = textComponentConfigSchema.parse({})
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
|
||||
it('默认值:isDynamic 应为 false', () => {
|
||||
const result = textComponentConfigSchema.parse({})
|
||||
expect(result.isDynamic).toBe(false)
|
||||
})
|
||||
|
||||
it('包含所有字段时应通过验证', () => {
|
||||
const config = {
|
||||
fillColor: '#FFFFFF',
|
||||
textColor: '#000000',
|
||||
content: '测试内容',
|
||||
isDynamic: true,
|
||||
expression: 'vars["MOD.val"]',
|
||||
}
|
||||
const result = textComponentConfigSchema.safeParse(config)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('textComponentSchema — 文本组件', () => {
|
||||
it('合法文本组件应通过验证', () => {
|
||||
const data = createValidBase({
|
||||
type: 'text',
|
||||
config: { content: 'Hello' },
|
||||
})
|
||||
expect(textComponentSchema.safeParse(data).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// barComponentSchema — 棒图组件
|
||||
// ============================================
|
||||
|
||||
describe('barComponentSchema — 棒图组件', () => {
|
||||
it('合法棒图组件应通过验证', () => {
|
||||
const data = createValidBase({
|
||||
type: 'bar',
|
||||
config: { min: 0, max: 100, direction: 'vertical' },
|
||||
})
|
||||
expect(barComponentSchema.safeParse(data).success).toBe(true)
|
||||
})
|
||||
|
||||
it('config 可以包含分段颜色', () => {
|
||||
const data = createValidBase({
|
||||
type: 'bar',
|
||||
config: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
colors: [
|
||||
{ threshold: 30, color: '#00FF00' },
|
||||
{ threshold: 70, color: '#FFFF00' },
|
||||
{ threshold: 100, color: '#FF0000' },
|
||||
],
|
||||
},
|
||||
})
|
||||
const result = barComponentSchema.safeParse(data)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('direction 只能是 horizontal 或 vertical', () => {
|
||||
const data = createValidBase({
|
||||
type: 'bar',
|
||||
config: { direction: 'diagonal' },
|
||||
})
|
||||
const result = barComponentSchema.safeParse(data)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// buttonComponentSchema / buttonComponentConfigSchema — 按钮组件
|
||||
// ============================================
|
||||
|
||||
describe('buttonComponentConfigSchema — 按钮组件配置', () => {
|
||||
it('空对象应通过(使用默认值)', () => {
|
||||
expect(buttonComponentConfigSchema.safeParse({}).success).toBe(true)
|
||||
})
|
||||
|
||||
it('默认值:label 应为 "按钮"', () => {
|
||||
const result = buttonComponentConfigSchema.parse({})
|
||||
expect(result.label).toBe('按钮')
|
||||
})
|
||||
|
||||
it('默认值:buttonType 应为 trigger', () => {
|
||||
const result = buttonComponentConfigSchema.parse({})
|
||||
expect(result.buttonType).toBe('trigger')
|
||||
})
|
||||
|
||||
it('默认值:confirmRequired 应为 false', () => {
|
||||
const result = buttonComponentConfigSchema.parse({})
|
||||
expect(result.confirmRequired).toBe(false)
|
||||
})
|
||||
|
||||
it('buttonType 只能是 trigger / toggle / navigate', () => {
|
||||
expect(buttonComponentConfigSchema.safeParse({ buttonType: 'trigger' }).success).toBe(true)
|
||||
expect(buttonComponentConfigSchema.safeParse({ buttonType: 'toggle' }).success).toBe(true)
|
||||
expect(buttonComponentConfigSchema.safeParse({ buttonType: 'navigate' }).success).toBe(true)
|
||||
expect(buttonComponentConfigSchema.safeParse({ buttonType: 'invalid' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buttonComponentSchema — 按钮组件', () => {
|
||||
it('合法按钮组件应通过验证', () => {
|
||||
const data = createValidBase({
|
||||
type: 'button',
|
||||
config: {
|
||||
label: '启动',
|
||||
buttonType: 'trigger',
|
||||
targetMethod: 'startPump',
|
||||
confirmRequired: true,
|
||||
confirmMessage: '确认启动泵?',
|
||||
},
|
||||
})
|
||||
expect(buttonComponentSchema.safeParse(data).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// 完整组件数据验证
|
||||
// ============================================
|
||||
|
||||
describe('完整组件数据验证', () => {
|
||||
it('包含所有可选字段的完整组件应通过验证', () => {
|
||||
const fullComponent = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
type: 'text',
|
||||
name: '温度显示',
|
||||
position: { x: 50, y: 100 },
|
||||
size: { width: 200, height: 40 },
|
||||
zIndex: 5,
|
||||
opacity: 0.8,
|
||||
visible: true,
|
||||
style: {
|
||||
fill: { color: '#FFFFFF', opacity: 0.5 },
|
||||
border: { color: '#333333', width: 1, style: 'solid' as const, radius: 4 },
|
||||
text: { fontSize: 16, color: '#000000', align: 'center' as const },
|
||||
shadow: { color: '#000000', blur: 4, offsetX: 1, offsetY: 2, enabled: true },
|
||||
},
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
trigger: 'click',
|
||||
action: { type: 'callMethod', payload: { method: 'reset' } },
|
||||
},
|
||||
],
|
||||
metadata: { groupId: 'g1', groupName: '温度组' },
|
||||
}
|
||||
|
||||
const result = textComponentSchema.safeParse(fullComponent)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('缺少必填字段 name 时应失败', () => {
|
||||
const data = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
type: 'rect',
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
}
|
||||
expect(componentBaseSchema.safeParse(data).success).toBe(false)
|
||||
})
|
||||
|
||||
it('缺少必填字段 position 时应失败', () => {
|
||||
const data = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
type: 'rect',
|
||||
name: '测试',
|
||||
size: { width: 100, height: 100 },
|
||||
}
|
||||
expect(componentBaseSchema.safeParse(data).success).toBe(false)
|
||||
})
|
||||
|
||||
it('缺少必填字段 size 时应失败', () => {
|
||||
const data = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
type: 'rect',
|
||||
name: '测试',
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
expect(componentBaseSchema.safeParse(data).success).toBe(false)
|
||||
})
|
||||
})
|
||||
43
tests/unit/stores/userStore.test.ts
Normal file
43
tests/unit/stores/userStore.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// Mock @vueuse/core 的 useLocalStorage,用 Vue 的 ref 替代以避免 localStorage 副作用
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useLocalStorage: (_key: string, defaultValue: unknown) => ref(defaultValue),
|
||||
}
|
||||
})
|
||||
|
||||
describe('useUserStore — Token 管理', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('setToken 应正确设置 token 值', () => {
|
||||
const store = useUserStore()
|
||||
store.setToken('abc123')
|
||||
expect(store.token).toBe('abc123')
|
||||
})
|
||||
|
||||
it('setRToken 应正确设置 refreshToken 值', () => {
|
||||
const store = useUserStore()
|
||||
store.setRToken('refresh_xyz')
|
||||
expect(store.rtoken).toBe('refresh_xyz')
|
||||
})
|
||||
|
||||
it('setRTokenTime 应正确设置刷新时间戳', () => {
|
||||
const store = useUserStore()
|
||||
const timestamp = Date.now()
|
||||
store.setRTokenTime(timestamp)
|
||||
expect(store.rtokenTime).toBe(timestamp)
|
||||
})
|
||||
|
||||
it('setDeviceType 应正确设置设备类型', () => {
|
||||
const store = useUserStore()
|
||||
store.setDeviceType('mobile')
|
||||
expect(store.deviceType).toBe('mobile')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user