This commit is contained in:
oneao committed 2026-10-17 17:17:39 +08:00
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('找菜单')
})
})