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,181 @@
import { expect, test } from '@playwright/test'
/**
* DCS 编辑器 - 画布编辑器核心功能测试
*
* 测试画布页面管理、侧边栏标签切换、组件面板等核心交互。
* 每个测试独立运行,通过创建画布进入编辑器。
*/
/** 辅助函数:创建画布并进入编辑器 */
async function enterEditor(page: import('@playwright/test').Page, canvasName = '测试画布') {
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.locator('.add-card').click()
const dialog = page.locator('.el-dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
await dialog.locator('input').first().fill(canvasName)
await dialog.locator('button').filter({ hasText: '创建' }).click()
await expect(page.locator('.dcs-editor')).toBeVisible({ timeout: 10000 })
}
test.describe('画布编辑器核心功能', () => {
test('编辑器加载后显示画布舞台', async ({ page }) => {
await enterEditor(page, '画布加载测试')
// 画布舞台wrapper应该可见
await expect(page.locator('.canvas-wrapper')).toBeVisible()
// 画布内 canvas 元素应存在
await expect(page.locator('.canvas-wrapper canvas')).toBeVisible()
// 标尺应该可见
await expect(page.locator('.ruler-top')).toBeVisible()
await expect(page.locator('.ruler-left')).toBeVisible()
})
test('侧边栏标签 - 默认显示图层面板', async ({ page }) => {
await enterEditor(page, '侧边栏标签测试')
// 默认激活的标签应该是"图层"
const activeTab = page.locator('.sidebar-tab-item.active')
await expect(activeTab).toContainText('图层')
// 图层面板区域应该可见
await expect(page.locator('.layers-tab')).toBeVisible()
})
test('侧边栏标签 - 切换到组件面板', async ({ page }) => {
await enterEditor(page, '组件面板测试')
// 点击"组件"标签
await page.locator('.sidebar-tab-item').filter({ hasText: '组件' }).click()
// 组件标签应变为激活状态
const activeTab = page.locator('.sidebar-tab-item.active')
await expect(activeTab).toContainText('组件')
// 组件面板应该可见,且包含提示文字
await expect(page.locator('.components-tab')).toBeVisible()
await expect(page.locator('.components-hint')).toContainText('拖拽添加到画布')
})
test('侧边栏标签 - 切换到模板面板', async ({ page }) => {
await enterEditor(page, '模板面板测试')
// 点击"模板"标签
await page.locator('.sidebar-tab-item').filter({ hasText: '模板' }).click()
// 模板标签应变为激活状态
const activeTab = page.locator('.sidebar-tab-item.active')
await expect(activeTab).toContainText('模板')
})
test('侧边栏标签 - 可以依次切换回图层面板', async ({ page }) => {
await enterEditor(page, '标签回切测试')
// 先切到组件
await page.locator('.sidebar-tab-item').filter({ hasText: '组件' }).click()
await expect(page.locator('.sidebar-tab-item.active')).toContainText('组件')
// 再切回图层
await page.locator('.sidebar-tab-item').filter({ hasText: '图层' }).click()
await expect(page.locator('.sidebar-tab-item.active')).toContainText('图层')
await expect(page.locator('.layers-tab')).toBeVisible()
})
test('组件面板显示所有可用组件', async ({ page }) => {
await enterEditor(page, '组件列表测试')
// 切换到组件面板
await page.locator('.sidebar-tab-item').filter({ hasText: '组件' }).click()
await expect(page.locator('.components-tab')).toBeVisible()
// 验证组件卡片数量constants.ts 定义了 5 个:矩形、数值、文本、棒图、按钮)
const cards = page.locator('.component-card')
await expect(cards).toHaveCount(5)
// 验证各组件名称
await expect(page.locator('.card-title').filter({ hasText: '矩形' })).toBeVisible()
await expect(page.locator('.card-title').filter({ hasText: '数值' })).toBeVisible()
await expect(page.locator('.card-title').filter({ hasText: '文本' })).toBeVisible()
await expect(page.locator('.card-title').filter({ hasText: '棒图' })).toBeVisible()
await expect(page.locator('.card-title').filter({ hasText: '按钮' })).toBeVisible()
})
test('图层面板显示页面列表和画布页数', async ({ page }) => {
await enterEditor(page, '页面列表测试')
// 图层面板应可见
await expect(page.locator('.layers-tab')).toBeVisible()
// 页面头部应显示页数
await expect(page.locator('.page-header')).toContainText('页数')
// 页面列表应至少有一个活动页面
const activePage = page.locator('.page-row.active')
await expect(activePage).toBeVisible()
})
test('点击画布背景可以取消图层选中', async ({ page }) => {
await enterEditor(page, '取消选中测试')
// 点击画布背景区域
await page.locator('.canvas-wrapper').click({ position: { x: 50, y: 50 } })
// 状态栏不应显示选中信息(没有选中任何图层时不会显示"已选中"
await expect(page.locator('.editor-status-bar .status-right')).not.toContainText('已选中')
})
test('撤销按钮初始状态为禁用', async ({ page }) => {
await enterEditor(page, '撤销状态测试')
// 撤销按钮初始应该是禁用的(没有操作历史)
const undoBtn = page.locator('.editor-header .icon-btn').filter({ has: page.locator('.fa-rotate-left') })
await expect(undoBtn).toBeDisabled()
// 重做按钮初始也应该是禁用的
const redoBtn = page.locator('.editor-header .icon-btn').filter({ has: page.locator('.fa-rotate-right') })
await expect(redoBtn).toBeDisabled()
})
test('头部包含分享和运行按钮', async ({ page }) => {
await enterEditor(page, '头部按钮测试')
// 分享按钮
await expect(page.locator('.share-btn')).toBeVisible()
await expect(page.locator('.share-btn')).toContainText('分享')
// 运行组合按钮
await expect(page.locator('.run-combo-btn')).toBeVisible()
await expect(page.locator('.run-main-btn')).toBeVisible()
})
test('工具栏显示工具按钮', async ({ page }) => {
await enterEditor(page, '工具栏测试')
// 工具栏应可见
await expect(page.locator('.tool-rail')).toBeVisible()
// 工具按钮应存在(至少有移动工具、文本工具、矩形工具 + 底部折叠按钮)
const toolButtons = page.locator('.tool-rail .tool-btn')
const count = await toolButtons.count()
expect(count).toBeGreaterThanOrEqual(3)
})
test('侧边栏可以折叠和展开', async ({ page }) => {
await enterEditor(page, '折叠展开测试')
// 确认侧边栏面板初始可见
await expect(page.locator('.editor-sidebar-panel')).toBeVisible()
// 点击折叠按钮(工具栏底部的按钮,有 fa-angles-left 图标)
const collapseBtn = page.locator('.tool-bottom .tool-btn')
await collapseBtn.click()
// 侧边栏应添加 is-collapsed 类
await expect(page.locator('.editor-sidebar-shell.is-collapsed')).toBeVisible()
// 再次点击展开
await collapseBtn.click()
await expect(page.locator('.editor-sidebar-shell:not(.is-collapsed)')).toBeVisible()
})
})

View File

@@ -0,0 +1,223 @@
import { expect, test } from '@playwright/test'
/**
* DCS 编辑器 - 图层操作测试
*
* 测试图层的添加、选择、删除等操作。
* 部分测试(如拖拽添加组件)因 Playwright 对 HTML5 drag-and-drop 的限制
* 而标记为 skip仅验证可测试的交互路径。
*/
/** 辅助函数:创建画布并进入编辑器 */
async function enterEditor(page: import('@playwright/test').Page, canvasName = '图层测试画布') {
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.locator('.add-card').click()
const dialog = page.locator('.el-dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
await dialog.locator('input').first().fill(canvasName)
await dialog.locator('button').filter({ hasText: '创建' }).click()
await expect(page.locator('.dcs-editor')).toBeVisible({ timeout: 10000 })
}
/** 辅助函数:获取图层面板中的图层行数量 */
async function getLayerRowCount(page: import('@playwright/test').Page): Promise<number> {
return page.locator('.layer-list .layer-row').count()
}
test.describe('图层管理', () => {
test.skip('通过拖拽添加组件到画布', async ({ page }) => {
// 跳过HTML5 drag-and-drop 在 Playwright 中较难模拟,
// 且应用在 Tauri 环境还使用了 pointer 方案替代。
await enterEditor(page, '拖拽添加测试')
// 切换到组件面板
await page.locator('.sidebar-tab-item').filter({ hasText: '组件' }).click()
await expect(page.locator('.components-tab')).toBeVisible()
// 获取矩形组件卡片和画布舞台区域
const rectCard = page.locator('.component-card').first()
const canvas = page.locator('.canvas-wrapper')
// 尝试拖拽(此操作可能不可靠)
await rectCard.dragTo(canvas, {
targetPosition: { x: 300, y: 300 },
})
// 验证图层面板中出现新图层
await page.locator('.sidebar-tab-item').filter({ hasText: '图层' }).click()
const layerCount = await getLayerRowCount(page)
expect(layerCount).toBeGreaterThan(0)
})
test('初始状态下图层面板为空', async ({ page }) => {
await enterEditor(page, '空图层面板测试')
// 确认图层面板可见
await expect(page.locator('.layers-tab')).toBeVisible()
// 新画布应该没有图层
const layerCount = await getLayerRowCount(page)
expect(layerCount).toBe(0)
})
test('属性面板初始显示空状态或底图信息', async ({ page }) => {
await enterEditor(page, '属性面板初始测试')
// 属性面板头部应该可见且显示"属性"标题
const propertyTitle = page.locator('.property-panel .title')
await expect(propertyTitle).toContainText('属性')
// 没有选中图层时,应显示提示文字
const summaryOrEmpty = page.locator('.property-panel .selection-summary, .property-panel .property-empty')
await expect(summaryOrEmpty.first()).toBeVisible()
})
test('属性面板有停靠/浮动切换按钮', async ({ page }) => {
await enterEditor(page, '停靠切换测试')
// 停靠/浮动切换按钮应存在
const dockBtn = page.locator('.dock-toggle-btn')
await expect(dockBtn).toBeVisible()
// 点击切换到浮动模式
await dockBtn.click()
await expect(page.locator('.property-panel-shell--floating')).toBeVisible()
// 再次点击切换回停靠模式
await page.locator('.dock-toggle-btn').click()
await expect(page.locator('.property-panel-shell:not(.property-panel-shell--floating)')).toBeVisible()
})
test('图层面板显示页面列表', async ({ page }) => {
await enterEditor(page, '页面列表验证测试')
// 页面树区域应显示至少一个页面
const pageRows = page.locator('.page-tree .page-row')
await expect(pageRows.first()).toBeVisible()
// 第一个页面应处于激活状态
await expect(page.locator('.page-row.active')).toBeVisible()
})
test('图层面板有添加页面按钮', async ({ page }) => {
await enterEditor(page, '添加页面按钮测试')
// 页面头部的添加按钮应存在fa-plus 图标)
const addPageBtn = page.locator('.page-header').locator('button').filter({
has: page.locator('.fa-plus'),
})
await expect(addPageBtn).toBeVisible()
})
test('图层面板有搜索框', async ({ page }) => {
await enterEditor(page, '图层搜索测试')
// 搜索行应包含输入框
const searchInput = page.locator('.layer-search-row input')
await expect(searchInput).toBeVisible()
})
test('状态栏无选中时不显示选中计数', async ({ page }) => {
await enterEditor(page, '状态栏选中计数测试')
// 没有选中图层时,状态栏右侧不应显示"已选中"
await expect(page.locator('.editor-status-bar .status-right')).not.toContainText('已选中')
})
test('缩放信息显示在状态栏中', async ({ page }) => {
await enterEditor(page, '状态栏缩放测试')
// 状态栏应显示缩放百分比(如"100%"
const statusItems = page.locator('.editor-status-bar .status-item')
let foundZoom = false
const count = await statusItems.count()
for (let i = 0; i < count; i++) {
const text = await statusItems.nth(i).textContent()
if (text && text.includes('%')) {
foundZoom = true
break
}
}
expect(foundZoom).toBe(true)
})
test('画布舞台有标尺', async ({ page }) => {
await enterEditor(page, '标尺测试')
// 水平标尺
await expect(page.locator('.ruler-top')).toBeVisible()
// 垂直标尺
await expect(page.locator('.ruler-left')).toBeVisible()
// 标尺角落
await expect(page.locator('.ruler-corner')).toBeVisible()
})
test.skip('选中图层后属性面板显示属性', async ({ page }) => {
// 跳过:需要先有图层才能选中,添加图层依赖拖拽操作
await enterEditor(page, '选中属性测试')
// 预期:选中图层后,属性面板标题下方应显示图层信息
const propertyBody = page.locator('.property-panel .property-body')
await expect(propertyBody).toBeVisible()
})
test.skip('可以删除选中的图层', async ({ page }) => {
// 跳过:需要先有可选中的图层(添加图层依赖拖拽操作)
await enterEditor(page, '删除图层测试')
// 预期:选中图层后按 Delete/Backspace 可删除
// 或通过右键菜单删除
})
test.skip('删除图层后可撤销', async ({ page }) => {
// 跳过:依赖先添加并删除图层
await enterEditor(page, '撤销删除测试')
// 预期:删除图层后,撤销按钮变为可用,点击撤销可恢复图层
})
test('图层面板页面列表可以折叠和展开', async ({ page }) => {
await enterEditor(page, '页面折叠测试')
// 页面树应该初始可见
await expect(page.locator('.page-tree')).toBeVisible()
// 点击折叠/展开按钮page-header 中的角标按钮)
const toggleBtn = page.locator('.page-header .header-actions button').filter({
has: page.locator('.fa-angle-up, .fa-angle-down'),
})
await toggleBtn.click()
// 页面树应该被隐藏v-show 控制,元素仍在 DOM 中但不可见)
await expect(page.locator('.page-tree')).toBeHidden()
// 再次点击展开
await toggleBtn.click()
await expect(page.locator('.page-tree')).toBeVisible()
})
test('画布舞台 overlay 层存在', async ({ page }) => {
await enterEditor(page, 'Overlay 测试')
// overlay 是放置图层选择框等交互元素的层
await expect(page.locator('.canvas-wrapper .overlay')).toBeAttached()
})
test('组件面板中的组件卡片显示描述信息', async ({ page }) => {
await enterEditor(page, '组件描述测试')
// 切换到组件面板
await page.locator('.sidebar-tab-item').filter({ hasText: '组件' }).click()
await expect(page.locator('.components-tab')).toBeVisible()
// 每个组件卡片应该有标题和描述
const firstCard = page.locator('.component-card').first()
await expect(firstCard.locator('.card-title')).toBeVisible()
await expect(firstCard.locator('.card-desc')).toBeVisible()
// 验证具体描述内容(矩形:基础矩形图层)
const rectDesc = page.locator('.component-card').filter({ hasText: '矩形' }).locator('.card-desc')
await expect(rectDesc).toContainText('基础矩形图层')
})
})

View File

@@ -0,0 +1,138 @@
import { expect, test } from '@playwright/test'
/**
* DCS 编辑器 - 基础导航测试
*
* 应用使用 Hash 路由createWebHashHistory('/dcs-web')
* 首次访问 Web 端默认跳转到 /canvases 画布列表页。
*/
test.describe('基础导航', () => {
test('应用加载无错误', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => {
errors.push(err.message)
})
await page.goto('/')
// 等待应用框架渲染完毕(路由守卫会重定向到 canvases 或 editor
await page.waitForLoadState('networkidle')
// 不应产生未捕获的 JS 异常
expect(errors).toEqual([])
})
test('可以导航到画布列表页', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// Web 模式下默认重定向到画布列表
await expect(page.locator('.canvases-page')).toBeVisible({ timeout: 10000 })
// 页面标题区域应包含 "DCS图"
await expect(page.locator('.page-title')).toContainText('DCS图')
// 应有"添加DCS图"卡片
await expect(page.locator('.add-card')).toBeVisible()
})
test('可以导航到编辑器页面', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// 通过"添加DCS图"创建一个画布并进入编辑器
await page.locator('.add-card').click()
// 等待创建对话框出现
const dialog = page.locator('.el-dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
// 填写画布名称
await dialog.locator('input').first().fill('测试画布')
// 点击创建按钮
await dialog.locator('button').filter({ hasText: '创建' }).click()
// 等待跳转到编辑器页面
await expect(page.locator('.dcs-editor')).toBeVisible({ timeout: 10000 })
})
test('编辑器包含侧边栏、画布舞台和属性面板', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// 先创建画布进入编辑器
await page.locator('.add-card').click()
const dialog = page.locator('.el-dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
await dialog.locator('input').first().fill('结构测试画布')
await dialog.locator('button').filter({ hasText: '创建' }).click()
await expect(page.locator('.dcs-editor')).toBeVisible({ timeout: 10000 })
// 验证编辑器三大区域
// 1. 侧边栏(工具栏 + 面板)
await expect(page.locator('.editor-sidebar-shell')).toBeVisible()
await expect(page.locator('.tool-rail')).toBeVisible()
// 2. 画布舞台
await expect(page.locator('.canvas-wrapper')).toBeVisible()
// 3. 属性面板
await expect(page.locator('.property-panel-shell')).toBeVisible()
})
test('编辑器头部可见且包含撤销重做按钮', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// 创建画布进入编辑器
await page.locator('.add-card').click()
const dialog = page.locator('.el-dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
await dialog.locator('input').first().fill('头部测试画布')
await dialog.locator('button').filter({ hasText: '创建' }).click()
await expect(page.locator('.dcs-editor')).toBeVisible({ timeout: 10000 })
// 编辑器头部
await expect(page.locator('.editor-header')).toBeVisible()
// 撤销按钮fa-rotate-left 图标)
const undoBtn = page.locator('.editor-header .icon-btn').filter({ has: page.locator('.fa-rotate-left') })
await expect(undoBtn).toBeVisible()
// 重做按钮fa-rotate-right 图标)
const redoBtn = page.locator('.editor-header .icon-btn').filter({ has: page.locator('.fa-rotate-right') })
await expect(redoBtn).toBeVisible()
})
test('状态栏在编辑器中可见', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// 创建画布进入编辑器
await page.locator('.add-card').click()
const dialog = page.locator('.el-dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
await dialog.locator('input').first().fill('状态栏测试画布')
await dialog.locator('button').filter({ hasText: '创建' }).click()
await expect(page.locator('.dcs-editor')).toBeVisible({ timeout: 10000 })
// 状态栏
await expect(page.locator('.editor-status-bar')).toBeVisible()
// 状态栏应显示缩放百分比
await expect(page.locator('.editor-status-bar .status-item')).toContainText(['%'])
})
test('可以通过头部 "DCS编辑器" 按钮返回画布列表', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// 先进入编辑器
await page.locator('.add-card').click()
const dialog = page.locator('.el-dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
await dialog.locator('input').first().fill('返回测试画布')
await dialog.locator('button').filter({ hasText: '创建' }).click()
await expect(page.locator('.dcs-editor')).toBeVisible({ timeout: 10000 })
// 点击 "DCS编辑器" 返回画布列表
await page.locator('.scope-btn').click()
await expect(page.locator('.canvases-page')).toBeVisible({ timeout: 10000 })
})
})

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

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

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

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