all
This commit is contained in:
181
tests/e2e/canvas-editor.spec.ts
Normal file
181
tests/e2e/canvas-editor.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
223
tests/e2e/layer-operations.spec.ts
Normal file
223
tests/e2e/layer-operations.spec.ts
Normal 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('基础矩形图层')
|
||||
})
|
||||
})
|
||||
138
tests/e2e/navigation.spec.ts
Normal file
138
tests/e2e/navigation.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
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