20260915221013
This commit is contained in:
1 parent
1f56cc3efb
commit
dce29f250e
54 files changed
+3732
-574
No files matched your search
@@ -118,9 +118,9 @@ describe('FmsTree 虚拟根(showRoot)', () => {
|
||||
const rows = wrapper.findAll('.tree-node')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].text()).toContain('全部分类')
|
||||
// 有子节点:根不是叶子,渲染真实折叠箭头(不是 is-placeholder 占位)
|
||||
// 有子节点:根不是叶子,渲染折叠箭头
|
||||
expect(rows[0].classes()).not.toContain('is-leaf')
|
||||
expect(rows[0].find('.tree-node-switcher:not(.is-placeholder)').exists()).toBe(true)
|
||||
expect(rows[0].find('.tree-node-switcher').exists()).toBe(true)
|
||||
// 调用方的 expandedKeys 为空,根的子节点依然可见
|
||||
expect(rows[1].text()).toContain('根节点')
|
||||
})
|
||||
@@ -130,7 +130,7 @@ describe('FmsTree 虚拟根(showRoot)', () => {
|
||||
const rows = wrapper.findAll('.tree-node')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].classes()).not.toContain('is-leaf')
|
||||
expect(rows[0].find('.tree-node-switcher:not(.is-placeholder)').exists()).toBe(true)
|
||||
expect(rows[0].find('.tree-node-switcher').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('根节点以 rootKey 作为 key,业务节点不带根 key', () => {
|
||||
@@ -161,19 +161,19 @@ describe('FmsTree 虚拟根(showRoot)', () => {
|
||||
expect(wrapper.findAll('.tree-node')).toHaveLength(2)
|
||||
|
||||
// 第一个真实展开按钮属于虚拟根:点击收起整棵树
|
||||
await wrapper.find('.tree-node-switcher:not(.is-placeholder)').trigger('click')
|
||||
await wrapper.find('.tree-node-switcher').trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.findAll('.tree-node')).toHaveLength(1)
|
||||
expect(wrapper.emitted('update:expandedKeys')[0][0]).not.toContain('__fms_tree_root__')
|
||||
|
||||
// 再点一次展开,业务节点回来
|
||||
await wrapper.find('.tree-node-switcher:not(.is-placeholder)').trigger('click')
|
||||
await wrapper.find('.tree-node-switcher').trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.findAll('.tree-node')).toHaveLength(2)
|
||||
expect(wrapper.find('.tree-node-content').text()).toBe('全部分类')
|
||||
|
||||
// 业务节点自己的展开按钮照常工作
|
||||
await wrapper.findAll('.tree-node-switcher:not(.is-placeholder)')[1].trigger('click')
|
||||
await wrapper.findAll('.tree-node-switcher')[1].trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.findAll('.tree-node')).toHaveLength(3)
|
||||
})
|
||||
@@ -184,6 +184,96 @@ describe('FmsTree 虚拟根(showRoot)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('FmsTree 虚拟根的选中约定(showRoot)', () => {
|
||||
it('selectedKeys 为空时虚拟根呈现选中态,调用方无需传 rootKey', () => {
|
||||
const wrapper = mount(FmsTree, {
|
||||
props: { treeData: makeTreeData(), showRoot: true, selectedKeys: [] },
|
||||
})
|
||||
// 第一个节点是虚拟根,应带上选中类
|
||||
expect(wrapper.findAll('.tree-node')[0].classes()).toContain('is-selected')
|
||||
})
|
||||
|
||||
it('未传 selectedKeys 时同样把虚拟根当作选中(默认选中「全部」)', () => {
|
||||
const wrapper = mount(FmsTree, { props: { treeData: makeTreeData(), showRoot: true } })
|
||||
expect(wrapper.findAll('.tree-node')[0].classes()).toContain('is-selected')
|
||||
})
|
||||
|
||||
it('选中业务节点时虚拟根不再高亮', () => {
|
||||
const wrapper = mount(FmsTree, {
|
||||
props: { treeData: makeTreeData(), showRoot: true, selectedKeys: ['1'] },
|
||||
})
|
||||
const rows = wrapper.findAll('.tree-node')
|
||||
expect(rows[0].classes()).not.toContain('is-selected')
|
||||
expect(rows[1].classes()).toContain('is-selected')
|
||||
})
|
||||
|
||||
it('点中虚拟根时 select 回传空数组,调用方看不到 rootKey', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const wrapper = mount(FmsTree, {
|
||||
props: { treeData: makeTreeData(), showRoot: true },
|
||||
attrs: { onSelect },
|
||||
})
|
||||
await wrapper.findAll('.tree-node-content')[0].trigger('click')
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
// 归一化成 [],而不是 ['__fms_tree_root__']
|
||||
expect(onSelect.mock.calls[0][0]).toEqual([])
|
||||
})
|
||||
|
||||
it('点中业务节点时 select 原样回传该节点的 key', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const wrapper = mount(FmsTree, {
|
||||
props: { treeData: makeTreeData(), showRoot: true },
|
||||
attrs: { onSelect },
|
||||
})
|
||||
await wrapper.findAll('.tree-node-content')[1].trigger('click')
|
||||
expect(onSelect.mock.calls[0][0]).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('无虚拟根时不改写 selectedKeys,也不改写 select 回传值', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const wrapper = mount(FmsTree, {
|
||||
props: { treeData: makeTreeData(), selectedKeys: [] },
|
||||
attrs: { onSelect },
|
||||
})
|
||||
expect(wrapper.find('.tree-node').classes()).not.toContain('is-selected')
|
||||
|
||||
await wrapper.find('.tree-node-content').trigger('click')
|
||||
expect(onSelect.mock.calls[0][0]).toEqual(['1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('FmsTree 加载态(loading)', () => {
|
||||
it('loading=false 时不渲染遮罩', () => {
|
||||
const wrapper = mount(FmsTree, { props: { treeData: makeTreeData() } })
|
||||
expect(wrapper.find('.fms-tree__loading').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('loading=true 时渲染遮罩,且树仍在渲染(不是 v-else 替换)', () => {
|
||||
const wrapper = mount(FmsTree, {
|
||||
props: { treeData: makeTreeData(), loading: true },
|
||||
})
|
||||
expect(wrapper.find('.fms-tree__loading').exists()).toBe(true)
|
||||
// 遮罩是覆盖层,业务节点照常存在,避免加载完成后尺寸跳动
|
||||
expect(wrapper.find('.tree-node-content').text()).toBe('根节点')
|
||||
})
|
||||
|
||||
it('#loading 插槽可覆盖默认的 Spin', () => {
|
||||
const wrapper = mount(FmsTree, {
|
||||
props: { treeData: makeTreeData(), loading: true },
|
||||
slots: { loading: () => h('span', { class: 'my-loading' }, '载入中') },
|
||||
})
|
||||
expect(wrapper.find('.my-loading').text()).toBe('载入中')
|
||||
})
|
||||
|
||||
it('loading=false 时 #loading 插槽不渲染', () => {
|
||||
const wrapper = mount(FmsTree, {
|
||||
props: { treeData: makeTreeData(), loading: false },
|
||||
slots: { loading: () => h('span', { class: 'my-loading' }, '载入中') },
|
||||
})
|
||||
expect(wrapper.find('.my-loading').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FmsTree 事件透传', () => {
|
||||
it('select 事件原样透传给调用方', async () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Tree from '@/components/ui/tree/tree.vue'
|
||||
|
||||
@@ -91,27 +92,103 @@ describe('Tree 右键菜单', () => {
|
||||
|
||||
describe('Tree 行内元素顺序', () => {
|
||||
/**
|
||||
* 回归点:箭头槽(非叶为真箭头、叶子为等宽占位)必须排在拖拽手柄之前。
|
||||
* 放到手柄之后时,叶子行的手柄与标题之间会空出一整个箭头宽,读起来像多余空隙;
|
||||
* 排在前面则这段空白归入左侧缩进带,手柄紧跟标题(见 tree-node.vue 模板注释)。
|
||||
* 回归点:行内只有「箭头槽 → 标题」,不再有拖拽手柄列 ——
|
||||
* 拖拽改由长按整行发起,行与行之间不存在「有手柄 / 无手柄」的宽度差异。
|
||||
* 箭头槽是全行唯一的固定槽:非叶为真箭头、叶子为等宽占位,
|
||||
* 保证同层标题左边界一致(去掉叶子占位会让叶子标题左移一格)。
|
||||
*/
|
||||
const rowClasses = (row) => [...row.element.children].map((el) => el.className)
|
||||
|
||||
it('拖拽开启时:箭头槽在拖拽手柄之前', () => {
|
||||
it('非叶行:箭头槽在标题之前,且不渲染拖拽手柄', () => {
|
||||
const wrapper = mount(Tree, { props: { treeData, fieldNames, draggable: true } })
|
||||
const parentRow = wrapper.findAll('.tree-node')[0]
|
||||
expect(rowClasses(parentRow)).toEqual(['tree-node-switcher', 'tree-node-grip', 'tree-node-content'])
|
||||
expect(rowClasses(parentRow)).toEqual(['tree-node-switcher', 'tree-node-content'])
|
||||
})
|
||||
|
||||
it('叶子行的占位与手柄顺序一致,保证标题横坐标与非叶兄弟相同', () => {
|
||||
it('叶子行渲染等宽箭头占位,标题与非叶兄弟对齐', () => {
|
||||
const wrapper = mount(Tree, {
|
||||
props: { treeData, fieldNames, draggable: true, defaultExpandedKeys: ['1'] },
|
||||
})
|
||||
const childRow = wrapper.findAll('.tree-node')[1]
|
||||
expect(rowClasses(childRow)).toEqual([
|
||||
'tree-node-switcher is-placeholder',
|
||||
'tree-node-grip',
|
||||
'tree-node-content',
|
||||
])
|
||||
expect(rowClasses(childRow)).toEqual(['tree-node-switcher is-placeholder', 'tree-node-content'])
|
||||
})
|
||||
|
||||
it('不可拖拽的节点与可拖拽节点行内结构一致', () => {
|
||||
const wrapper = mount(Tree, {
|
||||
props: {
|
||||
fieldNames,
|
||||
draggable: true,
|
||||
defaultExpandedKeys: ['1'],
|
||||
treeData: [
|
||||
{
|
||||
b_id: '1',
|
||||
b_name: '全部分类',
|
||||
draggable: false,
|
||||
children: [{ b_id: '2', b_name: '子节点' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const rows = wrapper.findAll('.tree-node')
|
||||
expect(rowClasses(rows[0])).toEqual(['tree-node-switcher', 'tree-node-content'])
|
||||
expect(rowClasses(rows[1])).toEqual(['tree-node-switcher is-placeholder', 'tree-node-content'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tree 长按拖拽', () => {
|
||||
const rowOf = (wrapper, index = 0) => wrapper.findAll('.tree-node')[index]
|
||||
const press = (row) => row.trigger('mousedown', { button: 0, clientX: 0, clientY: 0 })
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('长按到位后才置为可拖拽,短按保持普通行', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mount(Tree, { props: { treeData, fieldNames, draggable: true } })
|
||||
const row = rowOf(wrapper)
|
||||
|
||||
await press(row)
|
||||
// 未到长按时长:仍是普通行,短按/双击/多选点击的语义不受影响
|
||||
expect(row.attributes('draggable')).toBe('false')
|
||||
|
||||
vi.advanceTimersByTime(400)
|
||||
await nextTick()
|
||||
expect(row.attributes('draggable')).toBe('true')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('长按期间位移超过余量则放弃本次拖拽', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mount(Tree, { props: { treeData, fieldNames, draggable: true } })
|
||||
const row = rowOf(wrapper)
|
||||
|
||||
await press(row)
|
||||
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 0 }))
|
||||
vi.advanceTimersByTime(400)
|
||||
await nextTick()
|
||||
expect(row.attributes('draggable')).toBe('false')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('未开启 draggable 或节点标记 draggable:false 时不进入拖拽', async () => {
|
||||
vi.useFakeTimers()
|
||||
const plain = mount(Tree, { props: { treeData, fieldNames } })
|
||||
await press(rowOf(plain))
|
||||
vi.advanceTimersByTime(500)
|
||||
await nextTick()
|
||||
expect(rowOf(plain).attributes('draggable')).toBe('false')
|
||||
plain.unmount()
|
||||
|
||||
const locked = mount(Tree, {
|
||||
props: {
|
||||
fieldNames,
|
||||
draggable: true,
|
||||
treeData: [{ b_id: '1', b_name: '根节点', draggable: false, children: [] }],
|
||||
},
|
||||
})
|
||||
await press(rowOf(locked))
|
||||
vi.advanceTimersByTime(500)
|
||||
await nextTick()
|
||||
expect(rowOf(locked).attributes('draggable')).toBe('false')
|
||||
locked.unmount()
|
||||
})
|
||||
})
|
||||
@@ -85,7 +85,7 @@ describe('路由守卫 权限加载(三场景)', () => {
|
||||
JSON.stringify({ userInfo: { orgId: 'ORG', account: 'u1' } }),
|
||||
)
|
||||
// 用 createTestPinia(内部已 install,persist 插件真实注册),等价于刷新页面
|
||||
const pinia = createTestPinia()
|
||||
createTestPinia()
|
||||
const a = useAuthStore() // 首次创建
|
||||
await a.$hydrate() // 显式从 localStorage 恢复(等价于刷新页面)
|
||||
expect(useAuthStore().isAuthenticated).toBe(true)
|
||||
|
||||
@@ -118,4 +118,19 @@ describe('ModuleFieldsPanel 参与勾选', () => {
|
||||
expect('__query' in stripped).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
/**
|
||||
* 回归点:b_i18n 是派生值(field.{模块编码}.{字段名}),加载阶段不得自动补齐。
|
||||
* 补了会让草稿与基线不一致,表现为「打开模块什么都没改,切换节点却提示有未保存的修改」。
|
||||
* 它只在用户编辑(onCellChange)与父级保存前(beforeSave)物化。
|
||||
*/
|
||||
it('模块加载后不自动派生 b_i18n,草稿保持与库中一致', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const { wrapper, localFields } = mountPanel()
|
||||
await settle()
|
||||
expect(localFields[0].b_i18n).toBeNull()
|
||||
expect(localFields[1].b_i18n).toBeNull()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildQueryCondition,
|
||||
defaultQueryValue,
|
||||
formatDateValue,
|
||||
parseOptions,
|
||||
} from '@/components/fms-module-list/moduleListUtils'
|
||||
import { buildConditionSql, defaultQueryValue } from '@/components/fms-module-common/queryUtils'
|
||||
import { formatDateValue, parseOptions } from '@/components/fms-module-common/fieldUtils'
|
||||
import { setMessages, translate } from '@/stores/i18n'
|
||||
|
||||
/** buildConditionSql 需要按字段名查到字段定义,b_type 决定字面量形式(数字裸写 / 文本 N'...') */
|
||||
function fieldMapOf(...fields) {
|
||||
return new Map(fields.map((field) => [field.b_field, field]))
|
||||
}
|
||||
|
||||
function item(over = {}) {
|
||||
return {
|
||||
field: { b_field: 'amount', b_type: 'money', b_options: '' },
|
||||
@@ -24,26 +25,31 @@ describe('fms-module-list query utilities', () => {
|
||||
})
|
||||
|
||||
it('builds numeric and escaped text conditions', () => {
|
||||
expect(buildQueryCondition(item(), 12.5)).toBe('[amount] = 12.5')
|
||||
expect(
|
||||
buildQueryCondition(
|
||||
item({ field: { b_field: 'name', b_type: 'input' }, config: { b_operator: 'like' } }),
|
||||
"O'Reilly",
|
||||
buildConditionSql(
|
||||
{ field: 'amount', operator: 'eq', value: 12.5 },
|
||||
fieldMapOf({ b_field: 'amount', b_type: 'money' }),
|
||||
),
|
||||
).toBe('[amount] = 12.5')
|
||||
expect(
|
||||
buildConditionSql(
|
||||
{ field: 'name', operator: 'like', value: "O'Reilly" },
|
||||
fieldMapOf({ b_field: 'name', b_type: 'input' }),
|
||||
),
|
||||
).toBe("[name] LIKE N'%O''Reilly%'")
|
||||
})
|
||||
|
||||
it('builds range and IN conditions while ignoring empty values', () => {
|
||||
expect(
|
||||
buildQueryCondition(
|
||||
item({ field: { b_field: 'created', b_type: 'date' }, config: { b_operator: 'between' } }),
|
||||
['2026-01-01', '2026-01-31'],
|
||||
buildConditionSql(
|
||||
{ field: 'created', operator: 'between', value: ['2026-01-01', '2026-01-31'] },
|
||||
fieldMapOf({ b_field: 'created', b_type: 'date' }),
|
||||
),
|
||||
).toBe("[created] BETWEEN N'2026-01-01' AND N'2026-01-31'")
|
||||
expect(
|
||||
buildQueryCondition(
|
||||
item({ field: { b_field: 'status', b_type: 'input' }, config: { b_operator: 'in' } }),
|
||||
['a', '', 'b'],
|
||||
buildConditionSql(
|
||||
{ field: 'status', operator: 'in', value: ['a', '', 'b'] },
|
||||
fieldMapOf({ b_field: 'status', b_type: 'input' }),
|
||||
),
|
||||
).toBe("[status] IN (N'a', N'b')")
|
||||
})
|
||||
@@ -68,4 +74,18 @@ describe('fms-module-list query utilities', () => {
|
||||
expect(formatDateValue('2026-08-01T12:34:56Z')).toMatch(/^2026-08-01/)
|
||||
expect(formatDateValue(null)).toBe('')
|
||||
})
|
||||
|
||||
/**
|
||||
* 时间契约:后端统一输出「显示格式」的墙钟串(见后端 DbUtils.normalizeJdbcValue,
|
||||
* 与旧项目读取 ResultSet 时格式化的做法一致):
|
||||
* date → "2026-09-15",datetime → "2026-09-15 21:16:47",即拿即显示、不做时区换算。
|
||||
* 展示层对标准输入应为透传;仅非标准输入(数字时间戳等)走 Date 兜底。
|
||||
*/
|
||||
it('passes through display-format strings without timezone math', () => {
|
||||
expect(formatDateValue('2026-09-15 21:16:47', true)).toBe('2026-09-15 21:16:47')
|
||||
expect(formatDateValue('2026-09-15 21:16:47', false)).toBe('2026-09-15')
|
||||
expect(formatDateValue('2026-09-15', false)).toBe('2026-09-15')
|
||||
// 非日期开头的输入(数字时间戳等)仍走 Date 兜底,按本地时间格式化
|
||||
expect(formatDateValue(1789471007000, true)).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* 往来单位分类树的页面级纯函数(仍内联在 views/base/othercompany/index.vue)。
|
||||
*
|
||||
* 建树、位置派生、排序号重排、整树重建等通用逻辑已收敛到 @/utils/tree,
|
||||
* 其测试在 tests/unit/utils.spec.js;本文件只覆盖留在页面里的两个业务判定,
|
||||
* 它们内联在页面中,因此通过挂载页面组件访问 <script setup> 的内部绑定。
|
||||
* 建树、位置派生、排序号重排、整树重建、拖拽移动等通用逻辑已收敛到 @/utils/tree
|
||||
* (其测试在 tests/unit/utils.spec.js),选中「全部分类」的虚拟根约定收敛到 FmsTree。
|
||||
* 本文件只覆盖留在页面里的业务判定,它们内联在页面中,
|
||||
* 因此通过挂载页面组件访问 <script setup> 的内部绑定。
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
@@ -67,8 +68,15 @@ describe('othercompany 分类树工具:排序号与子分类判定', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('othercompany 分类树工具:虚拟根键', () => {
|
||||
it('与 FmsTree 的 rootKey 约定保持一致', () => {
|
||||
expect(vm.ALL_CATEGORY_KEY).toBe('__fms_tree_root__')
|
||||
describe('othercompany 分类树工具:选中「全部分类」的约定', () => {
|
||||
it('未选中任何分类时用空串表达「全部」,不再持有 rootKey', () => {
|
||||
// FmsTree 内部消化虚拟根:页面侧传空选中键即代表选中根,
|
||||
// 因此页面不应再暴露 ALL_CATEGORY_KEY 之类的魔法字符串。
|
||||
expect(vm.selectedCategoryKey).toBe('')
|
||||
expect(vm.ALL_CATEGORY_KEY).toBeUndefined()
|
||||
})
|
||||
|
||||
it('未选中分类时不过滤右侧列表(不加固定条件)', () => {
|
||||
expect(vm.categoryFilterCondition).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -3,9 +3,15 @@ import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { menuRouteOptions, routes } from '@/router/routes'
|
||||
|
||||
/**
|
||||
* 路由分组(/module)的契约测试。
|
||||
* 分组节点必须保持「无 component」:一旦给它配容器组件(内部再放 router-view),
|
||||
* 页面会下沉一层、脱离 DefaultLayout 的 keep-alive,缓存静默失效。
|
||||
* 路由分组(/module)的契约测试。两条分组节点的硬约束:
|
||||
*
|
||||
* 1. 分组节点必须保持「无 component」:一旦给它配容器组件(内部再放 router-view),
|
||||
* 页面会下沉一层、脱离 DefaultLayout 的 keep-alive,缓存静默失效。
|
||||
*
|
||||
* 2. 分组节点也不配 redirect:分组只是归类,不该自带「默认落地页」语义。
|
||||
* 直接访问 /base、/module 这类裸前缀时,交给表尾的 /:pathMatch(.*)* 兜到工作台
|
||||
* (/dashboard)—— 业务入口一律由 s_menu + 路由地址维护,避免出现「菜单上没配、
|
||||
* 却能落在某个页面」的第二入口。
|
||||
*/
|
||||
function createTestRouter() {
|
||||
return createRouter({ history: createMemoryHistory(), routes })
|
||||
@@ -24,10 +30,22 @@ describe('路由分组(嵌套结构)', () => {
|
||||
expect(router.currentRoute.value.meta.componentName).toBe('I18nTypeManagement')
|
||||
})
|
||||
|
||||
it('访问分组路径本身会重定向到首个子页(不会白屏)', async () => {
|
||||
it('分组节点既无 component 也无 redirect:只是归类,不自带默认落地页', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/module/i18n-type')
|
||||
const group = router.currentRoute.value.matched[1]
|
||||
|
||||
expect(group.path).toBe('/module')
|
||||
expect(group.components).toBeFalsy()
|
||||
expect(group.redirect).toBeFalsy()
|
||||
})
|
||||
|
||||
it('访问分组裸前缀时兜到工作台,不白屏', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/module')
|
||||
expect(router.currentRoute.value.path).toBe('/module/module-management')
|
||||
|
||||
// 分组无 component、无 redirect,裸前缀由表尾 /:pathMatch(.*)* 兜底
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('菜单路由选项:排除模块中心(adminOnly)整组与分组节点/动态/通配路由', () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
addRowUpdate,
|
||||
applyTreePosition,
|
||||
buildSortedTree,
|
||||
buildTree,
|
||||
collectDescendantIds,
|
||||
computeTreeMove,
|
||||
decorateTreeNodes,
|
||||
findNodePosition,
|
||||
rebuildTreePositions,
|
||||
@@ -79,7 +81,10 @@ describe('utils/tree sortTree', () => {
|
||||
})
|
||||
|
||||
it('b_xh 相同按 b_id 排序', () => {
|
||||
const tree = [{ b_xh: 1, b_id: 'b' }, { b_xh: 1, b_id: 'a' }]
|
||||
const tree = [
|
||||
{ b_xh: 1, b_id: 'b' },
|
||||
{ b_xh: 1, b_id: 'a' },
|
||||
]
|
||||
sortTree(tree)
|
||||
expect(tree.map((n) => n.b_id)).toEqual(['a', 'b'])
|
||||
})
|
||||
@@ -119,7 +124,6 @@ describe('utils/tree 可编辑树:建树与节点装饰', () => {
|
||||
})
|
||||
|
||||
it('decorateTreeNodes 预载类型图标,并强制指定类型非叶子', () => {
|
||||
const Folder = () => null
|
||||
const tree = [
|
||||
{
|
||||
b_id: 'a',
|
||||
@@ -131,11 +135,11 @@ describe('utils/tree 可编辑树:建树与节点装饰', () => {
|
||||
decorateTreeNodes(tree, {
|
||||
typeField: 'b_module_type',
|
||||
nonLeafValues: ['module'],
|
||||
iconMap: { module: Folder },
|
||||
iconMap: { module: FolderIcon },
|
||||
})
|
||||
|
||||
expect(tree[0].isLeaf).toBe(false)
|
||||
expect(tree[0].icon).toBe(Folder)
|
||||
expect(tree[0].icon).toBe(FolderIcon)
|
||||
// 非 module 类型不改 isLeaf,图标表里没有的类型不写 icon
|
||||
expect(tree[0].children[0].isLeaf).toBeUndefined()
|
||||
expect(tree[0].children[0].icon).toBeUndefined()
|
||||
@@ -241,6 +245,287 @@ describe('utils/tree 可编辑树:位置派生与就地变更', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** decorateTreeNodes 用的占位图标组件 */
|
||||
const FolderIcon = () => null
|
||||
|
||||
/** 三层扁平行:a(含 a1、a2);a1 含 a1x;b 独立 */
|
||||
function makeDescendantRows() {
|
||||
return [
|
||||
{ b_id: 'a', b_parent_id: null },
|
||||
{ b_id: 'a1', b_parent_id: 'a' },
|
||||
{ b_id: 'a2', b_parent_id: 'a' },
|
||||
{ b_id: 'a1x', b_parent_id: 'a1' },
|
||||
{ b_id: 'b', b_parent_id: null },
|
||||
]
|
||||
}
|
||||
|
||||
const asSet = (list) => new Set(list)
|
||||
|
||||
/** 造一棵两级树:a(含 a1 / a2)、b */
|
||||
function makeMoveTree() {
|
||||
return [
|
||||
{
|
||||
b_id: 'a',
|
||||
b_parent_id: null,
|
||||
b_xh: 10,
|
||||
b_depth: 0,
|
||||
b_path: '/a/',
|
||||
children: [
|
||||
{ b_id: 'a1', b_parent_id: 'a', b_xh: 10, b_depth: 1, b_path: '/a/a1/', children: [] },
|
||||
{ b_id: 'a2', b_parent_id: 'a', b_xh: 20, b_depth: 1, b_path: '/a/a2/', children: [] },
|
||||
],
|
||||
},
|
||||
{ b_id: 'b', b_parent_id: null, b_xh: 20, b_depth: 0, b_path: '/b/', children: [] },
|
||||
]
|
||||
}
|
||||
|
||||
/** 在 updatedRows 里按 b_id 取那一行 */
|
||||
function rowById(rows, id) {
|
||||
return rows.find((row) => row.b_id === id)
|
||||
}
|
||||
|
||||
describe('utils/tree collectDescendantIds:按父级关联展开整棵子树', () => {
|
||||
it('收集自身与全部层级的下级,不限深度', () => {
|
||||
const ids = collectDescendantIds(makeDescendantRows(), 'a')
|
||||
|
||||
expect(asSet(ids)).toEqual(new Set(['a', 'a1', 'a2', 'a1x']))
|
||||
expect(ids).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('叶子节点只返回自身', () => {
|
||||
expect(collectDescendantIds(makeDescendantRows(), 'a1x')).toEqual(['a1x'])
|
||||
expect(collectDescendantIds(makeDescendantRows(), 'b')).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('结果含自身(选中一个节点 = 选中整棵子树)', () => {
|
||||
const ids = collectDescendantIds(makeDescendantRows(), 'a1')
|
||||
|
||||
expect(ids).toContain('a1')
|
||||
expect(asSet(ids)).toEqual(new Set(['a1', 'a1x']))
|
||||
})
|
||||
|
||||
it('ID 数值与字符串混用时不漏(统一按字符串比较)', () => {
|
||||
const rows = [
|
||||
{ b_id: 1, b_parent_id: null },
|
||||
{ b_id: 2, b_parent_id: 1 },
|
||||
{ b_id: 3, b_parent_id: '2' },
|
||||
]
|
||||
|
||||
// 数值 1 起头,也要能顺着字符串 '2' 找到 3
|
||||
expect(asSet(collectDescendantIds(rows, 1))).toEqual(new Set(['1', '2', '3']))
|
||||
})
|
||||
|
||||
it('rootId 不在数据中时返回只含自身的集合,不抛错', () => {
|
||||
// 语义安全:拼成 IN ('不存在') 自然查不到数据,调用方无需额外判空
|
||||
expect(collectDescendantIds(makeDescendantRows(), 'ghost')).toEqual(['ghost'])
|
||||
expect(collectDescendantIds([], 'ghost')).toEqual(['ghost'])
|
||||
expect(collectDescendantIds(null, 'ghost')).toEqual(['ghost'])
|
||||
})
|
||||
|
||||
it('b_parent_id 成环的脏数据不会死循环(visited 兜底)', () => {
|
||||
const rows = [
|
||||
{ b_id: 'x', b_parent_id: 'y' },
|
||||
{ b_id: 'y', b_parent_id: 'x' },
|
||||
]
|
||||
|
||||
expect(asSet(collectDescendantIds(rows, 'x'))).toEqual(new Set(['x', 'y']))
|
||||
})
|
||||
|
||||
it('自引用的脏数据也不会死循环', () => {
|
||||
const rows = [{ b_id: 'z', b_parent_id: 'z' }]
|
||||
|
||||
expect(collectDescendantIds(rows, 'z')).toEqual(['z'])
|
||||
})
|
||||
|
||||
it('字段名可用 options 覆盖(非 b_id / b_parent_id 的数据)', () => {
|
||||
const rows = [
|
||||
{ code: 'p', parent: null },
|
||||
{ code: 'c', parent: 'p' },
|
||||
]
|
||||
|
||||
const ids = collectDescendantIds(rows, 'p', { idKey: 'code', parentKey: 'parent' })
|
||||
expect(asSet(ids)).toEqual(new Set(['p', 'c']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('utils/tree computeTreeMove:拖拽移动的守卫与落库行', () => {
|
||||
const makeTree = makeMoveTree
|
||||
it('拖到自己身上被拒绝,且不产生任何变更', () => {
|
||||
const tree = makeTree()
|
||||
const result = computeTreeMove(tree, { dragKey: 'a', dropKey: 'a', dropPosition: 'inside' })
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.reason).toBe('self')
|
||||
expect(result.updatedRows).toBeUndefined()
|
||||
expect(tree[0].children.map((n) => n.b_id)).toEqual(['a1', 'a2'])
|
||||
})
|
||||
|
||||
it('拖拽或落点节点不存在时返回 notFound', () => {
|
||||
const tree = makeTree()
|
||||
|
||||
expect(
|
||||
computeTreeMove(tree, { dragKey: 'ghost', dropKey: 'a', dropPosition: 'inside' }).reason,
|
||||
).toBe('notFound')
|
||||
expect(
|
||||
computeTreeMove(tree, { dragKey: 'a', dropKey: 'ghost', dropPosition: 'inside' }).reason,
|
||||
).toBe('notFound')
|
||||
})
|
||||
|
||||
it('把节点拖进自己的后代被判成环,回带调用方传入的提示文案', () => {
|
||||
const tree = makeTree()
|
||||
const result = computeTreeMove(tree, {
|
||||
dragKey: 'a',
|
||||
dropKey: 'a1',
|
||||
dropPosition: 'inside',
|
||||
messages: { cycle: '不能把模块移动到它自己的下级' },
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.reason).toBe('cycle')
|
||||
expect(result.message).toBe('不能把模块移动到它自己的下级')
|
||||
// 树未被破坏
|
||||
expect(tree[0].children.map((n) => n.b_id)).toEqual(['a1', 'a2'])
|
||||
})
|
||||
|
||||
it('inside 落到非容器类型上被拒绝', () => {
|
||||
const tree = makeTree()
|
||||
const result = computeTreeMove(tree, {
|
||||
dragKey: 'b',
|
||||
dropKey: 'a1',
|
||||
dropPosition: 'inside',
|
||||
typeField: 'b_module_type',
|
||||
nonLeafValues: ['module'],
|
||||
messages: { notContainer: '只能把模块放入模块类型的节点下' },
|
||||
})
|
||||
|
||||
expect(result.reason).toBe('notContainer')
|
||||
expect(result.message).toBe('只能把模块放入模块类型的节点下')
|
||||
})
|
||||
|
||||
it('inside 落到未保存的草稿节点上被拒绝', () => {
|
||||
const tree = makeTree()
|
||||
const result = computeTreeMove(tree, {
|
||||
dragKey: 'b',
|
||||
dropKey: '-1',
|
||||
dropPosition: 'inside',
|
||||
isUnsaved: (id) => id === '-1',
|
||||
messages: { unsavedTarget: '请先保存目标模块,再调整层级' },
|
||||
})
|
||||
|
||||
expect(result.reason).toBe('notFound') // -1 不在树里
|
||||
expect(result.ok).toBe(false)
|
||||
|
||||
// 真正放进树里再验一次
|
||||
const tree2 = makeTree()
|
||||
tree2[0].children.push({ b_id: '-1', b_parent_id: 'a', b_xh: 30, children: [] })
|
||||
const result2 = computeTreeMove(tree2, {
|
||||
dragKey: 'b',
|
||||
dropKey: '-1',
|
||||
dropPosition: 'inside',
|
||||
isUnsaved: (id) => id === '-1',
|
||||
messages: { unsavedTarget: '请先保存目标模块,再调整层级' },
|
||||
})
|
||||
|
||||
expect(result2.reason).toBe('unsavedTarget')
|
||||
expect(result2.message).toBe('请先保存目标模块,再调整层级')
|
||||
})
|
||||
|
||||
it('inside:换父成功,重算 b_parent_id / b_depth / b_path,父级子集与原子级都重排 b_xh', () => {
|
||||
const tree = makeTree()
|
||||
const result = computeTreeMove(tree, { dragKey: 'b', dropKey: 'a1', dropPosition: 'inside' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.parentChanged).toBe(true)
|
||||
|
||||
// a1 收下 b 作为子节点
|
||||
const a1 = tree[0].children.find((n) => n.b_id === 'a1')
|
||||
expect(a1.children.map((n) => n.b_id)).toEqual(['b'])
|
||||
|
||||
// a 下不再有 b
|
||||
expect(tree.map((n) => n.b_id)).toEqual(['a'])
|
||||
|
||||
// 迁移后的位置字段随新父级收敛
|
||||
expect(result.moved).toMatchObject({
|
||||
b_id: 'b',
|
||||
b_parent_id: 'a1',
|
||||
b_depth: 2,
|
||||
b_path: '/a/a1/b/',
|
||||
})
|
||||
|
||||
// b_parent_id 变化会单独补一条 update
|
||||
expect(rowById(result.updatedRows, 'b').b_parent_id).toBe('a1')
|
||||
// b 落到 a1 下成为唯一子节点 → 位置 10;a 下只剩 a1(原 10)、a2(原 20),
|
||||
// 重排后取值不变,因此不再产生 update
|
||||
expect(rowById(result.updatedRows, 'b').b_xh).toBe(10)
|
||||
expect(rowById(result.updatedRows, 'a2')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('inside:落点原本没有 children 字段时补出空数组并摘掉 isLeaf', () => {
|
||||
const tree = [
|
||||
{ b_id: 'a', b_parent_id: null, b_xh: 10, b_depth: 0, b_path: '/a/', children: [] },
|
||||
{ b_id: 'b', b_parent_id: null, b_xh: 20, b_depth: 0, b_path: '/b/', isLeaf: true },
|
||||
]
|
||||
|
||||
const result = computeTreeMove(tree, { dragKey: 'a', dropKey: 'b', dropPosition: 'inside' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
const b = tree.find((n) => n.b_id === 'b')
|
||||
expect(b.children.map((n) => n.b_id)).toEqual(['a'])
|
||||
expect(b.isLeaf).toBe(false)
|
||||
})
|
||||
|
||||
it('after:同级换序,父级不变时不再产生 b_parent_id 更新', () => {
|
||||
const tree = makeTree()
|
||||
const result = computeTreeMove(tree, { dragKey: 'a2', dropKey: 'a1', dropPosition: 'after' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.parentChanged).toBe(false)
|
||||
expect(tree[0].children.map((n) => n.b_id)).toEqual(['a1', 'a2'])
|
||||
// 位置本就是 10 / 20,重排后无变化 → 该节点不产生 update
|
||||
expect(rowById(result.updatedRows, 'a2')).toBeUndefined()
|
||||
expect(result.updatedRows).toEqual([])
|
||||
})
|
||||
|
||||
it('before:跨父拖到目标之前,落到目标同级并接管目标父级', () => {
|
||||
const tree = makeTree()
|
||||
const result = computeTreeMove(tree, { dragKey: 'b', dropKey: 'a2', dropPosition: 'before' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.parentChanged).toBe(true)
|
||||
expect(tree.map((n) => n.b_id)).toEqual(['a'])
|
||||
expect(tree[0].children.map((n) => n.b_id)).toEqual(['a1', 'b', 'a2'])
|
||||
expect(result.moved).toMatchObject({ b_parent_id: 'a', b_depth: 1, b_path: '/a/b/' })
|
||||
expect(rowById(result.updatedRows, 'b').b_parent_id).toBe('a')
|
||||
})
|
||||
|
||||
it('同级 after 且拖到自身之后:索引修正后位置不变', () => {
|
||||
const tree = makeTree()
|
||||
// a1 拖到 a2 之后 => a1 落到末尾
|
||||
const result = computeTreeMove(tree, { dragKey: 'a1', dropKey: 'a2', dropPosition: 'after' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(tree[0].children.map((n) => n.b_id)).toEqual(['a2', 'a1'])
|
||||
})
|
||||
|
||||
it('同级 before 且拖到自身之前:索引修正后位置不变', () => {
|
||||
const tree = makeTree()
|
||||
const result = computeTreeMove(tree, { dragKey: 'a2', dropKey: 'a1', dropPosition: 'before' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(tree[0].children.map((n) => n.b_id)).toEqual(['a2', 'a1'])
|
||||
expect(rowById(result.updatedRows, 'a2').b_xh).toBe(10)
|
||||
})
|
||||
|
||||
it('未落库时给出空 updatedRows 而不是报错(无变化场景)', () => {
|
||||
const tree = makeTree()
|
||||
// 把 a1 拖到 a2 之前,本来就是该位置
|
||||
const result = computeTreeMove(tree, { dragKey: 'a1', dropKey: 'a2', dropPosition: 'before' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.updatedRows).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('utils/theme', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.style.cssText = ''
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 其他数据页:左侧模块树的子树裁剪。
|
||||
*
|
||||
* 本页只显示挂在根模块 `base_otherdata` 之下的子孙,不显示 s_module 里的其他模块。
|
||||
* 这段裁剪原先按 b_path 前缀匹配实现,现已统一到 @/utils/tree 的 collectDescendantIds
|
||||
* (按 b_parent_id 展开)—— 与同一函数里向上回溯祖先所用的权威字段保持一致。
|
||||
* 用例按「行为」而非「实现」编写,因此两种实现都应通过。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import Otherdata from '@/views/base/otherdata/index.vue'
|
||||
import { mountPage } from '../helpers/app.js'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(),
|
||||
pageDataApi: vi.fn(() => Promise.resolve({ data: { rows: [], total: 0 } })),
|
||||
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('@/stores/permissions', () => ({
|
||||
usePermissionStore: () => ({
|
||||
load: () => Promise.resolve(),
|
||||
canAccess: () => true,
|
||||
canPower: () => true,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { loadDataApi } from '@/services/api'
|
||||
|
||||
/** 造一条 s_module 行;默认启用、非 virtual */
|
||||
function makeModule(over = {}) {
|
||||
return {
|
||||
b_id: 'x',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/x/',
|
||||
b_name: 'X',
|
||||
b_module_type: 'data',
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定夹具:
|
||||
* base_otherdata(根)
|
||||
* ├─ odc_1(分类)
|
||||
* │ └─ od_1(数据模块)
|
||||
* └─ od_2(数据模块)
|
||||
* other_root(完全无关的模块,不应出现在本页)
|
||||
*/
|
||||
const FIXTURE = [
|
||||
makeModule({
|
||||
b_id: 'base_otherdata',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/base_otherdata/',
|
||||
b_module_type: 'module',
|
||||
}),
|
||||
makeModule({
|
||||
b_id: 'odc_1',
|
||||
b_parent_id: 'base_otherdata',
|
||||
b_depth: 1,
|
||||
b_path: '/base_otherdata/odc_1/',
|
||||
b_module_type: 'module',
|
||||
}),
|
||||
makeModule({
|
||||
b_id: 'od_1',
|
||||
b_parent_id: 'odc_1',
|
||||
b_depth: 2,
|
||||
b_path: '/base_otherdata/odc_1/od_1/',
|
||||
b_module_type: 'data',
|
||||
}),
|
||||
makeModule({
|
||||
b_id: 'od_2',
|
||||
b_parent_id: 'base_otherdata',
|
||||
b_depth: 1,
|
||||
b_path: '/base_otherdata/od_2/',
|
||||
b_module_type: 'data',
|
||||
}),
|
||||
makeModule({
|
||||
b_id: 'other_root',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/other_root/',
|
||||
b_module_type: 'module',
|
||||
}),
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
loadDataApi.mockReset()
|
||||
loadDataApi.mockImplementation(() =>
|
||||
Promise.resolve({ data: FIXTURE.map((row) => ({ ...row })) }),
|
||||
)
|
||||
})
|
||||
|
||||
async function mountOtherdata() {
|
||||
const { wrapper } = await mountPage(Otherdata)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('其他数据:只显示根模块之下的子孙', () => {
|
||||
it('包含根的各层级子孙,排除根自身', async () => {
|
||||
const wrapper = await mountOtherdata()
|
||||
const ids = wrapper.vm.modules.map((item) => String(item.b_id)).toSorted()
|
||||
|
||||
// 根自身不进树(它是本页入口,不是可导航节点)
|
||||
expect(ids).toEqual(['od_1', 'od_2', 'odc_1'])
|
||||
})
|
||||
|
||||
it('排除与根无关的其他模块', async () => {
|
||||
const wrapper = await mountOtherdata()
|
||||
const ids = wrapper.vm.modules.map((item) => String(item.b_id))
|
||||
|
||||
expect(ids).not.toContain('other_root')
|
||||
})
|
||||
|
||||
it('virtual 类型即使挂在根下也不进树', async () => {
|
||||
loadDataApi.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
...FIXTURE.map((row) => ({ ...row })),
|
||||
makeModule({
|
||||
b_id: 'od_virtual',
|
||||
b_parent_id: 'base_otherdata',
|
||||
b_depth: 1,
|
||||
b_path: '/base_otherdata/od_virtual/',
|
||||
b_module_type: 'virtual',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = await mountOtherdata()
|
||||
const ids = wrapper.vm.modules.map((item) => String(item.b_id))
|
||||
|
||||
expect(ids).not.toContain('od_virtual')
|
||||
})
|
||||
|
||||
it('b_path 与 b_parent_id 不一致时以 b_parent_id 为准(b_path 只是派生字段)', async () => {
|
||||
// 回归用例:裁剪原先按 b_path 前缀匹配,而同一函数里向上回溯祖先用 b_parent_id。
|
||||
// 这条数据的 b_path 是过期的(写着挂在别的根下),但 b_parent_id 正确指向本页根,
|
||||
// 应当被算作根的子孙 —— 否则会和祖先回溯算出矛盾结果。
|
||||
loadDataApi.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
...FIXTURE.map((row) => ({ ...row })),
|
||||
makeModule({
|
||||
b_id: 'od_moved',
|
||||
b_parent_id: 'base_otherdata',
|
||||
b_path: '/some_stale_path/od_moved/',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = await mountOtherdata()
|
||||
const ids = wrapper.vm.modules.map((item) => String(item.b_id))
|
||||
|
||||
expect(ids).toContain('od_moved')
|
||||
})
|
||||
|
||||
it('根不存在时树为空,不退化成显示全部模块', async () => {
|
||||
loadDataApi.mockImplementation(() =>
|
||||
Promise.resolve({ data: [makeModule({ b_id: 'other_root', b_path: '/other_root/' })] }),
|
||||
)
|
||||
|
||||
const wrapper = await mountOtherdata()
|
||||
|
||||
expect(wrapper.vm.modules).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 用户管理页(/system/user):左部门树 + 右用户列表的页面级业务规则。
|
||||
*
|
||||
* 本页自身只做三件事,测试也只锁这三件:
|
||||
* 1. 选中部门 → 固定查询条件(含全部下级部门,按 b_parent_id 展开);
|
||||
* 2. 新增用户时把选中部门补成默认值(只补新增、且表单未带该字段时);
|
||||
* 3. 未选中部门时不加任何固定条件(显示全部用户)。
|
||||
*
|
||||
* 树的增删改与拖拽已在 DeptTreePanel 内,由 fms-tree / utils 的既有用例覆盖,这里不重复。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import SystemUser from '@/views/system/user/index.vue'
|
||||
import { mountPage } from '../helpers/app.js'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(),
|
||||
pageDataApi: vi.fn(() => Promise.resolve({ data: { rows: [], total: 0 } })),
|
||||
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('@/stores/permissions', () => ({
|
||||
usePermissionStore: () => ({
|
||||
load: () => Promise.resolve(),
|
||||
canAccess: () => true,
|
||||
canPower: () => true,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { loadDataApi } from '@/services/api'
|
||||
|
||||
/** 造一条 b_dept 行;默认启用 */
|
||||
function makeDept(over = {}) {
|
||||
return {
|
||||
b_id: 'd1',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/d1/',
|
||||
b_name: 'D1',
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定夹具:
|
||||
* d_root
|
||||
* ├─ d_a
|
||||
* │ └─ d_a1
|
||||
* └─ d_b
|
||||
* d_other(无父,与所选部门无关)
|
||||
*/
|
||||
const FIXTURE = [
|
||||
makeDept({ b_id: 'd_root', b_parent_id: null, b_depth: 0, b_path: '/d_root/', b_name: '总部' }),
|
||||
makeDept({
|
||||
b_id: 'd_a',
|
||||
b_parent_id: 'd_root',
|
||||
b_depth: 1,
|
||||
b_path: '/d_root/d_a/',
|
||||
b_name: '华东',
|
||||
}),
|
||||
makeDept({
|
||||
b_id: 'd_a1',
|
||||
b_parent_id: 'd_a',
|
||||
b_depth: 2,
|
||||
b_path: '/d_root/d_a/d_a1/',
|
||||
b_name: '上海',
|
||||
}),
|
||||
makeDept({
|
||||
b_id: 'd_b',
|
||||
b_parent_id: 'd_root',
|
||||
b_depth: 1,
|
||||
b_path: '/d_root/d_b/',
|
||||
b_name: '华南',
|
||||
}),
|
||||
makeDept({ b_id: 'd_other', b_parent_id: null, b_depth: 0, b_path: '/d_other/', b_name: '独立' }),
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
loadDataApi.mockReset()
|
||||
loadDataApi.mockImplementation(() =>
|
||||
Promise.resolve({ data: FIXTURE.map((row) => ({ ...row })) }),
|
||||
)
|
||||
})
|
||||
|
||||
/** 只渲染默认插槽的直通壳,用于布局容器(否则插槽内容不渲染) */
|
||||
const passthrough = {
|
||||
template: '<div><slot /></div>',
|
||||
}
|
||||
|
||||
async function mountUser() {
|
||||
// DeptTreePanel 必须真实挂载:本页的「选中部门 → 查询条件」依赖它加载完部门后 emit('loaded'),
|
||||
// 若被默认打桩,emit 不会触发,页面拿到的 depts 恒为空。
|
||||
//
|
||||
// 布局容器必须按「Vue 实际推断出的组件名」打桩。
|
||||
// Splitter/SplitterPanel 两个 SFC 的文件名分别是 splitter.vue / panel.vue,
|
||||
// 推断名因此是 'splitter' / 'panel'(不是 'SplitterPanel');写错键就匹配不上,
|
||||
// 容器被 shallowMount 默认打成空壳,插槽内容不渲染,页面拿到的 depts 恒为空。
|
||||
const { wrapper } = await mountPage(SystemUser, {
|
||||
stubs: {
|
||||
splitter: passthrough,
|
||||
panel: passthrough,
|
||||
DeptTreePanel: false,
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('用户管理:选中部门 → 固定查询条件', () => {
|
||||
it('未选中部门时不加任何固定条件', async () => {
|
||||
const wrapper = await mountUser()
|
||||
|
||||
expect(wrapper.vm.deptFilterCondition).toBe('')
|
||||
})
|
||||
|
||||
it('选中部门时条件含自身与全部下级', async () => {
|
||||
const wrapper = await mountUser()
|
||||
wrapper.vm.selectedDeptKey = 'd_root'
|
||||
await flushPromises()
|
||||
|
||||
// IN 子句里 id 的顺序无意义,collectDescendantIds 按遍历序返回,这里只比对集合
|
||||
const ids = [...wrapper.vm.deptFilterCondition.matchAll(/'([^']+)'/g)].map((m) => m[1])
|
||||
expect(new Set(ids)).toEqual(new Set(['d_root', 'd_a', 'd_a1', 'd_b']))
|
||||
expect(ids).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('选中中间层只含该分支,不含兄弟分支', async () => {
|
||||
const wrapper = await mountUser()
|
||||
wrapper.vm.selectedDeptKey = 'd_a'
|
||||
await flushPromises()
|
||||
|
||||
const condition = wrapper.vm.deptFilterCondition
|
||||
expect(condition).toContain("'d_a'")
|
||||
expect(condition).toContain("'d_a1'")
|
||||
expect(condition).not.toContain("'d_b'")
|
||||
expect(condition).not.toContain("'d_root'")
|
||||
})
|
||||
|
||||
it('选中无下级的部门时只匹配自身', async () => {
|
||||
const wrapper = await mountUser()
|
||||
wrapper.vm.selectedDeptKey = 'd_a1'
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.deptFilterCondition).toBe("[b_dept_id] IN ('d_a1')")
|
||||
})
|
||||
|
||||
it('b_parent_id 与 b_path 不一致时以 b_parent_id 为准(b_path 只是派生字段)', async () => {
|
||||
loadDataApi.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
...FIXTURE.map((row) => ({ ...row })),
|
||||
makeDept({
|
||||
b_id: 'd_moved',
|
||||
b_parent_id: 'd_a',
|
||||
b_depth: 2,
|
||||
b_path: '/stale/d_moved/',
|
||||
b_name: '搬迁部门',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = await mountUser()
|
||||
wrapper.vm.selectedDeptKey = 'd_a'
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.deptFilterCondition).toContain("'d_moved'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('用户管理:新增时默认部门', () => {
|
||||
it('选中部门时新增补上 b_dept_id', async () => {
|
||||
const wrapper = await mountUser()
|
||||
wrapper.vm.selectedDeptKey = 'd_a'
|
||||
await flushPromises()
|
||||
|
||||
const row = {}
|
||||
wrapper.vm.onBeforeSave(row, { isNew: true })
|
||||
|
||||
expect(row.b_dept_id).toBe('d_a')
|
||||
})
|
||||
|
||||
it('编辑时不覆盖已有部门', async () => {
|
||||
const wrapper = await mountUser()
|
||||
wrapper.vm.selectedDeptKey = 'd_a'
|
||||
await flushPromises()
|
||||
|
||||
const row = { b_id: 'u1', b_dept_id: 'd_b' }
|
||||
wrapper.vm.onBeforeSave(row, { isNew: false })
|
||||
|
||||
expect(row.b_dept_id).toBe('d_b')
|
||||
})
|
||||
|
||||
it('表单已填部门时不覆盖用户的输入', async () => {
|
||||
const wrapper = await mountUser()
|
||||
wrapper.vm.selectedDeptKey = 'd_a'
|
||||
await flushPromises()
|
||||
|
||||
const row = { b_dept_id: 'd_b' }
|
||||
wrapper.vm.onBeforeSave(row, { isNew: true })
|
||||
|
||||
expect(row.b_dept_id).toBe('d_b')
|
||||
})
|
||||
|
||||
it('未选中部门时新增不做任何补充', async () => {
|
||||
const wrapper = await mountUser()
|
||||
|
||||
const row = {}
|
||||
wrapper.vm.onBeforeSave(row, { isNew: true })
|
||||
|
||||
expect(row.b_dept_id).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('用户管理:密码字段', () => {
|
||||
/**
|
||||
* 视图 v_b_user 刻意不含 b_password(避免全员口令随列表接口下发),
|
||||
* 因此编辑态表单恒为空 —— 留空必须理解为「保持原密码」,
|
||||
* 把字段摘掉再提交,否则会被 diff 当成「改成空」覆盖库中原值。
|
||||
*/
|
||||
it('编辑时密码留空 → 摘掉字段,不提交', async () => {
|
||||
const wrapper = await mountUser()
|
||||
|
||||
const row = { b_id: 'u1', b_name: '张三', b_password: '' }
|
||||
expect(wrapper.vm.onBeforeSave(row, { isNew: false })).toBe(true)
|
||||
expect('b_password' in row).toBe(false)
|
||||
})
|
||||
|
||||
it('编辑时填了新密码 → 原样保留待提交', async () => {
|
||||
const wrapper = await mountUser()
|
||||
|
||||
const row = { b_id: 'u1', b_name: '张三', b_password: 'new-secret' }
|
||||
expect(wrapper.vm.onBeforeSave(row, { isNew: false })).toBe(true)
|
||||
expect(row.b_password).toBe('new-secret')
|
||||
})
|
||||
|
||||
it('新增时密码为空 → 拦截保存', async () => {
|
||||
const wrapper = await mountUser()
|
||||
|
||||
const row = { b_id: 'u1', b_name: '张三' }
|
||||
expect(wrapper.vm.onBeforeSave(row, { isNew: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('新增时填了密码 → 通过,且不误删字段', async () => {
|
||||
const wrapper = await mountUser()
|
||||
|
||||
const row = { b_id: 'u1', b_name: '张三', b_password: 'secret' }
|
||||
expect(wrapper.vm.onBeforeSave(row, { isNew: true })).toBe(true)
|
||||
expect(row.b_password).toBe('secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('用户管理:行操作', () => {
|
||||
it('提供编辑操作,点开时把该行交给弹窗', async () => {
|
||||
const wrapper = await mountUser()
|
||||
|
||||
const editAction = wrapper.vm.rowActions.find((action) => action.key === 'edit')
|
||||
expect(editAction).toBeTruthy()
|
||||
|
||||
const row = { b_id: 'u1', b_name: '张三' }
|
||||
editAction.onClick(row)
|
||||
|
||||
// 用 toStrictEqual 而非 toBe:editingUser 是 ref,读出来的是响应式代理,与传入对象不同一
|
||||
expect(wrapper.vm.editingUser).toStrictEqual(row)
|
||||
expect(wrapper.vm.editOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('有删除权限时提供删除操作', async () => {
|
||||
const wrapper = await mountUser()
|
||||
|
||||
const keys = wrapper.vm.rowActions.map((action) => action.key)
|
||||
expect(keys).toEqual(['edit', 'delete'])
|
||||
})
|
||||
|
||||
it('新增时把编辑对象清空,避免带入上一次编辑的行', async () => {
|
||||
const wrapper = await mountUser()
|
||||
|
||||
wrapper.vm.onEdit({ b_id: 'u1' })
|
||||
expect(wrapper.vm.editingUser).toEqual({ b_id: 'u1' })
|
||||
|
||||
wrapper.vm.onCreate()
|
||||
expect(wrapper.vm.editingUser).toBeNull()
|
||||
expect(wrapper.vm.editOpen).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user