u
This commit is contained in:
1 parent
025d3232c4
commit
1d7d75eeba
98 files changed
+4144
-12063
No files matched your search
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* BizTreePanel:面板骨架把未声明属性经 $attrs 透传给 BizTree。
|
||||
* BizTreePanel:面板骨架的透传约定 + 业务节点行(名称 / 停用变灰 / 未保存圆点 / 行尾启用勾选)。
|
||||
*
|
||||
* 这里用渲染函数而非 props 传参,是为了还原模板编译后的真实形态:
|
||||
* 透传用例用渲染函数而非 props 传参,是为了还原模板编译后的真实形态:
|
||||
* 页面写的是 `:selected-keys`(kebab),编译出来 vnode.props 的键名就是 'selected-keys',
|
||||
* 而不是 'selectedKeys'。用 props 传 camelCase 会绕过 Vue 的键名归一化,测不出问题。
|
||||
*
|
||||
* 业务节点行的用例原属 MenuTreePanel.spec / ModuleTreePanel.spec —— 那两个薄封装已并入
|
||||
* 本面板(它们曾各写一份 93% 相同的副本),行为约束随之一并搬到这里。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { h } from 'vue'
|
||||
import { Folder } from '@lucide/vue'
|
||||
import { G3Checkbox, G3Tree } from '@g3soft/ui'
|
||||
import BizTreePanel from '@/components/biz-tree/BizTreePanel.vue'
|
||||
|
||||
const TREE = [
|
||||
@@ -52,14 +57,229 @@ describe('BizTreePanel 选中态(selected-keys 透传)', () => {
|
||||
|
||||
expect(wrapper.findAll('[role="treeitem"]')[0].attributes('aria-selected')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('点击节点抛出 select,交由调用方回写 selected-keys', async () => {
|
||||
describe('BizTreePanel select 归一化', () => {
|
||||
it('点击节点抛出单个 id(不是 keys 数组)', async () => {
|
||||
const selected: string[] = []
|
||||
const onSelect = (keys: string[]) => selected.push(...keys)
|
||||
const wrapper = mount({ render: renderPanel({ 'selected-keys': [], onSelect }) })
|
||||
const onSelect = (id: string) => selected.push(id)
|
||||
const wrapper = mount({
|
||||
render: renderPanel({ 'selected-keys': [], onSelect }),
|
||||
})
|
||||
|
||||
await rowOf(wrapper, '数据二')?.find('.g3-tree-node__content').trigger('click')
|
||||
|
||||
expect(selected).toEqual(['d2'])
|
||||
})
|
||||
|
||||
it('点中已选中的节点不重复抛出(去重)', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const wrapper = mount({
|
||||
render: renderPanel({ 'selected-keys': ['d2'], onSelect }),
|
||||
})
|
||||
|
||||
await rowOf(wrapper, '数据二')?.find('.g3-tree-node__content').trigger('click')
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
/* ============================================================================
|
||||
* 业务节点行(原 MenuTreePanel.spec / ModuleTreePanel.spec 的约束)
|
||||
* ========================================================================== */
|
||||
|
||||
/** 带启用字段的业务树:一条启用、一条停用、一条未保存草稿 */
|
||||
const BIZ_TREE = [
|
||||
{ b_id: 'n1', b_name: '启用的节点', b_canuse: 1 },
|
||||
{ b_id: 'n2', b_name: '停用的节点', b_canuse: 0 },
|
||||
{ b_id: '-1', b_name: '未保存草稿', b_canuse: 1 },
|
||||
]
|
||||
|
||||
const mountBiz = (props: Record<string, unknown> = {}) =>
|
||||
mount(BizTreePanel, {
|
||||
props: {
|
||||
treeData: BIZ_TREE,
|
||||
title: '节点',
|
||||
enableField: 'b_canuse',
|
||||
addOptions: [{ key: 'page', label: '新增页面', icon: Folder }],
|
||||
...props,
|
||||
},
|
||||
})
|
||||
|
||||
const checkboxOf = (wrapper: VueWrapper, label: string) =>
|
||||
rowOf(wrapper, label)?.findComponent(G3Checkbox)
|
||||
|
||||
describe('BizTreePanel 行尾启用勾选', () => {
|
||||
it('传了 enable-field 才渲染勾选框,勾选态反映该字段', () => {
|
||||
const wrapper = mountBiz()
|
||||
|
||||
expect(checkboxOf(wrapper, '启用的节点')?.props('modelValue')).toBe(true)
|
||||
expect(checkboxOf(wrapper, '停用的节点')?.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('勾选上报 toggleEnabled(id + 目标启用态)', async () => {
|
||||
const wrapper = mountBiz()
|
||||
|
||||
checkboxOf(wrapper, '停用的节点')?.vm.$emit('update:modelValue', true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({ id: 'n2', enabled: true })
|
||||
})
|
||||
|
||||
it('取消勾选同样上报(enabled: false)', async () => {
|
||||
const wrapper = mountBiz()
|
||||
|
||||
checkboxOf(wrapper, '启用的节点')?.vm.$emit('update:modelValue', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({ id: 'n1', enabled: false })
|
||||
})
|
||||
|
||||
it('点击勾选框不触发行的选中(事件不冒泡)', async () => {
|
||||
const wrapper = mountBiz()
|
||||
|
||||
const row = rowOf(wrapper, '启用的节点')!
|
||||
await checkboxOf(wrapper, '启用的节点')!.find('input').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
// 行自身仍可正常选中,说明拦的是勾选框那一下、不是把整行点击弄坏了
|
||||
await row.trigger('click')
|
||||
expect(wrapper.emitted('select')?.[0]?.[0]).toBe('n1')
|
||||
})
|
||||
|
||||
it('未保存草稿的勾选框禁用且不上报(库里还没有这行)', async () => {
|
||||
const wrapper = mountBiz({ unsavedIds: new Set(['-1']) })
|
||||
|
||||
const box = checkboxOf(wrapper, '未保存草稿')!
|
||||
expect(box.props('disabled')).toBe(true)
|
||||
|
||||
box.vm.$emit('update:modelValue', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('toggleEnabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('已保存节点不受草稿集合影响,仍可勾选', async () => {
|
||||
const wrapper = mountBiz({ unsavedIds: new Set(['-1']) })
|
||||
|
||||
expect(checkboxOf(wrapper, '启用的节点')?.props('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('勾选框渲染在名称之后的行尾(不是行首的多选位)', () => {
|
||||
const wrapper = mountBiz()
|
||||
const row = rowOf(wrapper, '启用的节点')!
|
||||
|
||||
const nameEl = row.find('.biz-tree-node-name')
|
||||
const enableEl = row.find('.biz-tree-node-row__enable')
|
||||
expect(nameEl.exists()).toBe(true)
|
||||
expect(enableEl.exists()).toBe(true)
|
||||
|
||||
// compareDocumentPosition: 4 = 名称之后(DOCUMENT_POSITION_FOLLOWING)
|
||||
const relation = nameEl.element.compareDocumentPosition(enableEl.element)
|
||||
expect(relation & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('不启用 G3Tree 内置 checkable(避免行首出现多选勾选框)', () => {
|
||||
const wrapper = mountBiz()
|
||||
|
||||
expect(wrapper.find('.g3-tree-node__checkbox').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('不传 enable-field 的纯只读树不渲染勾选框', () => {
|
||||
const wrapper = mount(BizTreePanel, { props: { treeData: BIZ_TREE, title: '节点' } })
|
||||
|
||||
// findComponent 找不到时返回空 wrapper(不是 undefined),断言要用 exists()
|
||||
expect(checkboxOf(wrapper, '启用的节点')?.exists()).toBe(false)
|
||||
expect(rowOf(wrapper, '启用的节点')?.find('.biz-tree-node-row__enable').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BizTreePanel 未保存标记', () => {
|
||||
it('草稿节点渲染圆点,已落库节点没有', () => {
|
||||
const wrapper = mountBiz({ unsavedIds: new Set(['-1']) })
|
||||
|
||||
expect(rowOf(wrapper, '未保存草稿')?.find('.biz-tree-node-row__unsaved').exists()).toBe(true)
|
||||
expect(rowOf(wrapper, '启用的节点')?.find('.biz-tree-node-row__unsaved').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BizTreePanel block-node 前提', () => {
|
||||
it('面板强制开 block-node:否则内容层不撑满,勾选框贴不到行尾', () => {
|
||||
const wrapper = mountBiz()
|
||||
|
||||
// 断言必须落在 G3Tree 上:BizTreePanel / BizTree 都是 inheritAttrs:false,
|
||||
// 只有当 attr 往下传到 G3Tree 时才会被归一成 blockNode prop。
|
||||
expect(wrapper.findComponent(G3Tree).props('blockNode')).toBe(true)
|
||||
})
|
||||
|
||||
it('内容层带 is-block(flex:1),勾选才有行尾可贴', () => {
|
||||
const wrapper = mountBiz()
|
||||
const row = rowOf(wrapper, '启用的节点')!
|
||||
|
||||
expect(row.find('.g3-tree-node__content').classes()).toContain('is-block')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BizTreePanel 新增与右键菜单', () => {
|
||||
it('传了 add-options 时工具栏新增走下拉,选中节点即父级', async () => {
|
||||
const wrapper = mountBiz({ selectedId: 'n1' })
|
||||
|
||||
// 下拉项点击 → 抛 add,parentId 取当前选中项、nodeType 取选项 key
|
||||
const dropdown = wrapper.findComponent({ name: 'G3Dropdown' })
|
||||
dropdown.vm.$emit('click', { key: 'page' })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('add')?.[0]?.[0]).toEqual({ parentId: 'n1', nodeType: 'page' })
|
||||
})
|
||||
|
||||
it('无选中时工具栏新增挂到顶级(parentId 为 null)', async () => {
|
||||
const wrapper = mountBiz({ selectedId: null })
|
||||
|
||||
const dropdown = wrapper.findComponent({ name: 'G3Dropdown' })
|
||||
dropdown.vm.$emit('click', { key: 'page' })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('add')?.[0]?.[0]).toEqual({ parentId: null, nodeType: 'page' })
|
||||
})
|
||||
|
||||
it('右键菜单含新增项与删除,未保存节点的新增项禁用', () => {
|
||||
const wrapper = mountBiz({ unsavedIds: new Set(['n1']) })
|
||||
|
||||
// contextMenu 不是 BizTree 的声明 prop,它经 $attrs 一路透传到 G3Tree,
|
||||
// 所以断言落点必须是 G3Tree(在中间层取会得到 undefined,假阴性)。
|
||||
const tree = wrapper.findComponent(G3Tree)
|
||||
const build = tree.props('contextMenu') as (
|
||||
node: Record<string, unknown> | null,
|
||||
) => Array<{ key: string; disabled?: boolean; divider?: boolean; label?: string }>
|
||||
|
||||
const menuForUnsaved = build({ b_id: 'n1', b_name: '启用的节点', b_canuse: 1 })
|
||||
expect(menuForUnsaved[0].disabled).toBe(true)
|
||||
expect(menuForUnsaved.at(-1)?.key).toBe('delete')
|
||||
|
||||
const menuForSavedElsewhere = build({ b_id: 'n2', b_name: '停用的节点', b_canuse: 0 })
|
||||
expect(menuForSavedElsewhere[0].disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('调用方自带 context-menu 时面板让位(不覆盖)', () => {
|
||||
const callerMenu = () => [{ key: 'custom', label: '自定义' }]
|
||||
const wrapper = mountBiz({ contextMenu: callerMenu })
|
||||
|
||||
expect(wrapper.findComponent(G3Tree).props('contextMenu')).toBe(callerMenu)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BizTreePanel 搜索与占位文案', () => {
|
||||
it('不传 placeholder 时按标题生成「搜索{标题}」', async () => {
|
||||
const wrapper = mountBiz({ title: '菜单' })
|
||||
|
||||
await wrapper.find('[title="搜索"]').trigger('click')
|
||||
const input = wrapper.find('.biz-tree-panel__search input')
|
||||
expect(input.attributes('placeholder')).toBe('搜索菜单')
|
||||
})
|
||||
|
||||
it('传了 placeholder 用调用方的', async () => {
|
||||
const wrapper = mountBiz({ title: '菜单', placeholder: '找菜单' })
|
||||
|
||||
await wrapper.find('[title="搜索"]').trigger('click')
|
||||
expect(wrapper.find('.biz-tree-panel__search input').attributes('placeholder')).toBe('找菜单')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* useBizTree:树取数与草稿保护编排。
|
||||
*
|
||||
* 重点覆盖两块最容易回归、也最难靠肉眼验证的行为:
|
||||
* 1. 重载时保留未落库草稿(含父节点已消失时降级为顶级 + 重算 b_depth / b_path);
|
||||
* 2. 默认建树 / 装饰口径,以及每个可覆盖的口子(load / transform / condition / onError)。
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { toRaw } from 'vue'
|
||||
import { useBizTree } from '@/composables/useBizTree'
|
||||
import type { TreeRow } from '@/utils/tree'
|
||||
|
||||
/** 把 loadDataApi 换掉:本文件只测编排,不打真接口 */
|
||||
const loadDataApi = vi.fn()
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: (...args: unknown[]) => loadDataApi(...args),
|
||||
}))
|
||||
|
||||
/**
|
||||
* 扁平行:模块树形态(b_parent_id 是权威关系)。
|
||||
* b_depth / b_path 是库里的派生列,真实行都带着 —— mock 也要带,
|
||||
* 否则位置派生(applyTreePosition)会按「父级 depth 缺失 = 0」算出浅一层的错值。
|
||||
*/
|
||||
const ROWS = [
|
||||
{ b_id: 'm1', b_parent_id: null, b_name: '模块一', b_module_type: 'module', b_xh: 20, b_depth: 0, b_path: '/m1/' },
|
||||
{ b_id: 'm2', b_parent_id: null, b_name: '模块二', b_module_type: 'module', b_xh: 10, b_depth: 0, b_path: '/m2/' },
|
||||
{ b_id: 'd1', b_parent_id: 'm1', b_name: '数据一', b_module_type: 'data', b_xh: 10, b_depth: 1, b_path: '/m1/d1/' },
|
||||
{ b_id: 'd2', b_parent_id: 'd1', b_name: '数据二', b_module_type: 'data', b_xh: 10, b_depth: 2, b_path: '/m1/d1/d2/' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 按 condition 过滤的假后端。
|
||||
*
|
||||
* ⚠️ 必须真的按条件筛,不能一律返回全表:懒加载的核心行为就是「按父级分次取」,
|
||||
* mock 若不筛,根请求也会带回子孙,懒加载路径等于没被验证(一开始就是这么写的)。
|
||||
* 只认本文件用到的两种条件:父级为空(根)、`b_parent_id = 'X'`(某节点的子节点)。
|
||||
*/
|
||||
function fakeBackend(condition: string): Array<Record<string, unknown>> {
|
||||
const parentEq = /b_parent_id\s*=\s*'([^']*)'/.exec(condition)
|
||||
if (parentEq) {
|
||||
return ROWS.filter((row) => String(row.b_parent_id ?? '') === parentEq[1]).map((r) => ({ ...r }))
|
||||
}
|
||||
if (/b_parent_id\s+IS\s+NULL/i.test(condition)) {
|
||||
return ROWS.filter((row) => row.b_parent_id == null).map((row) => ({ ...row }))
|
||||
}
|
||||
// 全量(懒加载关掉时)
|
||||
return ROWS.map((row) => ({ ...row }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
loadDataApi.mockReset()
|
||||
loadDataApi.mockImplementation((_table: string, condition: string) =>
|
||||
Promise.resolve(fakeBackend(condition ?? '')),
|
||||
)
|
||||
})
|
||||
|
||||
describe('默认建树与装饰', () => {
|
||||
it('按 b_xh 排序建树,子节点挂到父级下', async () => {
|
||||
const tree = useBizTree({ table: 's_module', lazy: false })
|
||||
await tree.reload()
|
||||
|
||||
// b_xh 10 的 m2 排在 20 的 m1 前面
|
||||
expect(tree.treeData.value.map((node) => node.b_id)).toEqual(['m2', 'm1'])
|
||||
const m1 = tree.findNode('m1') as TreeRow
|
||||
expect((m1.children as TreeRow[]).map((child) => child.b_id)).toEqual(['d1'])
|
||||
})
|
||||
|
||||
it('全量模式:默认取数走 loadDataApi(table, condition, orderBy)', async () => {
|
||||
const tree = useBizTree({
|
||||
table: 's_module',
|
||||
condition: 'b_canuse = 1',
|
||||
orderBy: 'b_id ASC',
|
||||
lazy: false,
|
||||
})
|
||||
await tree.reload()
|
||||
|
||||
expect(loadDataApi).toHaveBeenCalledWith('s_module', 'b_canuse = 1', 'b_id ASC')
|
||||
})
|
||||
|
||||
it('传 typeField 时按 nonLeafValues / iconMap 装饰', async () => {
|
||||
const Icon = { name: 'StubIcon' }
|
||||
const tree = useBizTree({
|
||||
table: 's_module',
|
||||
typeField: 'b_module_type',
|
||||
nonLeafValues: ['module'],
|
||||
iconMap: { module: Icon as never },
|
||||
lazy: false,
|
||||
})
|
||||
await tree.reload()
|
||||
|
||||
const m1 = tree.findNode('m1') as TreeRow
|
||||
// module 可承载子节点 → 强制非叶子(暂无子节点也显示箭头)
|
||||
expect(m1.isLeaf).toBe(false)
|
||||
// treeData 是 ref,读出来的节点是深度代理,icon 组件被包了一层 Proxy,
|
||||
// 所以比对象身份要用 toBe 之前先取原始对象(页面侧同样按引用消费,行为一致)
|
||||
expect(toRaw(m1).icon).toBe(Icon)
|
||||
// data 不在 nonLeafValues 里,不强制
|
||||
expect((tree.findNode('d1') as TreeRow).isLeaf).toBeUndefined()
|
||||
})
|
||||
|
||||
it('不传 typeField 时不做装饰(isLeaf / icon 都不动)', async () => {
|
||||
const tree = useBizTree({ table: 's_module' })
|
||||
await tree.reload()
|
||||
|
||||
const m1 = tree.findNode('m1') as TreeRow
|
||||
expect(m1.isLeaf).toBeUndefined()
|
||||
expect(m1.icon).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('可覆盖的口子', () => {
|
||||
it('load 接管后不再调 loadDataApi', async () => {
|
||||
const tree = useBizTree({
|
||||
table: 's_module',
|
||||
load: async () => [{ b_id: 'x', b_parent_id: null, b_name: '自定义', b_xh: 1 }],
|
||||
})
|
||||
await tree.reload()
|
||||
|
||||
expect(loadDataApi).not.toHaveBeenCalled()
|
||||
expect(tree.treeData.value.map((node) => node.b_id)).toEqual(['x'])
|
||||
})
|
||||
|
||||
it('全量模式:load 能拿到 table / condition / orderBy 上下文', async () => {
|
||||
const spy = vi.fn().mockResolvedValue([])
|
||||
const tree = useBizTree({ table: 's_menu', condition: 'b_canuse = 1', load: spy, lazy: false })
|
||||
await tree.reload()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
table: 's_menu',
|
||||
condition: 'b_canuse = 1',
|
||||
orderBy: 'b_xh ASC, b_id ASC',
|
||||
})
|
||||
})
|
||||
|
||||
it('懒加载模式:首屏只查根节点,condition 不参与(按父级为空的根条件)', async () => {
|
||||
const tree = useBizTree({ table: 's_module', condition: 'b_canuse = 1' })
|
||||
await tree.reload()
|
||||
|
||||
expect(loadDataApi).toHaveBeenCalledTimes(1)
|
||||
const [, condition] = loadDataApi.mock.calls[0] as [string, string]
|
||||
expect(condition).toContain('b_parent_id IS NULL')
|
||||
expect(condition).not.toBe('b_canuse = 1')
|
||||
})
|
||||
|
||||
it('transform 接管建树(接口已返回嵌套结构时原样使用)', async () => {
|
||||
const nested = [{ b_id: 'root', b_name: '根', b_parent_id: null, children: [] }]
|
||||
const tree = useBizTree({ table: 't', transform: () => nested as never })
|
||||
await tree.reload()
|
||||
|
||||
expect(tree.treeData.value).toEqual(nested)
|
||||
})
|
||||
|
||||
it('transform 能读到装饰配置,便于自行复用', async () => {
|
||||
const spy = vi.fn().mockReturnValue([])
|
||||
const tree = useBizTree({ typeField: 'b_type', nonLeafValues: ['dir'], transform: spy })
|
||||
await tree.reload()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ typeField: 'b_type', nonLeafValues: ['dir'] }),
|
||||
)
|
||||
})
|
||||
|
||||
it('onError 接管后 loading 仍会复位,且不抛给调用方', async () => {
|
||||
const onError = vi.fn()
|
||||
loadDataApi.mockRejectedValue(new Error('boom'))
|
||||
const tree = useBizTree({ table: 't', onError })
|
||||
|
||||
await expect(tree.reload()).resolves.toBeDefined()
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(tree.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('未传 onError 时默认弹提示(错误不吞掉,loading 复位)', async () => {
|
||||
loadDataApi.mockRejectedValue(new Error('boom'))
|
||||
const tree = useBizTree({ table: 't', label: '模块' })
|
||||
|
||||
await tree.reload()
|
||||
expect(tree.loading.value).toBe(false)
|
||||
expect(tree.treeData.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('草稿保护(preserveDrafts)', () => {
|
||||
it('重载时草稿子树不丢,并保持未保存标记', async () => {
|
||||
const tree = useBizTree({ table: 's_module' })
|
||||
await tree.reload()
|
||||
|
||||
// 模拟「新增待保存」:本地插一个临时负数 id 的草稿
|
||||
const draft = {
|
||||
b_id: '-1',
|
||||
b_parent_id: null,
|
||||
b_name: '',
|
||||
b_module_type: 'module',
|
||||
b_xh: 10,
|
||||
} as TreeRow
|
||||
tree.treeData.value.push(draft)
|
||||
tree.markUnsaved('-1')
|
||||
|
||||
await tree.reload()
|
||||
|
||||
expect(tree.findNode('-1')).not.toBeNull()
|
||||
expect(tree.unsavedIds.has('-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('全量模式:草稿父节点已消失时降级为顶级,并重算 b_depth / b_path', async () => {
|
||||
const tree = useBizTree({ table: 's_module', lazy: false })
|
||||
await tree.reload()
|
||||
|
||||
// 草稿挂在一个不存在的父级下(模拟父级已被改编码 / 删除)
|
||||
tree.treeData.value.push({
|
||||
b_id: '-2',
|
||||
b_parent_id: 'ghost',
|
||||
b_name: '孤儿草稿',
|
||||
b_module_type: 'module',
|
||||
b_xh: 10,
|
||||
b_depth: 3,
|
||||
b_path: '/ghost/-2/',
|
||||
} as TreeRow)
|
||||
tree.markUnsaved('-2')
|
||||
|
||||
await tree.reload()
|
||||
|
||||
const node = tree.findNode('-2') as TreeRow
|
||||
expect(node.b_parent_id).toBeNull()
|
||||
expect(node.b_depth).toBe(0)
|
||||
expect(node.b_path).toBe('/-2/')
|
||||
})
|
||||
|
||||
it('懒加载模式:父节点尚未加载时草稿暂存,不擅自降级为顶级', async () => {
|
||||
const tree = useBizTree({ table: 's_module' })
|
||||
await tree.reload()
|
||||
|
||||
// 'd1' 不是根节点:首屏只拉了根(m1 / m2),d1 此刻还不在树上。
|
||||
// 懒加载分不清「父节点不存在」和「父节点没展开」,所以不能像全量模式那样
|
||||
// 直接把 b_parent_id 抹成 null —— 那会静默丢掉层级,等展开再挂上才对。
|
||||
tree.treeData.value.push({
|
||||
b_id: '-4',
|
||||
b_parent_id: 'd1',
|
||||
b_name: '深层草稿',
|
||||
b_module_type: 'data',
|
||||
b_xh: 10,
|
||||
} as TreeRow)
|
||||
tree.markUnsaved('-4')
|
||||
|
||||
await tree.reload()
|
||||
|
||||
// 父节点没加载到 → 草稿先存着,不出现在树上,但标记还在
|
||||
expect(tree.findNode('-4')).toBeNull()
|
||||
expect(tree.unsavedIds.has('-4')).toBe(true)
|
||||
})
|
||||
|
||||
it('懒加载模式:父节点展开后,暂存的草稿自动挂回去', async () => {
|
||||
const tree = useBizTree({ table: 's_module' })
|
||||
await tree.reload()
|
||||
|
||||
tree.treeData.value.push({
|
||||
b_id: '-5',
|
||||
b_parent_id: 'd1',
|
||||
b_name: '等父节点的草稿',
|
||||
b_module_type: 'data',
|
||||
b_xh: 10,
|
||||
} as TreeRow)
|
||||
tree.markUnsaved('-5')
|
||||
await tree.reload()
|
||||
|
||||
// 逐层展开 m1 → d1,草稿应在 d1 的子节点加载后出现
|
||||
await tree.loadChildren(tree.findNode('m1') as TreeRow)
|
||||
const d1 = tree.findNode('d1') as TreeRow
|
||||
await tree.loadChildren(d1)
|
||||
|
||||
const draft = tree.findNode('-5') as TreeRow
|
||||
expect(draft).not.toBeNull()
|
||||
expect(draft.b_parent_id).toBe('d1')
|
||||
expect(draft.b_depth).toBe(2)
|
||||
})
|
||||
|
||||
it('preserveDrafts=false 时草稿被丢弃', async () => {
|
||||
const tree = useBizTree({ table: 's_module', preserveDrafts: false })
|
||||
await tree.reload()
|
||||
|
||||
tree.treeData.value.push({ b_id: '-3', b_parent_id: null, b_xh: 10 } as TreeRow)
|
||||
tree.markUnsaved('-3')
|
||||
|
||||
await tree.reload()
|
||||
|
||||
expect(tree.findNode('-3')).toBeNull()
|
||||
})
|
||||
|
||||
it('重载丢弃的草稿其未保存标记也一并清掉', async () => {
|
||||
const tree = useBizTree({ table: 's_module', preserveDrafts: false })
|
||||
await tree.reload()
|
||||
tree.markUnsaved('no-such-node')
|
||||
|
||||
await tree.reload()
|
||||
|
||||
expect(tree.unsavedIds.has('no-such-node')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('节点查询与草稿标记', () => {
|
||||
it('findNode 对空 id 返回 null(不把空串当合法键)', async () => {
|
||||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||||
const tree = useBizTree({ table: 't', lazy: false })
|
||||
await tree.reload()
|
||||
|
||||
expect(tree.findNode(null)).toBeNull()
|
||||
expect(tree.findNode(undefined)).toBeNull()
|
||||
expect(tree.findNode('')).toBeNull()
|
||||
})
|
||||
|
||||
it('findNode 能查到深层子节点', async () => {
|
||||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||||
const tree = useBizTree({ table: 't', lazy: false })
|
||||
await tree.reload()
|
||||
|
||||
expect((tree.findNode('d1') as TreeRow).b_name).toBe('数据一')
|
||||
})
|
||||
|
||||
it('collectSubtreeIds 收集自身与全部后代', async () => {
|
||||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||||
const tree = useBizTree({ table: 't', lazy: false })
|
||||
await tree.reload()
|
||||
|
||||
// m1 → d1 → d2,三层都要收进来
|
||||
expect([...tree.collectSubtreeIds(tree.findNode('m1') as TreeRow)].sort()).toEqual([
|
||||
'd1',
|
||||
'd2',
|
||||
'm1',
|
||||
])
|
||||
})
|
||||
|
||||
it('hasSavedDescendant:子孙里有已落库节点才为 true', async () => {
|
||||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||||
const tree = useBizTree({ table: 't', lazy: false })
|
||||
await tree.reload()
|
||||
|
||||
// d1 未标草稿 → 视为已落库
|
||||
expect(tree.hasSavedDescendant(tree.findNode('m1') as TreeRow)).toBe(true)
|
||||
|
||||
// 整条子孙链都标成草稿(d1 与更深一层的 d2)后,m1 才算没有已落库的下级
|
||||
tree.markUnsaved('d1')
|
||||
tree.markUnsaved('d2')
|
||||
expect(tree.hasSavedDescendant(tree.findNode('m1') as TreeRow)).toBe(false)
|
||||
})
|
||||
|
||||
it('unmarkUnsavedSubtree 连带清掉整棵子树的草稿标记', async () => {
|
||||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||||
const tree = useBizTree({ table: 't', lazy: false })
|
||||
await tree.reload()
|
||||
|
||||
tree.markUnsaved('m1')
|
||||
tree.markUnsaved('d1')
|
||||
tree.unmarkUnsavedSubtree(tree.findNode('m1') as TreeRow)
|
||||
|
||||
expect(tree.unsavedIds.has('m1')).toBe(false)
|
||||
expect(tree.unsavedIds.has('d1')).toBe(false)
|
||||
})
|
||||
|
||||
it('unmarkUnsaved 只清单个节点,不动其子节点', async () => {
|
||||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||||
const tree = useBizTree({ table: 't', lazy: false })
|
||||
await tree.reload()
|
||||
|
||||
tree.markUnsaved('m1')
|
||||
tree.markUnsaved('d1')
|
||||
tree.unmarkUnsaved('m1')
|
||||
|
||||
expect(tree.unsavedIds.has('m1')).toBe(false)
|
||||
expect(tree.unsavedIds.has('d1')).toBe(true)
|
||||
})
|
||||
|
||||
it('unmarkUnsavedSubtree 传空值时不报错', () => {
|
||||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||||
const tree = useBizTree({ table: 't', lazy: false })
|
||||
expect(() => tree.unmarkUnsavedSubtree(null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('syncSubtreePosition 递归重算整棵子树的派生列', () => {
|
||||
// 这组用例验证的是「树内查询 / 草稿标记」,不是加载行为:
|
||||
// 用全量模式一次性建好整棵树,才能查到深层节点 d1
|
||||
const tree = useBizTree({ table: 't', lazy: false })
|
||||
const parent = { b_id: 'p', b_depth: 0, b_path: '/p/' } as TreeRow
|
||||
const child = { b_id: 'c', b_parent_id: 'p' } as TreeRow
|
||||
const grandchild = { b_id: 'g', b_parent_id: 'c' } as TreeRow
|
||||
child.children = [grandchild]
|
||||
;(parent as TreeRow).children = [child]
|
||||
|
||||
tree.syncSubtreePosition(child as never, parent)
|
||||
|
||||
expect(child.b_depth).toBe(1)
|
||||
expect(child.b_path).toBe('/p/c/')
|
||||
expect(grandchild.b_depth).toBe(2)
|
||||
expect(grandchild.b_path).toBe('/p/c/g/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* querySchema:查询配置的解析 / 派生 / 自检。
|
||||
*
|
||||
* 重点覆盖三块最容易回归的逻辑:
|
||||
* 1. 宽容解析——库里存着 12 条空壳与一批旧结构(groups / quick),不认识就按空处理,
|
||||
* 不能抛错、也不能把两种结构混着渲染;
|
||||
* 2. 「更多查询」派生——这是唯一**不落库**的部分,规则错了配置界面与列表页会对不上;
|
||||
* 3. 保存前自检——这些是「运行时一定拼不出合法 SQL」的硬错误。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createEmptyQuerySchema,
|
||||
defaultOperatorOf,
|
||||
deriveMoreEntries,
|
||||
inferComponent,
|
||||
needsOptions,
|
||||
operatorOptionsOf,
|
||||
parseQuerySchema,
|
||||
pinnedFieldSet,
|
||||
TAGS_OPERATOR,
|
||||
validateQuerySchema,
|
||||
} from '@/utils/querySchema'
|
||||
|
||||
describe('parseQuerySchema 宽容解析', () => {
|
||||
it('空值 / 非法 JSON 回退到空配置', () => {
|
||||
for (const raw of [null, undefined, '', ' ', '{不是json}', '[]', '123']) {
|
||||
const schema = parseQuerySchema(raw)
|
||||
expect(schema.keyword.enabled).toBe(true)
|
||||
expect(schema.keyword.fields).toEqual([])
|
||||
expect(schema.filters).toEqual([])
|
||||
expect(schema.customs).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('旧结构(groups / quick)不被识别,解析成空配置', () => {
|
||||
// 库里确实存在这种行(b_city 等 12 条),刻意不做兼容
|
||||
const schema = parseQuerySchema({
|
||||
schemaVersion: 1,
|
||||
quick: ['b_name'],
|
||||
groups: [{ conditions: [{ field: 'b_name', operator: 'like' }] }],
|
||||
})
|
||||
|
||||
expect(schema.keyword.fields).toEqual([])
|
||||
expect(schema.filters).toEqual([])
|
||||
})
|
||||
|
||||
it('完整结构能原样读回来', () => {
|
||||
const schema = parseQuerySchema({
|
||||
keyword: { enabled: false, fields: ['b_name', 'b_id'] },
|
||||
filters: [
|
||||
{ field: 'b_name', pinned: true, component: 'input', operator: 'like' },
|
||||
{ field: 'b_city', pinned: false },
|
||||
],
|
||||
customs: [
|
||||
{ key: 'q1', label: '仅看逾期', component: 'checkbox', condition: 'b_x < 1', pinned: true },
|
||||
],
|
||||
})
|
||||
|
||||
expect(schema.keyword).toEqual({
|
||||
enabled: false,
|
||||
fields: ['b_name', 'b_id'],
|
||||
})
|
||||
// 旧结构里的 placeholder 不再解析(配置界面已不提供该项)
|
||||
expect('placeholder' in schema.keyword).toBe(false)
|
||||
expect(schema.filters).toHaveLength(2)
|
||||
expect(schema.filters[0]).toEqual({
|
||||
field: 'b_name',
|
||||
pinned: true,
|
||||
component: 'input',
|
||||
operator: 'like',
|
||||
})
|
||||
// 未写 pinned 的项保持 undefined(不擅自补 false,避免与「显式收入更多」混淆)
|
||||
expect(schema.filters[1].pinned).toBe(false)
|
||||
expect(schema.customs[0].condition).toBe('b_x < 1')
|
||||
})
|
||||
|
||||
it('缺 field 的 filter、缺 key/label 的 custom 被丢弃', () => {
|
||||
const schema = parseQuerySchema({
|
||||
filters: [{ pinned: true }, { field: 'ok' }],
|
||||
customs: [
|
||||
{ key: 'q1', component: 'checkbox' }, // 缺 label
|
||||
{ label: '无 key', component: 'checkbox' }, // 缺 key
|
||||
{ key: 'q2', label: '合法项', component: 'checkbox' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(schema.filters.map((item) => item.field)).toEqual(['ok'])
|
||||
expect(schema.customs.map((item) => item.key)).toEqual(['q2'])
|
||||
})
|
||||
|
||||
it('选项来源两种形态都能解析,非法 source 丢弃', () => {
|
||||
const schema = parseQuerySchema({
|
||||
filters: [
|
||||
{
|
||||
field: 'a',
|
||||
options: { source: 'static', items: [{ label: '已核销', value: '1' }] },
|
||||
},
|
||||
{ field: 'b', options: { source: 'sql', sql: 'select 1' } },
|
||||
{ field: 'c', options: { source: '未知', sql: 'x' } },
|
||||
],
|
||||
})
|
||||
|
||||
expect(schema.filters[0].options).toEqual({
|
||||
source: 'static',
|
||||
items: [{ label: '已核销', value: '1' }],
|
||||
})
|
||||
expect(schema.filters[1].options).toEqual({ source: 'sql', sql: 'select 1' })
|
||||
expect(schema.filters[2].options).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('控件与操作符', () => {
|
||||
it('按字段类型推断控件,未知类型回退 input', () => {
|
||||
expect(inferComponent('textarea')).toBe('textarea')
|
||||
expect(inferComponent('number')).toBe('number')
|
||||
expect(inferComponent('money')).toBe('money')
|
||||
expect(inferComponent('date')).toBe('date')
|
||||
expect(inferComponent('datetime')).toBe('datetime')
|
||||
expect(inferComponent('checkbox')).toBe('checkbox')
|
||||
expect(inferComponent('select')).toBe('select')
|
||||
expect(inferComponent('input')).toBe('input')
|
||||
expect(inferComponent(null)).toBe('input')
|
||||
expect(inferComponent('没见过的类型')).toBe('input')
|
||||
})
|
||||
|
||||
it('各控件只给出类型上说得通的操作符', () => {
|
||||
// 文本没有「大于」
|
||||
expect(operatorOptionsOf('input').map((o) => o.value)).toEqual(['like', 'eq', 'ne'])
|
||||
// 数字没有「包含」
|
||||
expect(operatorOptionsOf('number').map((o) => o.value)).not.toContain('like')
|
||||
// 复选框只有等值
|
||||
expect(operatorOptionsOf('checkbox').map((o) => o.value)).toEqual(['eq'])
|
||||
// tags 已从控件体系移除:它走独立的 QueryTag 组,固定用 TAGS_OPERATOR
|
||||
expect(operatorOptionsOf('tags').map((o) => o.value)).toEqual(['like', 'eq', 'ne'])
|
||||
expect(TAGS_OPERATOR).toBe('in')
|
||||
})
|
||||
|
||||
it('默认操作符取各自可用列表的第一项', () => {
|
||||
expect(defaultOperatorOf('input')).toBe('like')
|
||||
expect(defaultOperatorOf('number')).toBe('eq')
|
||||
expect(defaultOperatorOf('datetime')).toBe('eq')
|
||||
})
|
||||
|
||||
it('只有 select 需要配选项(tags 是独立结构,必然自带选项)', () => {
|
||||
expect(needsOptions('select')).toBe(true)
|
||||
expect(needsOptions('tags')).toBe(false)
|
||||
expect(needsOptions('input')).toBe(false)
|
||||
expect(needsOptions('checkbox')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('「更多查询」派生(不落库)', () => {
|
||||
const FIELDS = new Map<string, { b_name?: unknown; b_type?: unknown }>([
|
||||
['b_name', { b_name: '名称', b_type: 'input' }],
|
||||
['b_city', { b_name: '城市', b_type: 'select' }],
|
||||
['b_created_at', { b_name: '创建时间', b_type: 'datetime' }],
|
||||
['b_remark', { b_name: '备注', b_type: 'input' }],
|
||||
])
|
||||
|
||||
it('列表列里未被普通查询占用的列,自动进更多查询', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.filters = [{ field: 'b_name', pinned: true }]
|
||||
const columns = [{ field: 'b_name' }, { field: 'b_city' }, { field: 'b_created_at' }]
|
||||
|
||||
const entries = deriveMoreEntries(schema, columns, FIELDS)
|
||||
|
||||
// b_name 已在普通查询 → 排除;其余两列衍生出来
|
||||
expect(entries.map((e) => e.field)).toEqual(['b_city', 'b_created_at'])
|
||||
expect(entries.every((e) => e.from === 'column')).toBe(true)
|
||||
// 名称与控件按字段元数据带出来
|
||||
expect(entries[0]).toMatchObject({ label: '城市', component: 'select' })
|
||||
expect(entries[1]).toMatchObject({ label: '创建时间', component: 'datetime' })
|
||||
})
|
||||
|
||||
it('列表列里没有、但显式收进更多查询的条件也会出现', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.filters = [{ field: 'b_remark', pinned: false }]
|
||||
|
||||
const entries = deriveMoreEntries(schema, [{ field: 'b_name' }], FIELDS)
|
||||
|
||||
expect(entries.map((e) => [e.field, e.from])).toEqual([
|
||||
['b_name', 'column'],
|
||||
['b_remark', 'filter'],
|
||||
])
|
||||
})
|
||||
|
||||
it('自定义项按 key 出现在更多查询里', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.customs = [
|
||||
{ key: 'q1', label: '仅看逾期', component: 'checkbox', pinned: false },
|
||||
]
|
||||
|
||||
const entries = deriveMoreEntries(schema, [], FIELDS)
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ field: 'q1', label: '仅看逾期', component: 'checkbox', from: 'custom' },
|
||||
])
|
||||
})
|
||||
|
||||
it('同一字段既在列表列又在 filters 里,只出现一次', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.filters = [{ field: 'b_city', pinned: false }]
|
||||
|
||||
const entries = deriveMoreEntries(schema, [{ field: 'b_city' }], FIELDS)
|
||||
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0].from).toBe('column')
|
||||
})
|
||||
|
||||
it('没有字段元数据时用字段名本身作标签', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
|
||||
const entries = deriveMoreEntries(schema, [{ field: 'unknown_col' }])
|
||||
|
||||
expect(entries[0]).toMatchObject({ field: 'unknown_col', label: 'unknown_col', component: 'input' })
|
||||
})
|
||||
|
||||
it('列里缺 field 的项被跳过(不产生空条目)', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
|
||||
const entries = deriveMoreEntries(schema, [{}, { field: '' }, { field: 'b_name' }])
|
||||
|
||||
expect(entries.map((e) => e.field)).toEqual(['b_name'])
|
||||
})
|
||||
|
||||
it('pinnedFieldSet 汇总普通查询占用的字段,含自定义项的 field', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.filters = [
|
||||
{ field: 'a', pinned: true },
|
||||
{ field: 'b', pinned: false },
|
||||
]
|
||||
schema.customs = [
|
||||
{ key: 'q1', label: '固定条件', component: 'checkbox', pinned: true },
|
||||
{ key: 'q2', label: '虚拟字段', component: 'number', field: 'c', pinned: true },
|
||||
{ key: 'q3', label: '收起来的', component: 'number', field: 'd', pinned: false },
|
||||
]
|
||||
|
||||
expect([...pinnedFieldSet(schema)].sort()).toEqual(['a', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateQuerySchema 自检', () => {
|
||||
it('空配置无问题', () => {
|
||||
expect(validateQuerySchema(createEmptyQuerySchema())).toEqual([])
|
||||
})
|
||||
|
||||
it('同一字段在 filters 里重复出现会报错', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.filters = [{ field: 'a', pinned: true }, { field: 'a', pinned: false }]
|
||||
|
||||
expect(validateQuerySchema(schema).join()).toContain('重复出现')
|
||||
})
|
||||
|
||||
it('自定义项 key 重复会报错', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.customs = [
|
||||
{ key: 'q1', label: 'A', component: 'checkbox', condition: 'x = 1' },
|
||||
{ key: 'q1', label: 'B', component: 'checkbox', condition: 'y = 1' },
|
||||
]
|
||||
|
||||
expect(validateQuerySchema(schema).join()).toContain('重复')
|
||||
})
|
||||
|
||||
it('固定条件形态缺 condition 会报错;虚拟字段形态缺 field 也会', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.customs = [
|
||||
{ key: 'q1', label: '缺条件', component: 'checkbox' },
|
||||
{ key: 'q2', label: '缺字段', component: 'number' },
|
||||
]
|
||||
|
||||
const issues = validateQuerySchema(schema)
|
||||
expect(issues.join()).toContain('必须填写条件片段')
|
||||
expect(issues.join()).toContain('缺少字段或字段表达式')
|
||||
})
|
||||
|
||||
it('缺名称会报错', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.customs = [{ key: 'q1', label: '', component: 'checkbox', condition: 'x = 1' }]
|
||||
|
||||
expect(validateQuerySchema(schema).join()).toContain('缺少名称')
|
||||
})
|
||||
|
||||
it('select 缺选项会报错,配了选项则不报', () => {
|
||||
const bad = createEmptyQuerySchema()
|
||||
bad.filters = [{ field: 'a', component: 'select' }]
|
||||
expect(validateQuerySchema(bad).join()).toContain('需要配置选项')
|
||||
|
||||
const good = createEmptyQuerySchema()
|
||||
good.filters = [
|
||||
{ field: 'a', component: 'select', options: { source: 'sql', sql: 'select 1 as value' } },
|
||||
]
|
||||
expect(validateQuerySchema(good)).toEqual([])
|
||||
})
|
||||
|
||||
it('静态选项为空数组视为没配', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.filters = [{ field: 'a', component: 'select', options: { source: 'static', items: [] } }]
|
||||
|
||||
expect(validateQuerySchema(schema).join()).toContain('需要配置选项')
|
||||
})
|
||||
})
|
||||
|
||||
/* ============================================================================
|
||||
* 多标签搜索(tags 组)
|
||||
* ========================================================================== */
|
||||
|
||||
describe('多标签搜索', () => {
|
||||
it('完整结构能解析(含 SQL 选项、数量、默认选中)', () => {
|
||||
const schema = parseQuerySchema({
|
||||
tags: [
|
||||
{
|
||||
key: 't1',
|
||||
label: '核销状态',
|
||||
field: 'b_writeoff_status',
|
||||
pinned: true,
|
||||
showCount: true,
|
||||
defaultValues: ['0', '1'],
|
||||
options: {
|
||||
source: 'sql',
|
||||
sql: 'select b_id as value, b_name as label, cnt as count from x',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(schema.tags).toHaveLength(1)
|
||||
expect(schema.tags[0]).toMatchObject({
|
||||
key: 't1',
|
||||
label: '核销状态',
|
||||
field: 'b_writeoff_status',
|
||||
pinned: true,
|
||||
showCount: true,
|
||||
defaultValues: ['0', '1'],
|
||||
})
|
||||
expect(schema.tags[0].options?.source).toBe('sql')
|
||||
})
|
||||
|
||||
it('缺 key / label / field 的组被丢弃', () => {
|
||||
const schema = parseQuerySchema({
|
||||
tags: [
|
||||
{ label: '缺key', field: 'a' },
|
||||
{ key: 't1', field: 'a' },
|
||||
{ key: 't2', label: '缺field' },
|
||||
{ key: 't3', label: '合法', field: 'a' },
|
||||
],
|
||||
})
|
||||
expect(schema.tags.map((t) => t.key)).toEqual(['t3'])
|
||||
})
|
||||
|
||||
it('静态选项的数量只有确实写了才保留(空串不落成 NaN)', () => {
|
||||
const schema = parseQuerySchema({
|
||||
tags: [
|
||||
{
|
||||
key: 't1',
|
||||
label: '状态',
|
||||
field: 'st',
|
||||
options: {
|
||||
source: 'static',
|
||||
items: [
|
||||
{ label: '待核销', value: '0', count: 12 },
|
||||
{ label: '已核销', value: '1', count: '' },
|
||||
{ label: '其它', value: '2', count: 'abc' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const items = schema.tags[0].options?.items ?? []
|
||||
expect(items[0].count).toBe(12)
|
||||
expect(items[1].count).toBeUndefined()
|
||||
expect(items[2].count).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tags 组参与「更多查询」派生(按 key 去重,不与字段名混)', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.tags = [
|
||||
{ key: 't1', label: '核销状态', field: 'b_st', pinned: false },
|
||||
{ key: 't2', label: '审核状态', field: 'b_audit', pinned: true },
|
||||
]
|
||||
|
||||
const entries = deriveMoreEntries(schema, [{ field: 'b_name' }])
|
||||
|
||||
// t2 在普通查询里 → 不出现在更多;t1 出现且来源标为 tag
|
||||
expect(entries.map((e) => [e.field, e.from])).toEqual([
|
||||
['b_name', 'column'],
|
||||
['t1', 'tag'],
|
||||
])
|
||||
})
|
||||
|
||||
it('普通查询里的 tags 组,其字段从更多查询里排除', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.tags = [{ key: 't1', label: '核销状态', field: 'b_st', pinned: true }]
|
||||
|
||||
// b_st 这一列已经在普通查询(通过 tags 组)→ 不该再作为列衍生项出现
|
||||
expect([...pinnedFieldSet(schema)]).toEqual(['b_st'])
|
||||
expect(deriveMoreEntries(schema, [{ field: 'b_st' }])).toEqual([])
|
||||
})
|
||||
|
||||
it('缺选项的 tags 组会报错', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.tags = [{ key: 't1', label: '核销状态', field: 'b_st' }]
|
||||
|
||||
expect(validateQuerySchema(schema).join()).toContain('需要配置选项')
|
||||
})
|
||||
|
||||
it('刚新增、还什么都没填的 tags 组不报错(那是「还没配」,不是「配错了」)', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
// 「新增标签组」刚点下去的形态:有 key 与默认字段,但组名空、选项空
|
||||
schema.tags = [
|
||||
{ key: 't1', label: '', field: 'b_st', options: { source: 'sql', sql: '', items: [] } },
|
||||
{ key: 't2', label: '', field: 'b_st', options: { source: 'static', items: [] } },
|
||||
]
|
||||
|
||||
expect(validateQuerySchema(schema)).toEqual([])
|
||||
})
|
||||
|
||||
it('只要填了组名或配了选项,就进入校验', () => {
|
||||
// 填了组名 → 开始校验选项
|
||||
const withLabel = createEmptyQuerySchema()
|
||||
withLabel.tags = [
|
||||
{ key: 't1', label: '审核状态', field: 'b_st', options: { source: 'sql', sql: '' } },
|
||||
]
|
||||
expect(validateQuerySchema(withLabel).join()).toContain('需要配置选项')
|
||||
|
||||
// 配了选项 → 开始校验组名
|
||||
const withOptions = createEmptyQuerySchema()
|
||||
withOptions.tags = [
|
||||
{ key: 't1', label: '', field: 'b_st', options: { source: 'sql', sql: 'select 1' } },
|
||||
]
|
||||
expect(validateQuerySchema(withOptions).join()).toContain('缺少名称')
|
||||
})
|
||||
|
||||
it('缺字段的 tags 组会报错', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.tags = [
|
||||
{ key: 't1', label: '核销状态', field: '', options: { source: 'sql', sql: 'select 1' } },
|
||||
]
|
||||
|
||||
expect(validateQuerySchema(schema).join()).toContain('缺少筛选字段')
|
||||
})
|
||||
|
||||
it('tags 标识重复会报错', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
const options = { source: 'sql' as const, sql: 'select 1' }
|
||||
schema.tags = [
|
||||
{ key: 't1', label: 'A', field: 'a', options },
|
||||
{ key: 't1', label: 'B', field: 'b', options },
|
||||
]
|
||||
|
||||
expect(validateQuerySchema(schema).join()).toContain('重复')
|
||||
})
|
||||
|
||||
it('开了数量但静态选项都没写数量,会报错', () => {
|
||||
const schema = createEmptyQuerySchema()
|
||||
schema.tags = [
|
||||
{
|
||||
key: 't1',
|
||||
label: '核销状态',
|
||||
field: 'b_st',
|
||||
showCount: true,
|
||||
options: {
|
||||
source: 'static',
|
||||
items: [
|
||||
{ label: '待核销', value: '0' },
|
||||
{ label: '已核销', value: '1' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
expect(validateQuerySchema(schema).join()).toContain('都没写数量')
|
||||
})
|
||||
|
||||
it('开了数量且写了数量则不报;用 SQL 选项也不报', () => {
|
||||
const withCounts = createEmptyQuerySchema()
|
||||
withCounts.tags = [
|
||||
{
|
||||
key: 't1',
|
||||
label: '核销状态',
|
||||
field: 'b_st',
|
||||
showCount: true,
|
||||
options: {
|
||||
source: 'static',
|
||||
items: [{ label: '待核销', value: '0', count: 3 }],
|
||||
},
|
||||
},
|
||||
]
|
||||
expect(validateQuerySchema(withCounts)).toEqual([])
|
||||
|
||||
const withSql = createEmptyQuerySchema()
|
||||
withSql.tags = [
|
||||
{
|
||||
key: 't1',
|
||||
label: '核销状态',
|
||||
field: 'b_st',
|
||||
showCount: true,
|
||||
options: { source: 'sql', sql: 'select 1 as value, 2 as label, 3 as count' },
|
||||
},
|
||||
]
|
||||
expect(validateQuerySchema(withSql)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* MenuTreePanel:行尾启用勾选。
|
||||
*
|
||||
* 与模块树面板同构(勾选框不走 G3Tree 内置的 checkable,而是画在 #title 插槽行尾),
|
||||
* 因此同样要锁住三件事:
|
||||
* 1. 勾选上报 toggleEnabled,由父级落库;
|
||||
* 2. 点击不冒泡(行上挂了 click 选中 / mousedown 武装拖拽);
|
||||
* 3. 未保存草稿不可勾(库里没有这行),且面板必须开 block-node 才能贴到行尾。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { G3Checkbox, G3Tree } from '@g3soft/ui'
|
||||
import MenuTreePanel from '@/views/module/menu-management/MenuTreePanel.vue'
|
||||
import type { MenuNode } from '@/views/module/menu-management/utils'
|
||||
|
||||
const TREE = [
|
||||
{ b_id: 'group_base', b_name: '基础资料', b_menu_type: 'directory', b_canuse: 1 },
|
||||
{ b_id: 'base_contact', b_name: '联系人', b_menu_type: 'page', b_canuse: 0 },
|
||||
{ b_id: '-1', b_name: '未保存草稿', b_menu_type: 'page', b_canuse: 1 },
|
||||
] as unknown as MenuNode[]
|
||||
|
||||
function mountPanel(unsavedIds = new Set<string>()) {
|
||||
return mount(MenuTreePanel, {
|
||||
props: { treeData: TREE, unsavedIds },
|
||||
})
|
||||
}
|
||||
|
||||
const rowOf = (wrapper: VueWrapper, label: string) =>
|
||||
wrapper.findAll('[role="treeitem"]').find((row) => row.text().includes(label))
|
||||
|
||||
const checkboxOf = (wrapper: VueWrapper, label: string) =>
|
||||
rowOf(wrapper, label)?.findComponent(G3Checkbox)
|
||||
|
||||
describe('MenuTreePanel 行尾启用勾选', () => {
|
||||
it('每个节点行都带勾选框,勾选态反映 b_canuse', () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
expect(checkboxOf(wrapper, '基础资料')?.props('modelValue')).toBe(true)
|
||||
expect(checkboxOf(wrapper, '联系人')?.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('勾选上报 toggleEnabled(id + 目标启用态)', async () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
checkboxOf(wrapper, '联系人')?.vm.$emit('update:modelValue', true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({
|
||||
id: 'base_contact',
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('取消勾选同样上报(enabled: false)', async () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
checkboxOf(wrapper, '基础资料')?.vm.$emit('update:modelValue', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({
|
||||
id: 'group_base',
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('点击勾选框不触发行的选中(事件不冒泡)', async () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
const row = rowOf(wrapper, '基础资料')!
|
||||
const box = checkboxOf(wrapper, '基础资料')!.find('input')
|
||||
await box.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
// 行自身仍可正常选中,说明拦的是勾选框那一下、不是把整行点击弄坏了
|
||||
await row.trigger('click')
|
||||
expect(wrapper.emitted('select')?.[0]?.[0]).toBe('group_base')
|
||||
})
|
||||
|
||||
it('未保存草稿的勾选框禁用且不上报', async () => {
|
||||
const wrapper = mountPanel(new Set(['-1']))
|
||||
|
||||
const box = checkboxOf(wrapper, '未保存草稿')!
|
||||
expect(box.props('disabled')).toBe(true)
|
||||
|
||||
box.vm.$emit('update:modelValue', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('toggleEnabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('勾选框渲染在名称之后的行尾', () => {
|
||||
const wrapper = mountPanel()
|
||||
const row = rowOf(wrapper, '基础资料')!
|
||||
|
||||
const nameEl = row.find('.mm-tree-node__name')
|
||||
const enableEl = row.find('.mm-tree-node__enable')
|
||||
expect(nameEl.exists()).toBe(true)
|
||||
expect(enableEl.exists()).toBe(true)
|
||||
|
||||
const relation = nameEl.element.compareDocumentPosition(enableEl.element)
|
||||
expect(relation & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('面板必须开 block-node:否则内容层不撑满,勾选框贴不到行尾', () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
// 断言落在 G3Tree 上:BizTreePanel / BizTree 是 inheritAttrs:false,
|
||||
// kebab 的 'block-node' 会留在中间层的 $attrs 里(那些层取不到),
|
||||
// 只有到 G3Tree 才被归一成 blockNode prop。
|
||||
expect(wrapper.findComponent(G3Tree).props('blockNode')).toBe(true)
|
||||
})
|
||||
|
||||
it('面板不启用 G3Tree 内置 checkable(避免行首出现多选勾选框)', () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
expect(wrapper.find('.g3-tree-node__checkbox').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* ModuleTreePanel 行尾启用勾选。
|
||||
*
|
||||
* 勾选框不走 G3Tree 内置的 checkable(那是行首、多选语义),而是画在 #title 插槽的
|
||||
* 行尾。自己画就要自己兜住三件事,也正是这里要锁住的行为:
|
||||
* 1. 勾选上报 toggleEnabled(id + 目标启用态),由父级落库;
|
||||
* 2. 点击不冒泡 —— 不能顺带选中这一行(行上挂了 click 选中 / mousedown 武装拖拽);
|
||||
* 3. 未保存草稿不可勾(库里没有这行,改不了它的 b_canuse)。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { G3Checkbox, G3Tree } from '@g3soft/ui'
|
||||
import ModuleTreePanel from '@/views/module/module-management/ModuleTreePanel.vue'
|
||||
import type { ModuleNode } from '@/views/module/module-management/utils'
|
||||
import BizTreePanel from '@/components/biz-tree/BizTreePanel.vue'
|
||||
|
||||
const TREE = [
|
||||
{
|
||||
b_id: 'm1',
|
||||
b_name: '启用的模块',
|
||||
b_module_type: 'module',
|
||||
b_canuse: 1,
|
||||
},
|
||||
{
|
||||
b_id: 'm2',
|
||||
b_name: '停用的模块',
|
||||
b_module_type: 'module',
|
||||
b_canuse: 0,
|
||||
},
|
||||
{
|
||||
b_id: '-1',
|
||||
b_name: '未保存草稿',
|
||||
b_module_type: 'module',
|
||||
b_canuse: 1,
|
||||
},
|
||||
] as unknown as ModuleNode[]
|
||||
|
||||
function mountPanel(unsavedIds = new Set<string>()) {
|
||||
return mount(ModuleTreePanel, {
|
||||
props: { treeData: TREE, unsavedIds },
|
||||
})
|
||||
}
|
||||
|
||||
/** 按标题取节点行(BizTreePanel 默认插槽由本组件接管,树节点带 role=treeitem) */
|
||||
const rowOf = (wrapper: VueWrapper, label: string) =>
|
||||
wrapper.findAll('[role="treeitem"]').find((row) => row.text().includes(label))
|
||||
|
||||
/** 取某节点行里的勾选框 */
|
||||
const checkboxOf = (wrapper: VueWrapper, label: string) =>
|
||||
rowOf(wrapper, label)?.findComponent(G3Checkbox)
|
||||
|
||||
describe('ModuleTreePanel 行尾启用勾选', () => {
|
||||
it('每个节点行都带一个勾选框,勾选态反映 b_canuse', () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
expect(checkboxOf(wrapper, '启用的模块')?.props('modelValue')).toBe(true)
|
||||
expect(checkboxOf(wrapper, '停用的模块')?.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('勾选上报 toggleEnabled(id + 目标启用态)', async () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
checkboxOf(wrapper, '停用的模块')?.vm.$emit('update:modelValue', true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({ id: 'm2', enabled: true })
|
||||
})
|
||||
|
||||
it('取消勾选同样上报(enabled: false)', async () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
checkboxOf(wrapper, '启用的模块')?.vm.$emit('update:modelValue', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({ id: 'm1', enabled: false })
|
||||
})
|
||||
|
||||
it('点击勾选框不触发行的选中(事件不冒泡)', () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
const row = rowOf(wrapper, '启用的模块')!
|
||||
// 勾选框外层用 @click.stop 拦住冒泡:直接点勾选框本体,行上不应收到 click
|
||||
const box = checkboxOf(wrapper, '启用的模块')!.find('input')
|
||||
void box.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
// 行自身仍可正常选中,说明拦的是勾选框那一下、不是把整行点击弄坏了
|
||||
void row.trigger('click')
|
||||
expect(wrapper.emitted('select')?.[0]?.[0]).toBe('m1')
|
||||
})
|
||||
|
||||
it('未保存草稿的勾选框禁用且不上报(库里还没有这行)', async () => {
|
||||
const wrapper = mountPanel(new Set(['-1']))
|
||||
|
||||
const box = checkboxOf(wrapper, '未保存草稿')!
|
||||
expect(box.props('disabled')).toBe(true)
|
||||
|
||||
box.vm.$emit('update:modelValue', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('toggleEnabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('已保存节点不受草稿集合影响,仍可勾选', async () => {
|
||||
const wrapper = mountPanel(new Set(['-1']))
|
||||
|
||||
expect(checkboxOf(wrapper, '启用的模块')?.props('disabled')).toBe(false)
|
||||
|
||||
checkboxOf(wrapper, '启用的模块')?.vm.$emit('update:modelValue', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('toggleEnabled')?.[0]?.[0]).toEqual({ id: 'm1', enabled: false })
|
||||
})
|
||||
|
||||
it('勾选框渲染在名称之后的行尾(不是行首的多选位)', () => {
|
||||
const wrapper = mountPanel()
|
||||
const row = rowOf(wrapper, '启用的模块')!
|
||||
|
||||
// 行内文本顺序:名称在前;勾选框的 DOM 位置在名称节点之后
|
||||
const nameEl = row.find('.mm-tree-node__name')
|
||||
const enableEl = row.find('.mm-tree-node__enable')
|
||||
expect(nameEl.exists()).toBe(true)
|
||||
expect(enableEl.exists()).toBe(true)
|
||||
|
||||
// compareDocumentPosition: 4 = 名称之后(DOCUMENT_POSITION_FOLLOWING)
|
||||
const relation = nameEl.element.compareDocumentPosition(enableEl.element)
|
||||
expect(relation & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('面板必须开 block-node:否则内容层不撑满,勾选框贴不到行尾', () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
// G3Tree 的节点内容层默认 inline-flex 且无 flex:1(宽度=文字宽度),
|
||||
// 只有 blockNode 会给它 flex:1。少了它 .mm-tree-node 的 flex:1 只撑到
|
||||
// 「文字那一小段」,勾选框会紧跟名称而不是贴右边 —— 这条断言把这个前提钉住。
|
||||
//
|
||||
// 断言必须落在 G3Tree 上:BizTreePanel / BizTree 都是 inheritAttrs:false,
|
||||
// 模板写的 'block-node'(kebab)会原样留在 BizTree 的 $attrs 里,
|
||||
// 所以 BizTree.props('blockNode') 是 undefined —— 但往下传给 G3Tree 时
|
||||
// Vue 会把 kebab attr 归一到 camelCase prop,G3Tree 那边是 true。
|
||||
// 在中间层断言会得到假阴性(这条一开始就是这么写错的)。
|
||||
expect(wrapper.findComponent(G3Tree).props('blockNode')).toBe(true)
|
||||
})
|
||||
|
||||
it('block-node 生效:内容层带 is-block(flex:1),勾选才有行尾可贴', () => {
|
||||
const wrapper = mountPanel()
|
||||
const row = rowOf(wrapper, '启用的模块')!
|
||||
|
||||
// 库内样式:.g3-tree-node__content 只有 .is-block 才 flex:1
|
||||
expect(row.find('.g3-tree-node__content').classes()).toContain('is-block')
|
||||
})
|
||||
|
||||
it('面板不启用 G3Tree 内置 checkable(避免行首出现多选勾选框)', () => {
|
||||
const wrapper = mountPanel()
|
||||
const panel = wrapper.findComponent(BizTreePanel)
|
||||
// checkable 未透传:内置勾选框列不渲染
|
||||
expect(panel.attributes('checkable')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user