Files
workspace/code/g3soft-erp/web/tests/components/biz-tree/BizTreePanel.spec.ts
T
2026-10-17 17:17:39 +08:00

286 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BizTreePanel:面板骨架的透传约定 + 业务节点行(名称 / 停用变灰 / 未保存圆点 / 行尾启用勾选)。
*
* 透传用例用渲染函数而非 props 传参,是为了还原模板编译后的真实形态:
* 页面写的是 `:selected-keys`(kebab),编译出来 vnode.props 的键名就是 'selected-keys',
* 而不是 'selectedKeys'。用 props 传 camelCase 会绕过 Vue 的键名归一化,测不出问题。
*
* 业务节点行的用例原属 MenuTreePanel.spec / ModuleTreePanel.spec —— 那两个薄封装已并入
* 本面板(它们曾各写一份 93% 相同的副本),行为约束随之一并搬到这里。
*/
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 = [
{
b_id: 'c1',
b_name: '分类一',
children: [{ b_id: 'd1', b_name: '数据一' }],
},
{ b_id: 'd2', b_name: '数据二' },
]
const renderPanel =
(extraProps: Record<string, unknown> = {}) =>
() =>
h(BizTreePanel, { treeData: TREE, showRoot: true, ...extraProps })
/** 按标题取节点行(children 渲染在行外的兄弟容器里,行自身的 text 只含本节点标题) */
const rowOf = (wrapper: VueWrapper, label: string) =>
wrapper.findAll('[role="treeitem"]').find((row) => row.text().includes(label))
describe('BizTreePanel 选中态(selected-keys 透传)', () => {
it('kebab 写的 selected-keys 能选中业务节点,虚拟根让位', () => {
const wrapper = mount({ render: renderPanel({ 'selected-keys': ['d2'] }) })
const root = wrapper.findAll('[role="treeitem"]')[0]
expect(root.text()).toContain('全部分类')
expect(root.attributes('aria-selected')).toBe('false')
expect(rowOf(wrapper, '数据二')?.attributes('aria-selected')).toBe('true')
})
it('分类节点同样按 selected-keys 高亮', () => {
const wrapper = mount({ render: renderPanel({ 'selected-keys': ['c1'] }) })
expect(rowOf(wrapper, '分类一')?.attributes('aria-selected')).toBe('true')
expect(wrapper.findAll('[role="treeitem"]')[0].attributes('aria-selected')).toBe('false')
})
it('selected-keys 为空时由虚拟根呈现选中态', () => {
const wrapper = mount({ render: renderPanel({ 'selected-keys': [] }) })
expect(wrapper.findAll('[role="treeitem"]')[0].attributes('aria-selected')).toBe('true')
})
})
describe('BizTreePanel select 归一化', () => {
it('点击节点抛出单个 id(不是 keys 数组)', async () => {
const selected: string[] = []
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('找菜单')
})
})