/** * 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 = {}) => () => 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 = {}) => 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 | 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('找菜单') }) })