20260915221013

This commit is contained in:
oneao committed 2026-09-15 22:10:13 +08:00
1 parent 1f56cc3efb
commit dce29f250e
54 files changed
+3732 -574

No files matched your search

@@ -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)整组与分组节点/动态/通配路由', () => {
+290 -5
View File
@@ -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 = ''