20260914223013
This commit is contained in:
1 parent
549c8bd2eb
commit
63514476d7
60 files changed
+6478
-2004
No files matched your search
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Modal.confirm 命令式确认对话框:开关时机与 onOk / onCancel 语义。
|
||||
* 覆盖「确定后关闭」「onOk reject 保持打开」「取消」「点遮罩静默关闭(不触发 onCancel)」
|
||||
* 「destroy 立即卸载」几种路径。
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { confirm } from '@/components/ui/modal/modal-confirm.js'
|
||||
|
||||
/** Modal 用 Teleport 挂到 body,只能从 document 上找面板 */
|
||||
const panel = () => document.querySelector('.fms-modal')
|
||||
/** footer 按钮顺序:[取消, 确定] */
|
||||
const footerButtons = () => [...document.querySelectorAll('.fms-modal-footer button')]
|
||||
|
||||
/**
|
||||
* 关窗要等 Transition 的两帧 rAF 与 after-leave,时序不确定:
|
||||
* 轮询到条件成立或超时为止。
|
||||
*/
|
||||
async function waitFor(predicate, timeout = 500) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
if (predicate()) return true
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let active = null
|
||||
|
||||
afterEach(async () => {
|
||||
active?.destroy()
|
||||
active = null
|
||||
await waitFor(() => !panel())
|
||||
})
|
||||
|
||||
describe('Modal.confirm', () => {
|
||||
it('确定:onOk resolve 后对话框关闭', async () => {
|
||||
const onOk = vi.fn()
|
||||
active = confirm({ title: '确认', content: '删除后不可恢复', onOk })
|
||||
|
||||
expect(panel()).toBeTruthy()
|
||||
expect(document.body.textContent).toContain('删除后不可恢复')
|
||||
|
||||
footerButtons().at(-1).click()
|
||||
|
||||
expect(await waitFor(() => !panel())).toBe(true)
|
||||
expect(onOk).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('确定:onOk reject 时保持打开,等待重试或取消', async () => {
|
||||
const onOk = vi.fn(() => Promise.reject(new Error('删除失败')))
|
||||
active = confirm({ title: '确认', content: '内容', onOk })
|
||||
|
||||
footerButtons().at(-1).click()
|
||||
// 等一段确定的时间,确认对话框没有进入关闭流程
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await flushPromises()
|
||||
|
||||
expect(onOk).toHaveBeenCalledTimes(1)
|
||||
expect(panel()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('取消:onCancel 被调用并关闭', async () => {
|
||||
const onCancel = vi.fn()
|
||||
active = confirm({ title: '确认', content: '内容', onCancel })
|
||||
|
||||
footerButtons()[0].click()
|
||||
|
||||
expect(await waitFor(() => !panel())).toBe(true)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('点遮罩空白:关闭对话框但不触发 onCancel(静默关闭)', async () => {
|
||||
const onCancel = vi.fn()
|
||||
active = confirm({ title: '确认', content: '内容', onCancel })
|
||||
|
||||
const wrap = document.querySelector('.fms-modal-wrap')
|
||||
expect(wrap).toBeTruthy()
|
||||
// 点击 wrap 自身(面板外空白区域):target === currentTarget 才命中遮罩关闭分支。
|
||||
// 遮罩关闭走 Modal.requestClose → emit('cancel'),但 confirm 未绑 @cancel,
|
||||
// 故只由 v-model:open 关窗,不应触发调用方 onCancel。
|
||||
wrap.click()
|
||||
|
||||
expect(await waitFor(() => !panel())).toBe(true)
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('okButtonProps.danger 映射为危险态确定按钮', () => {
|
||||
active = confirm({ title: '确认', content: '内容', okButtonProps: { danger: true } })
|
||||
|
||||
expect(footerButtons().at(-1).className).toContain('button-danger')
|
||||
})
|
||||
|
||||
it('destroy 立即卸载容器与遮罩', async () => {
|
||||
active = confirm({ title: '确认', content: '内容' })
|
||||
expect(panel()).toBeTruthy()
|
||||
|
||||
active.destroy()
|
||||
active = null
|
||||
await waitFor(() => !panel())
|
||||
|
||||
expect(panel()).toBeFalsy()
|
||||
expect(document.querySelector('.fms-modal-wrap')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Space 间距组件:容器方向 / 间距 gap / 对齐 / 换行 / 分隔符 / 空子节点。
|
||||
* 只断言渲染出的 class、内联 gap 与包裹结构,不涉及真实布局尺寸。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Space from '@/components/ui/space/space.vue'
|
||||
|
||||
/** 便捷挂载:默认塞两个子元素 */
|
||||
function mountSpace(props = {}, slots = {}) {
|
||||
return mount(Space, {
|
||||
props,
|
||||
slots: { default: '<span>A</span><span>B</span>', ...slots },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Space 组件', () => {
|
||||
it('默认水平:容器带方向类,每个子元素包一层 .space-item', () => {
|
||||
const wrapper = mountSpace()
|
||||
expect(wrapper.classes()).toContain('space')
|
||||
expect(wrapper.classes()).toContain('space-horizontal')
|
||||
expect(wrapper.findAll('.space-item')).toHaveLength(2)
|
||||
expect(wrapper.attributes('data-slot')).toBe('space')
|
||||
})
|
||||
|
||||
it('水平默认对齐 center;显式 align 覆盖', () => {
|
||||
expect(mountSpace().classes()).toContain('space-align-center')
|
||||
expect(mountSpace({ align: 'start' }).classes()).toContain('space-align-start')
|
||||
expect(mountSpace({ align: 'baseline' }).classes()).toContain('space-align-baseline')
|
||||
})
|
||||
|
||||
it('size 预设映射为对应 px 的 gap', () => {
|
||||
const style = mountSpace({ size: 'middle' }).attributes('style')
|
||||
expect(style).toContain('column-gap: 16px')
|
||||
expect(style).toContain('row-gap: 16px')
|
||||
})
|
||||
|
||||
it('size 数字按 px 处理', () => {
|
||||
const style = mountSpace({ size: 20 }).attributes('style')
|
||||
expect(style).toContain('column-gap: 20px')
|
||||
expect(style).toContain('row-gap: 20px')
|
||||
})
|
||||
|
||||
it('size 数组 [水平, 垂直] 分别设 column-gap / row-gap', () => {
|
||||
const style = mountSpace({ size: [12, 24] }).attributes('style')
|
||||
expect(style).toContain('column-gap: 12px')
|
||||
expect(style).toContain('row-gap: 24px')
|
||||
})
|
||||
|
||||
it('vertical 简写切换为纵向且不设默认对齐', () => {
|
||||
const wrapper = mountSpace({ vertical: true })
|
||||
expect(wrapper.classes()).toContain('space-vertical')
|
||||
expect(wrapper.classes()).not.toContain('space-align-center')
|
||||
})
|
||||
|
||||
it('orientation 优先于 vertical', () => {
|
||||
const wrapper = mountSpace({ orientation: 'horizontal', vertical: true })
|
||||
expect(wrapper.classes()).toContain('space-horizontal')
|
||||
expect(wrapper.classes()).not.toContain('space-vertical')
|
||||
})
|
||||
|
||||
it('wrap 仅水平方向生效', () => {
|
||||
expect(mountSpace({ wrap: true }).classes()).toContain('space-wrap')
|
||||
expect(mountSpace({ wrap: true, vertical: true }).classes()).not.toContain('space-wrap')
|
||||
})
|
||||
|
||||
it('separator 插槽:分隔符数量 = 子元素数 - 1,末尾不加', () => {
|
||||
const wrapper = mountSpace(
|
||||
{},
|
||||
{ default: '<span>A</span><span>B</span><span>C</span>', separator: '|' },
|
||||
)
|
||||
expect(wrapper.findAll('.space-item')).toHaveLength(3)
|
||||
expect(wrapper.findAll('.space-separator')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('|')
|
||||
})
|
||||
|
||||
it('无 separator 插槽时不渲染分隔符', () => {
|
||||
expect(mountSpace().findAll('.space-separator')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('无有效子元素时不渲染容器', () => {
|
||||
const wrapper = mount(Space, { slots: { default: '' } })
|
||||
expect(wrapper.find('.space').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('过滤子元素之间的空白文本节点', () => {
|
||||
const wrapper = mount(Space, {
|
||||
slots: { default: '<span>A</span>\n <span>B</span>\n' },
|
||||
})
|
||||
expect(wrapper.findAll('.space-item')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,12 @@ import FmsQueryControl from '@/components/fms-module-list/FmsQueryControl.vue'
|
||||
import FmsQueryToolbar from '@/components/fms-module-list/FmsQueryToolbar.vue'
|
||||
import FmsAdvancedQueryPanel from '@/components/fms-module-list/FmsAdvancedQueryPanel.vue'
|
||||
|
||||
/** 构造一个查询条件项(字段 + 配置),供工具栏布局用例复用 */
|
||||
const queryItem = (field) => ({
|
||||
field: { b_field: field, b_type: 'input' },
|
||||
config: { b_operator: 'like', b_component: 'input' },
|
||||
})
|
||||
|
||||
describe('fms-module-list components', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
@@ -27,6 +33,33 @@ describe('fms-module-list components', () => {
|
||||
expect(wrapper.html()).not.toContain('<a-')
|
||||
})
|
||||
|
||||
it('查询条件网格列数按条件数收缩(只有一个条件时不占 4 列宽)', () => {
|
||||
const one = mount(FmsQueryToolbar, {
|
||||
props: {
|
||||
queryItems: [queryItem('b_name')],
|
||||
queryLayout: { area: { b_name: 'quick' }, order: { b_name: 10 } },
|
||||
},
|
||||
})
|
||||
expect(one.find('.query-grid').element.style.gridTemplateColumns).toBe(
|
||||
'repeat(1, minmax(0, 200px))',
|
||||
)
|
||||
one.unmount()
|
||||
|
||||
const four = mount(FmsQueryToolbar, {
|
||||
props: {
|
||||
queryItems: [queryItem('a'), queryItem('b'), queryItem('c'), queryItem('d')],
|
||||
queryLayout: {
|
||||
area: { a: 'quick', b: 'quick', c: 'quick', d: 'quick' },
|
||||
order: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(four.find('.query-grid').element.style.gridTemplateColumns).toBe(
|
||||
'repeat(4, minmax(0, 200px))',
|
||||
)
|
||||
four.unmount()
|
||||
})
|
||||
|
||||
it('高级查询行间连接符表达行与行的连接:≥2 项才出现,点击切换后一项的 join', async () => {
|
||||
const state = {
|
||||
nodes: [
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
/**
|
||||
* 往来单位分类树页面级纯函数(views/base/othercompany/utils.js)。
|
||||
* 往来单位分类树页面级纯函数。
|
||||
*
|
||||
* 页面单文件自包含后(分类树逻辑内联在 views/base/othercompany/index.vue),
|
||||
* 纯函数不再单独导出,测试通过挂载页面组件访问 <script setup> 的内部绑定。
|
||||
*
|
||||
* 重点是 buildCategoryTree 不能污染原始行:buildTree 会就地往行对象上挂 children,
|
||||
* 若直接在 categories 的行上构建,重复构建会累积出重复子节点。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ALL_CATEGORY_KEY,
|
||||
addRowUpdate,
|
||||
applyCategoryPosition,
|
||||
buildCategoryTree,
|
||||
findNodePosition,
|
||||
hasChildCategory,
|
||||
nextSiblingXh,
|
||||
rebuildTreePositions,
|
||||
renumberSiblings,
|
||||
} from '@/views/base/othercompany/utils'
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import Othercompany from '@/views/base/othercompany/index.vue'
|
||||
import { mountPage } from '../helpers/app.js'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
|
||||
pageDataApi: vi.fn(() =>
|
||||
Promise.resolve({ data: { rows: [], total: 0 }, code: 0, success: true }),
|
||||
),
|
||||
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
|
||||
}))
|
||||
|
||||
function category(over = {}) {
|
||||
return {
|
||||
@@ -30,6 +33,15 @@ function category(over = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面实例(分类树纯函数与状态都在它的 setup 里) */
|
||||
let vm
|
||||
|
||||
beforeEach(async () => {
|
||||
const { wrapper } = await mountPage(Othercompany)
|
||||
await flushPromises()
|
||||
vm = wrapper.vm
|
||||
})
|
||||
|
||||
describe('othercompany 分类树工具:buildCategoryTree', () => {
|
||||
it('按 b_parent_id 建树并按 b_xh 排序', () => {
|
||||
const rows = [
|
||||
@@ -38,7 +50,7 @@ describe('othercompany 分类树工具:buildCategoryTree', () => {
|
||||
category({ b_id: 'a1', b_parent_id: 'a', b_depth: 1, b_path: '/a/a1/', b_name: 'A1' }),
|
||||
]
|
||||
|
||||
const tree = buildCategoryTree(rows)
|
||||
const tree = vm.buildCategoryTree(rows)
|
||||
|
||||
expect(tree.map((node) => node.b_id)).toEqual(['a', 'b'])
|
||||
expect(tree[0].children.map((node) => node.b_id)).toEqual(['a1'])
|
||||
@@ -50,11 +62,11 @@ describe('othercompany 分类树工具:buildCategoryTree', () => {
|
||||
category({ b_id: 'a1', b_parent_id: 'a', b_depth: 1, b_path: '/a/a1/' }),
|
||||
]
|
||||
|
||||
buildCategoryTree(rows)
|
||||
vm.buildCategoryTree(rows)
|
||||
// buildTree 的 children 挂在浅拷贝上,原始行保持干净
|
||||
expect(rows[0].children).toBeUndefined()
|
||||
|
||||
const tree = buildCategoryTree(rows)
|
||||
const tree = vm.buildCategoryTree(rows)
|
||||
expect(tree[0].children).toHaveLength(1)
|
||||
expect(tree[0].children[0].b_id).toBe('a1')
|
||||
})
|
||||
@@ -63,13 +75,13 @@ describe('othercompany 分类树工具:buildCategoryTree', () => {
|
||||
describe('othercompany 分类树工具:位置派生', () => {
|
||||
it('根节点的 b_depth 为 0、b_path 为 /编码/', () => {
|
||||
const row = { b_id: 'a' }
|
||||
applyCategoryPosition(row, null)
|
||||
vm.applyCategoryPosition(row, null)
|
||||
expect(row).toMatchObject({ b_depth: 0, b_path: '/a/' })
|
||||
})
|
||||
|
||||
it('子节点继承父级路径并递增层级', () => {
|
||||
const row = { b_id: 'a1' }
|
||||
applyCategoryPosition(row, { b_depth: 2, b_path: '/a/b/' })
|
||||
vm.applyCategoryPosition(row, { b_depth: 2, b_path: '/a/b/' })
|
||||
expect(row).toMatchObject({ b_depth: 3, b_path: '/a/b/a1/' })
|
||||
})
|
||||
})
|
||||
@@ -82,10 +94,10 @@ describe('othercompany 分类树工具:排序号与子分类判定', () => {
|
||||
category({ b_id: 'a1', b_parent_id: 'a', b_xh: 90 }),
|
||||
]
|
||||
|
||||
expect(nextSiblingXh(rows, null)).toBe(40)
|
||||
expect(nextSiblingXh(rows, 'a')).toBe(100)
|
||||
expect(nextSiblingXh(rows, 'not-exist')).toBe(10)
|
||||
expect(nextSiblingXh([], null)).toBe(10)
|
||||
expect(vm.nextSiblingXh(rows, null)).toBe(40)
|
||||
expect(vm.nextSiblingXh(rows, 'a')).toBe(100)
|
||||
expect(vm.nextSiblingXh(rows, 'not-exist')).toBe(10)
|
||||
expect(vm.nextSiblingXh([], null)).toBe(10)
|
||||
})
|
||||
|
||||
it('hasChildCategory 只看直接子分类', () => {
|
||||
@@ -95,9 +107,9 @@ describe('othercompany 分类树工具:排序号与子分类判定', () => {
|
||||
category({ b_id: 'a1x', b_parent_id: 'a1' }),
|
||||
]
|
||||
|
||||
expect(hasChildCategory(rows, 'a')).toBe(true)
|
||||
expect(hasChildCategory(rows, 'a1')).toBe(true)
|
||||
expect(hasChildCategory(rows, 'a1x')).toBe(false)
|
||||
expect(vm.hasChildCategory(rows, 'a')).toBe(true)
|
||||
expect(vm.hasChildCategory(rows, 'a1')).toBe(true)
|
||||
expect(vm.hasChildCategory(rows, 'a1x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,7 +119,7 @@ describe('othercompany 分类树工具:重排与重建位置', () => {
|
||||
const b = { b_id: 'b', b_xh: 10 }
|
||||
const updated = []
|
||||
|
||||
renumberSiblings([a, b], updated)
|
||||
vm.renumberSiblings([a, b], updated)
|
||||
|
||||
expect([a.b_xh, b.b_xh]).toEqual([10, 20])
|
||||
expect(updated).toEqual([
|
||||
@@ -127,7 +139,7 @@ describe('othercompany 分类树工具:重排与重建位置', () => {
|
||||
]
|
||||
const updated = []
|
||||
|
||||
rebuildTreePositions(tree, updated)
|
||||
vm.rebuildTreePositions(tree, updated)
|
||||
|
||||
expect(tree[0].children[0]).toMatchObject({ b_depth: 1, b_path: '/a/a1/' })
|
||||
// 根节点本来就是 0 / '/a/',不该产生一条无意义的 update
|
||||
@@ -136,9 +148,9 @@ describe('othercompany 分类树工具:重排与重建位置', () => {
|
||||
|
||||
it('addRowUpdate 按 b_id 合并字段,避免同节点多条 update', () => {
|
||||
const updated = []
|
||||
addRowUpdate(updated, { b_id: 'a', b_xh: 20 })
|
||||
addRowUpdate(updated, { b_id: 'a', b_parent_id: 'p' })
|
||||
addRowUpdate(updated, { b_id: 'b', b_xh: 10 })
|
||||
vm.addRowUpdate(updated, { b_id: 'a', b_xh: 20 })
|
||||
vm.addRowUpdate(updated, { b_id: 'a', b_parent_id: 'p' })
|
||||
vm.addRowUpdate(updated, { b_id: 'b', b_xh: 10 })
|
||||
|
||||
expect(updated).toEqual([
|
||||
{ b_id: 'a', b_xh: 20, b_parent_id: 'p' },
|
||||
@@ -149,16 +161,16 @@ describe('othercompany 分类树工具:重排与重建位置', () => {
|
||||
it('findNodePosition 返回所在父数组与索引', () => {
|
||||
const tree = [{ b_id: 'a', children: [{ b_id: 'a1' }, { b_id: 'a2' }] }]
|
||||
|
||||
expect(findNodePosition(tree, 'a')).toMatchObject({ index: 0 })
|
||||
const found = findNodePosition(tree, 'a2')
|
||||
expect(vm.findNodePosition(tree, 'a')).toMatchObject({ index: 0 })
|
||||
const found = vm.findNodePosition(tree, 'a2')
|
||||
expect(found.index).toBe(1)
|
||||
expect(found.list.map((node) => node.b_id)).toEqual(['a1', 'a2'])
|
||||
expect(findNodePosition(tree, 'not-exist')).toBe(null)
|
||||
expect(vm.findNodePosition(tree, 'not-exist')).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('othercompany 分类树工具:虚拟根键', () => {
|
||||
it('与 FmsTree 的 rootKey 约定保持一致', () => {
|
||||
expect(ALL_CATEGORY_KEY).toBe('__fms_tree_root__')
|
||||
expect(vm.ALL_CATEGORY_KEY).toBe('__fms_tree_root__')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 回归用例:菜单管理 - 绑定模块(s_menu_module)的加载与保存。
|
||||
*
|
||||
* s_menu 表没有模块列:绑定关系存 s_menu_module,行身份 = (b_menu_id, b_module_id),
|
||||
* 只登记 data / virtual 模块(module 只是模块树上的分类,不参与权限)。
|
||||
* 保存时与加载基线做行级 diff,只提交增删行;目录 / 外链菜单不绑定模块。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import MenuManagement from '@/views/module/menu-management/index.vue'
|
||||
import { mountPage } from '../helpers/app.js'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(),
|
||||
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
|
||||
nextIdApi: vi.fn(() => Promise.resolve({ data: 9001, code: 0, success: true })),
|
||||
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/message/message-manager.js', () => {
|
||||
const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
|
||||
return { Message: m, default: m }
|
||||
})
|
||||
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
|
||||
const directoryMenu = {
|
||||
b_id: 'base',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/base/',
|
||||
b_name: '基础资料',
|
||||
b_i18n: 'menu.base',
|
||||
b_menu_type: 'directory',
|
||||
b_route: null,
|
||||
b_icon: null,
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
}
|
||||
|
||||
const pageMenu = {
|
||||
b_id: 'otherdata',
|
||||
b_parent_id: 'base',
|
||||
b_depth: 1,
|
||||
b_path: '/base/otherdata/',
|
||||
b_name: '其他数据',
|
||||
b_i18n: 'menu.otherdata',
|
||||
b_menu_type: 'page',
|
||||
b_route: '/base/otherdata',
|
||||
b_icon: null,
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
}
|
||||
|
||||
/** 模块清单:base / base_otherdata 为分类节点,b_port / b_money 为数据模块 */
|
||||
const modules = [
|
||||
{
|
||||
b_id: 'base',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/base/',
|
||||
b_module_type: 'module',
|
||||
b_name: '基础资料',
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
},
|
||||
{
|
||||
b_id: 'base_otherdata',
|
||||
b_parent_id: 'base',
|
||||
b_depth: 1,
|
||||
b_path: '/base/base_otherdata/',
|
||||
b_module_type: 'module',
|
||||
b_name: '其他数据',
|
||||
b_canuse: 1,
|
||||
b_xh: 30,
|
||||
},
|
||||
{
|
||||
b_id: 'b_port',
|
||||
b_parent_id: 'base_otherdata',
|
||||
b_depth: 2,
|
||||
b_path: '/base/base_otherdata/b_port/',
|
||||
b_module_type: 'data',
|
||||
b_name: '港口',
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
},
|
||||
{
|
||||
b_id: 'b_money',
|
||||
b_parent_id: 'base_otherdata',
|
||||
b_depth: 2,
|
||||
b_path: '/base/base_otherdata/b_money/',
|
||||
b_module_type: 'data',
|
||||
b_name: '币种',
|
||||
b_canuse: 1,
|
||||
b_xh: 20,
|
||||
},
|
||||
]
|
||||
|
||||
/** 库中 s_menu_module 的现有行(每个用例前重置) */
|
||||
let boundRows = []
|
||||
|
||||
/** 从查询条件里取 N'xxx' 字面量(mock 按 id 过滤用;'1 = 1' 等条件返回 null) */
|
||||
function literalOf(condition) {
|
||||
return /N'([^']*)'/.exec(String(condition || ''))?.[1] ?? null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
boundRows = [{ b_menu_id: 'otherdata', b_module_id: 'b_port', b_xh: 10, b_canuse: 1 }]
|
||||
loadDataApi.mockReset()
|
||||
saveObjectApi.mockClear()
|
||||
loadDataApi.mockImplementation((view, condition) => {
|
||||
if (view === 's_menu') {
|
||||
const id = literalOf(condition)
|
||||
const rows = [directoryMenu, pageMenu].filter((row) => !id || row.b_id === id)
|
||||
return Promise.resolve({ data: rows })
|
||||
}
|
||||
if (view === 's_module') return Promise.resolve({ data: modules })
|
||||
if (view === 's_menu_module') {
|
||||
const id = literalOf(condition)
|
||||
return Promise.resolve({
|
||||
data: boundRows.filter((row) => !id || row.b_menu_id === id),
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
})
|
||||
|
||||
/** 取出最近一次保存请求里的指定表请求 */
|
||||
function lastRequestOf(table) {
|
||||
const requests = saveObjectApi.mock.calls.at(-1)[0]
|
||||
return requests.find((request) => request.table === table)
|
||||
}
|
||||
|
||||
describe('菜单管理:绑定模块(s_menu_module)', () => {
|
||||
it('选中菜单时按 b_menu_id 加载绑定模块', async () => {
|
||||
const { wrapper } = await mountPage(MenuManagement)
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.menuSelectedId = 'otherdata'
|
||||
await flushPromises()
|
||||
|
||||
expect(loadDataApi).toHaveBeenCalledWith(
|
||||
's_menu_module',
|
||||
"b_menu_id = N'otherdata'",
|
||||
'b_xh ASC, b_module_id ASC',
|
||||
)
|
||||
expect(wrapper.vm.menuModules).toEqual(['b_port'])
|
||||
})
|
||||
|
||||
it('新增绑定:只提交 s_menu_module 的 insert 行', async () => {
|
||||
const { wrapper } = await mountPage(MenuManagement)
|
||||
await flushPromises()
|
||||
wrapper.vm.menuSelectedId = 'otherdata'
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.menuModules = ['b_port', 'b_money']
|
||||
await wrapper.vm.handleSave()
|
||||
|
||||
const request = lastRequestOf('s_menu_module')
|
||||
expect(request.key_field).toBe('b_menu_id,b_module_id')
|
||||
expect(request.inserts).toEqual([
|
||||
{ b_menu_id: 'otherdata', b_module_id: 'b_money', b_xh: 20, b_canuse: 1 },
|
||||
])
|
||||
expect(request.deletes).toBeUndefined()
|
||||
})
|
||||
|
||||
it('移除绑定:只提交 s_menu_module 的 delete 主键行', async () => {
|
||||
const { wrapper } = await mountPage(MenuManagement)
|
||||
await flushPromises()
|
||||
wrapper.vm.menuSelectedId = 'otherdata'
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.menuModules = []
|
||||
await wrapper.vm.handleSave()
|
||||
|
||||
const request = lastRequestOf('s_menu_module')
|
||||
expect(request.deletes).toEqual([{ b_menu_id: 'otherdata', b_module_id: 'b_port' }])
|
||||
expect(request.inserts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('目录菜单不绑定模块:保存时清空并删除既有关系行', async () => {
|
||||
boundRows = [{ b_menu_id: 'base', b_module_id: 'b_port', b_xh: 10, b_canuse: 1 }]
|
||||
const { wrapper } = await mountPage(MenuManagement)
|
||||
await flushPromises()
|
||||
wrapper.vm.menuSelectedId = 'base'
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.menuModules).toEqual(['b_port'])
|
||||
|
||||
await wrapper.vm.handleSave()
|
||||
|
||||
const request = lastRequestOf('s_menu_module')
|
||||
expect(request.deletes).toEqual([{ b_menu_id: 'base', b_module_id: 'b_port' }])
|
||||
})
|
||||
})
|
||||
@@ -10,9 +10,8 @@
|
||||
* 3) 分类编码 b_id 是主键,修改时只提交名称与排序号,不能改编码。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import Othercompany from '@/views/base/othercompany/index.vue'
|
||||
import CategoryTreePanel from '@/views/base/othercompany/CategoryTreePanel.vue'
|
||||
import { mountPage } from '../helpers/app.js'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
@@ -28,8 +27,14 @@ vi.mock('@/components/ui/message/message-manager.js', () => {
|
||||
return { Message: m, default: m }
|
||||
})
|
||||
|
||||
// Modal.confirm 是命令式对话框:mock 掉后由用例手动触发 onOk,模拟用户点「确定」
|
||||
vi.mock('@/components/ui/modal/modal-confirm.js', () => ({
|
||||
confirm: vi.fn(),
|
||||
}))
|
||||
|
||||
import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api'
|
||||
import { Message } from '@/components/ui/message/message-manager.js'
|
||||
import { confirm as modalConfirm } from '@/components/ui/modal/modal-confirm.js'
|
||||
|
||||
const ROOT1 = {
|
||||
b_id: 'root1',
|
||||
@@ -70,6 +75,7 @@ beforeEach(() => {
|
||||
Message.success.mockClear()
|
||||
Message.error.mockClear()
|
||||
Message.warning.mockClear()
|
||||
modalConfirm.mockReset()
|
||||
loadDataApi.mockImplementation((view) => {
|
||||
if (view === 'b_othercompany_bmfl')
|
||||
return Promise.resolve({ data: CATEGORIES.map((row) => ({ ...row })) })
|
||||
@@ -157,8 +163,8 @@ describe('往来单位:删除分类的两条守卫', () => {
|
||||
|
||||
expect(Message.warning).toHaveBeenCalledWith('该分类下还有子分类,请先删除子分类')
|
||||
expect(pageDataApi).not.toHaveBeenCalled()
|
||||
expect(modalConfirm).not.toHaveBeenCalled()
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
expect(wrapper.vm.categoryDeleteTarget).toBe(null)
|
||||
})
|
||||
|
||||
it('分类下还有往来单位时拦下,不发删除请求', async () => {
|
||||
@@ -168,18 +174,20 @@ describe('往来单位:删除分类的两条守卫', () => {
|
||||
await wrapper.vm.onCategoryDelete({ b_id: 'child1' })
|
||||
|
||||
expect(Message.warning).toHaveBeenCalledWith('该分类下还有往来单位数据,不能删除')
|
||||
expect(modalConfirm).not.toHaveBeenCalled()
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
expect(wrapper.vm.categoryDeleteTarget).toBe(null)
|
||||
})
|
||||
|
||||
it('守卫通过后弹确认,确认才落库删除', async () => {
|
||||
const wrapper = await mountOthercompany()
|
||||
|
||||
await wrapper.vm.onCategoryDelete({ b_id: 'child1' })
|
||||
expect(wrapper.vm.categoryDeleteTarget?.b_id).toBe('child1')
|
||||
|
||||
expect(modalConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.vm.confirmCategoryDelete()
|
||||
// 模拟用户点「删除」
|
||||
await modalConfirm.mock.calls[0][0].onOk()
|
||||
await flushPromises()
|
||||
|
||||
const request = lastRequestOf('b_othercompany_bmfl')
|
||||
@@ -221,36 +229,30 @@ describe('往来单位:拖拽分类', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('往来单位分类树面板:搜索', () => {
|
||||
describe('往来单位分类树:搜索', () => {
|
||||
it('按名称与编码过滤,并把命中的层级展开', async () => {
|
||||
// 面板收到的是已经建好的树(页面的 categoryTree)。这里整树真实渲染:
|
||||
// Input / Button 与保留字同名,shallowMount 打桩会触发 Vue 保留名告警。
|
||||
const treeData = [{ ...ROOT1, children: [{ ...CHILD1 }] }, { ...ROOT2 }]
|
||||
const wrapper = mount(CategoryTreePanel, { props: { treeData } })
|
||||
const wrapper = await mountOthercompany()
|
||||
|
||||
expect(wrapper.vm.filteredTree).toHaveLength(2)
|
||||
expect(wrapper.emitted('update:expandedKeys')).toBeUndefined()
|
||||
// 初始:整树两棵根(root1 带子节点 child1、root2),含子节点的分类保持展开
|
||||
expect(wrapper.vm.filteredCategoryTree.map((node) => node.b_id)).toEqual(['root1', 'root2'])
|
||||
expect(wrapper.vm.expandedCategoryKeys).toEqual(['root1'])
|
||||
|
||||
// 命中子分类的编码:保留其祖先,保证命中节点可见
|
||||
wrapper.vm.keyword = 'child1'
|
||||
wrapper.vm.categoryKeyword = 'child1'
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.filteredTree.map((node) => node.b_id)).toEqual(['root1'])
|
||||
expect(wrapper.vm.filteredTree[0].children.map((node) => node.b_id)).toEqual(['child1'])
|
||||
expect(wrapper.vm.filteredCategoryTree.map((node) => node.b_id)).toEqual(['root1'])
|
||||
expect(wrapper.vm.filteredCategoryTree[0].children.map((node) => node.b_id)).toEqual(['child1'])
|
||||
// 搜索中展开全部,保证命中节点可见
|
||||
expect(wrapper.emitted('update:expandedKeys')?.at(-1)?.[0]).toEqual([
|
||||
'root1',
|
||||
'child1',
|
||||
'root2',
|
||||
])
|
||||
expect(wrapper.vm.expandedCategoryKeys).toEqual(['root1', 'child1', 'root2'])
|
||||
|
||||
// 命中根分类的名称
|
||||
wrapper.vm.keyword = '另一个'
|
||||
wrapper.vm.categoryKeyword = '另一个'
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.filteredTree.map((node) => node.b_id)).toEqual(['root2'])
|
||||
expect(wrapper.vm.filteredCategoryTree.map((node) => node.b_id)).toEqual(['root2'])
|
||||
|
||||
// 清空搜索后收起
|
||||
wrapper.vm.keyword = ''
|
||||
wrapper.vm.categoryKeyword = ''
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:expandedKeys')?.at(-1)?.[0]).toEqual([])
|
||||
expect(wrapper.vm.expandedCategoryKeys).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user