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,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)
})
})
// ──── templatessortedTemplates────
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)
})
})
})

View 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)
})
})
})

View 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 应为 undefinederror 不传 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)
})
})
})