286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
/**
|
||
* 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('找菜单')
|
||
})
|
||
})
|