From 443c76871ebf3514991c8061dd483043cf8c2455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=20Cai?= Date: Tue, 13 Oct 2026 00:10:54 +0800 Subject: [PATCH 1/3] fix(tree): ignore disabled children in parent checking --- packages/common | 2 +- .../tree/__tests__/disabled-checking.test.tsx | 215 ++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 packages/components/tree/__tests__/disabled-checking.test.tsx diff --git a/packages/common b/packages/common index b8c4417066..d4f2e934bc 160000 --- a/packages/common +++ b/packages/common @@ -1 +1 @@ -Subproject commit b8c44170662a28b9f8c1d11cdc48875899fc8e0f +Subproject commit d4f2e934bc5ff97e4198cc9ddeadbbc4b75010a8 diff --git a/packages/components/tree/__tests__/disabled-checking.test.tsx b/packages/components/tree/__tests__/disabled-checking.test.tsx new file mode 100644 index 0000000000..11901de76d --- /dev/null +++ b/packages/components/tree/__tests__/disabled-checking.test.tsx @@ -0,0 +1,215 @@ +import React from 'react'; +import { fireEvent, render, vi, waitFor } from '@test/utils'; + +import Tree from '../Tree'; + +import type { TreeProps } from '../Tree'; + +describe('Tree checking with disabled children', () => { + const data = [ + { + value: 'parent', + label: 'Parent', + children: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + { value: 'disabled', label: 'Disabled', disabled: true }, + ], + }, + ]; + + async function setup(props: Partial = {}) { + const onChange = vi.fn(); + const result = render(); + await waitFor(() => expect(result.getAllByRole('checkbox')).toHaveLength(4)); + return { + ...result, + onChange, + inputs: result.getAllByRole('checkbox') as HTMLInputElement[], + }; + } + + const modes = [ + ['onlyLeaf', ['a', 'b']], + ['all', ['parent', 'a', 'b']], + ['parentFirst', ['parent']], + ] as const; + + modes.forEach(([valueMode, expected]) => { + it(`round-trips controlled values in ${valueMode} mode without checking disabled children`, async () => { + const onChange = vi.fn(); + function ControlledTree() { + const [value, setValue] = React.useState([]); + return ( + { + onChange(nextValue, context); + setValue(nextValue); + }} + /> + ); + } + const { getAllByRole } = render(); + await waitFor(() => expect(getAllByRole('checkbox')).toHaveLength(4)); + const inputs = getAllByRole('checkbox'); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(expected); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[3]).not.toBeChecked(); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual([]); + await waitFor(() => expect(inputs[0]).not.toBeChecked()); + }); + + it(`checks and unchecks the parent in ${valueMode} mode`, async () => { + const { inputs, onChange } = await setup({ valueMode }); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(expected); + expect(onChange.mock.lastCall[1].node.value).toBe('parent'); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[1]).toBeChecked(); + expect(inputs[2]).toBeChecked(); + expect(inputs[3]).not.toBeChecked(); + expect(inputs[3]).toBeDisabled(); + + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual([]); + await waitFor(() => expect(inputs[0]).not.toBeChecked()); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[1]).not.toBeChecked(); + expect(inputs[2]).not.toBeChecked(); + }); + + it(`derives full and partial selection from enabled children in ${valueMode} mode`, async () => { + const { inputs, onChange } = await setup({ valueMode }); + fireEvent.click(inputs[1]); + expect(onChange.mock.lastCall[0]).toEqual(['a']); + await waitFor(() => expect(inputs[0].closest('label')).toHaveClass('t-is-indeterminate')); + + fireEvent.click(inputs[2]); + expect(onChange.mock.lastCall[0]).toEqual(expected); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + + fireEvent.click(inputs[1]); + expect(onChange.mock.lastCall[0]).toEqual(['b']); + await waitFor(() => expect(inputs[0].closest('label')).toHaveClass('t-is-indeterminate')); + expect(inputs[0]).not.toBeChecked(); + }); + + it(`does not automatically check a parent with only disabled children in ${valueMode} mode`, async () => { + const { inputs, onChange } = await setup({ + valueMode, + data: [ + { + ...data[0], + children: data[0].children.map((child) => ({ + ...child, + disabled: true, + })), + }, + ], + }); + expect(inputs[0]).not.toBeChecked(); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual([]); + expect(inputs[0]).not.toBeChecked(); + }); + }); + + it.each(['all', 'parentFirst'] as const)('preserves a selected disabled child in %s mode', async (valueMode) => { + const { inputs, onChange } = await setup({ + valueMode, + defaultValue: ['disabled'], + }); + expect(inputs[0]).not.toBeChecked(); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[3]).toBeChecked(); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual( + valueMode === 'all' ? ['parent', 'a', 'b', 'disabled'] : ['parent', 'disabled'], + ); + await waitFor(() => expect(inputs[0]).toBeChecked()); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(['disabled']); + await waitFor(() => expect(inputs[0]).not.toBeChecked()); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[3]).toBeChecked(); + }); + + it('excludes disabled branches and their descendants from parent selection', async () => { + const onChange = vi.fn(); + const { getAllByRole } = render( + , + ); + await waitFor(() => expect(getAllByRole('checkbox')).toHaveLength(4)); + const inputs = getAllByRole('checkbox'); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(['root', 'enabled']); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[2]).not.toBeChecked(); + expect(inputs[3]).not.toBeChecked(); + expect(inputs[2]).toBeDisabled(); + expect(inputs[3]).toBeDisabled(); + }); + + it('propagates full selection through nested enabled branches', async () => { + const onChange = vi.fn(); + const { getAllByRole } = render( + , + ); + await waitFor(() => expect(getAllByRole('checkbox')).toHaveLength(5)); + const inputs = getAllByRole('checkbox') as HTMLInputElement[]; + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(['root', 'parent', 'a', 'b']); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[1]).toBeChecked(); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[1].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[4]).not.toBeChecked(); + }); + + it('keeps strict checking independent of child selection', async () => { + const { inputs, onChange } = await setup({ checkStrictly: true }); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(['parent']); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[1]).not.toBeChecked(); + expect(inputs[2]).not.toBeChecked(); + expect(inputs[3]).not.toBeChecked(); + }); +}); From e15cd3021519f92d3c0d422289671ecf7e776851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=20Cai?= Date: Tue, 13 Oct 2026 00:49:16 +0800 Subject: [PATCH 2/3] chore(tree): update shared fix for Vue tree model --- packages/common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common b/packages/common index d4f2e934bc..2d5d75f737 160000 --- a/packages/common +++ b/packages/common @@ -1 +1 @@ -Subproject commit d4f2e934bc5ff97e4198cc9ddeadbbc4b75010a8 +Subproject commit 2d5d75f7373854b8590f9e1ef2e5fe8467350f9c From 5223edf7d5935e0a4665add35a0a3ed28da2e698 Mon Sep 17 00:00:00 2001 From: Rylan Date: Mon, 19 Oct 2026 11:30:41 +0800 Subject: [PATCH 3/3] chore: update test format --- .../skills/tdesign-test-component/SKILL.md | 16 +- .../tree/__tests__/disabled-checking.test.tsx | 215 ------- .../components/tree/__tests__/tree.test.tsx | 573 ++++++++++++------ 3 files changed, 395 insertions(+), 409 deletions(-) delete mode 100644 packages/components/tree/__tests__/disabled-checking.test.tsx diff --git a/.codebuddy/skills/tdesign-test-component/SKILL.md b/.codebuddy/skills/tdesign-test-component/SKILL.md index 782f1711c0..ae30560d70 100644 --- a/.codebuddy/skills/tdesign-test-component/SKILL.md +++ b/.codebuddy/skills/tdesign-test-component/SKILL.md @@ -51,7 +51,7 @@ packages/components/[component-name]/ ### 文件内容和结构 -- 固定顶层结构:每个组件的测试文件仅允许以下四种顶层 describe:props、events、instanceFunctions、scenarios。无相关测试内容时可省略对应分组。 +- 固定顶层结构:每个组件的测试文件仅允许以下四种顶层 describe:`props`、`events`、`instanceFunctions`、`scenarios`。相对简单的单 prop 或多 prop 组合渲染测试归入 `props`,仅验证回调是否触发及其参数的测试则归入 `events`,若标题必须补充复杂目的或场景描述时才能表达,应将用例移入 scenarios。无相关测试内容时可省略对应分组。 - 用例较少时:在对应的顶层 describe 中编写 `test(...)`,不额外嵌套。 @@ -62,12 +62,11 @@ packages/components/[component-name]/ ```tsx describe("ComponentName", () => { describe("props", () => { - // 1. 按需使用 `propName[type]`(如 TNode、function、number)或 `propName[enumValue]` - // 2. 方括号仅允许具体类型或合法枚举值,禁止写具体数值。数组类型统一写成 `array`,禁止使用 `string[]` 等; - // 布尔属性按常规方式开启(传入 `true` 或使用简写)时直接写属性名;仅显式验证关闭时写 `[false]`;同一用例覆盖两种取值时才写 `[boolean]` - // 3. 同一属性的多个枚举值若测试结构与断言一致,应提取取值列表并使用 `forEach` 生成独立的 `test('propName[enumValue]')`,避免重复编写同构用例 - // 4. 同一行为涉及多个边界值或组合条件,且无需通过标题分别表达时,合并进单个 `test('propName')`,具体阈值与数据留在用例内 - // 5. 若标题必须补充复杂目的或场景描述时才能表达,应将用例移入 scenarios + // 1. 优先合并同构用例。同一属性、同一行为下的多种类型、边界值及组合条件,应优先合并为一个 `test('propName[type]')`,并在用例内部处理具体取值。对于测试结构和断言一致的多个枚举值,应提取取值列表,通过 `forEach` 生成独立的 `test('propName[enumValue]')`,避免重复编写同构用例。 + + // 2.按需拆分用例。当用例无法合理合并时,再根据测试对象拆分,并使用 `propName[type]`(如 `TNode`、`function`、`number`)标识对应的类型。 + + // 3. 规范用例标题。方括号内仅允许填写具体类型或合法枚举值,不得填写具体数值。数组类型统一使用 `array` 格式,不得使用 `string[]` 等形式。布尔属性常规开启时(传入 `true` 或使用简写),标题直接使用属性名;仅显式验证关闭行为时,使用 `propName[false]`;只有同一用例同时覆盖开启和关闭两种取值时,才使用 `propName[boolean]`。 }); describe("events", () => { // 回调 prop(onChange 等)是否触发 @@ -77,8 +76,11 @@ describe("ComponentName", () => { }); describe("scenarios", () => { // 1. 覆盖多 API 联动的完整业务流;复杂时可嵌套子 `describe` + // 2. 标题应明确涉及的 API 与测试目的,优先使用自然语言,仅在枚举值是关键条件时保留 `[enumValue]` + // 3. 单 prop、多 prop 组合的简单渲染测试归入 `props`,不应放在此模块 + // 4. 仅验证回调是否触发及参数的测试归入 `events` }); ``` diff --git a/packages/components/tree/__tests__/disabled-checking.test.tsx b/packages/components/tree/__tests__/disabled-checking.test.tsx deleted file mode 100644 index 11901de76d..0000000000 --- a/packages/components/tree/__tests__/disabled-checking.test.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import React from 'react'; -import { fireEvent, render, vi, waitFor } from '@test/utils'; - -import Tree from '../Tree'; - -import type { TreeProps } from '../Tree'; - -describe('Tree checking with disabled children', () => { - const data = [ - { - value: 'parent', - label: 'Parent', - children: [ - { value: 'a', label: 'A' }, - { value: 'b', label: 'B' }, - { value: 'disabled', label: 'Disabled', disabled: true }, - ], - }, - ]; - - async function setup(props: Partial = {}) { - const onChange = vi.fn(); - const result = render(); - await waitFor(() => expect(result.getAllByRole('checkbox')).toHaveLength(4)); - return { - ...result, - onChange, - inputs: result.getAllByRole('checkbox') as HTMLInputElement[], - }; - } - - const modes = [ - ['onlyLeaf', ['a', 'b']], - ['all', ['parent', 'a', 'b']], - ['parentFirst', ['parent']], - ] as const; - - modes.forEach(([valueMode, expected]) => { - it(`round-trips controlled values in ${valueMode} mode without checking disabled children`, async () => { - const onChange = vi.fn(); - function ControlledTree() { - const [value, setValue] = React.useState([]); - return ( - { - onChange(nextValue, context); - setValue(nextValue); - }} - /> - ); - } - const { getAllByRole } = render(); - await waitFor(() => expect(getAllByRole('checkbox')).toHaveLength(4)); - const inputs = getAllByRole('checkbox'); - fireEvent.click(inputs[0]); - expect(onChange.mock.lastCall[0]).toEqual(expected); - await waitFor(() => expect(inputs[0]).toBeChecked()); - expect(inputs[3]).not.toBeChecked(); - fireEvent.click(inputs[0]); - expect(onChange.mock.lastCall[0]).toEqual([]); - await waitFor(() => expect(inputs[0]).not.toBeChecked()); - }); - - it(`checks and unchecks the parent in ${valueMode} mode`, async () => { - const { inputs, onChange } = await setup({ valueMode }); - fireEvent.click(inputs[0]); - expect(onChange.mock.lastCall[0]).toEqual(expected); - expect(onChange.mock.lastCall[1].node.value).toBe('parent'); - await waitFor(() => expect(inputs[0]).toBeChecked()); - expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); - expect(inputs[1]).toBeChecked(); - expect(inputs[2]).toBeChecked(); - expect(inputs[3]).not.toBeChecked(); - expect(inputs[3]).toBeDisabled(); - - fireEvent.click(inputs[0]); - expect(onChange.mock.lastCall[0]).toEqual([]); - await waitFor(() => expect(inputs[0]).not.toBeChecked()); - expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); - expect(inputs[1]).not.toBeChecked(); - expect(inputs[2]).not.toBeChecked(); - }); - - it(`derives full and partial selection from enabled children in ${valueMode} mode`, async () => { - const { inputs, onChange } = await setup({ valueMode }); - fireEvent.click(inputs[1]); - expect(onChange.mock.lastCall[0]).toEqual(['a']); - await waitFor(() => expect(inputs[0].closest('label')).toHaveClass('t-is-indeterminate')); - - fireEvent.click(inputs[2]); - expect(onChange.mock.lastCall[0]).toEqual(expected); - await waitFor(() => expect(inputs[0]).toBeChecked()); - expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); - - fireEvent.click(inputs[1]); - expect(onChange.mock.lastCall[0]).toEqual(['b']); - await waitFor(() => expect(inputs[0].closest('label')).toHaveClass('t-is-indeterminate')); - expect(inputs[0]).not.toBeChecked(); - }); - - it(`does not automatically check a parent with only disabled children in ${valueMode} mode`, async () => { - const { inputs, onChange } = await setup({ - valueMode, - data: [ - { - ...data[0], - children: data[0].children.map((child) => ({ - ...child, - disabled: true, - })), - }, - ], - }); - expect(inputs[0]).not.toBeChecked(); - expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); - fireEvent.click(inputs[0]); - expect(onChange.mock.lastCall[0]).toEqual([]); - expect(inputs[0]).not.toBeChecked(); - }); - }); - - it.each(['all', 'parentFirst'] as const)('preserves a selected disabled child in %s mode', async (valueMode) => { - const { inputs, onChange } = await setup({ - valueMode, - defaultValue: ['disabled'], - }); - expect(inputs[0]).not.toBeChecked(); - expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); - expect(inputs[3]).toBeChecked(); - fireEvent.click(inputs[0]); - expect(onChange.mock.lastCall[0]).toEqual( - valueMode === 'all' ? ['parent', 'a', 'b', 'disabled'] : ['parent', 'disabled'], - ); - await waitFor(() => expect(inputs[0]).toBeChecked()); - fireEvent.click(inputs[0]); - expect(onChange.mock.lastCall[0]).toEqual(['disabled']); - await waitFor(() => expect(inputs[0]).not.toBeChecked()); - expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); - expect(inputs[3]).toBeChecked(); - }); - - it('excludes disabled branches and their descendants from parent selection', async () => { - const onChange = vi.fn(); - const { getAllByRole } = render( - , - ); - await waitFor(() => expect(getAllByRole('checkbox')).toHaveLength(4)); - const inputs = getAllByRole('checkbox'); - fireEvent.click(inputs[0]); - expect(onChange.mock.lastCall[0]).toEqual(['root', 'enabled']); - await waitFor(() => expect(inputs[0]).toBeChecked()); - expect(inputs[2]).not.toBeChecked(); - expect(inputs[3]).not.toBeChecked(); - expect(inputs[2]).toBeDisabled(); - expect(inputs[3]).toBeDisabled(); - }); - - it('propagates full selection through nested enabled branches', async () => { - const onChange = vi.fn(); - const { getAllByRole } = render( - , - ); - await waitFor(() => expect(getAllByRole('checkbox')).toHaveLength(5)); - const inputs = getAllByRole('checkbox') as HTMLInputElement[]; - fireEvent.click(inputs[0]); - expect(onChange.mock.lastCall[0]).toEqual(['root', 'parent', 'a', 'b']); - await waitFor(() => expect(inputs[0]).toBeChecked()); - expect(inputs[1]).toBeChecked(); - expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); - expect(inputs[1].closest('label')).not.toHaveClass('t-is-indeterminate'); - expect(inputs[4]).not.toBeChecked(); - }); - - it('keeps strict checking independent of child selection', async () => { - const { inputs, onChange } = await setup({ checkStrictly: true }); - fireEvent.click(inputs[0]); - expect(onChange.mock.lastCall[0]).toEqual(['parent']); - await waitFor(() => expect(inputs[0]).toBeChecked()); - expect(inputs[1]).not.toBeChecked(); - expect(inputs[2]).not.toBeChecked(); - expect(inputs[3]).not.toBeChecked(); - }); -}); diff --git a/packages/components/tree/__tests__/tree.test.tsx b/packages/components/tree/__tests__/tree.test.tsx index 6e0db24acf..7d5b2d2c44 100644 --- a/packages/components/tree/__tests__/tree.test.tsx +++ b/packages/components/tree/__tests__/tree.test.tsx @@ -1,12 +1,11 @@ import React from 'react'; -import { fireEvent, mockDelay, mockTimeout, render, vi } from '@test/utils'; +import { fireEvent, mockDelay, mockTimeout, render, vi, waitFor } from '@test/utils'; import Button from '../../button'; import Tree from '../Tree'; import type { TdTreeProps, TreeInstanceFunctions } from '../type'; -// TODO describe('Tree', () => { // label 类型定义有问题 const items = [ @@ -48,6 +47,66 @@ describe('Tree', () => { return render(); } + const lineData = [ + { + label: '第 1 段', + value: 1, + children: [ + { + label: '第 1-1 段', + value: '1-1', + children: [{ label: '第 1-1-1 段', value: '1-1-1' }], + }, + ], + }, + { + label: '第 2 段', + value: 2, + children: [ + { + label: '第 2-1 段', + value: '2-1', + children: [{ label: '第 2-1-1 段', value: '2-1-1' }], + }, + ], + }, + ]; + + const instanceData = [ + { + label: 't-1', + value: 1, + children: [ + { + label: 't-1-1', + value: '1-1', + }, + { + label: 't-1-2', + value: '1-2', + children: [ + { + label: 't-1-2-1', + value: '1-2-1', + }, + { + label: 't-1-2-2', + value: '1-2-2', + }, + { + label: 't-1-2-3', + value: '1-2-3', + }, + ], + }, + ], + }, + { + label: 't-2', + value: 2, + }, + ]; + describe('props', () => { test('expanded[array]', async () => { const { container } = await renderTreeWithProps({ @@ -115,7 +174,7 @@ describe('Tree', () => { expect(onActiveFn1.mock.calls[0][0]).toEqual([2]); }); - test('disableCheck and disableCheck[function]', async () => { + test('disableCheck', async () => { const onChangeFn1 = vi.fn(); const { container } = getTreeDefaultMount( Tree, @@ -185,14 +244,19 @@ describe('Tree', () => { }); test('empty[string]', async () => { - const { container } = render(); + const { container } = render(); await mockTimeout(() => { - console.log('customize empty prop', container.querySelector('.t-tree')); - expect(container.querySelector('.t-tree')).toHaveTextContent('空数据(string)'); + expect(container.querySelector('.t-tree')).toHaveTextContent('暂无数据'); + }); + + const { container: stringContainer } = render(); + await mockTimeout(() => { + console.log('customize empty prop', stringContainer.querySelector('.t-tree')); + expect(stringContainer.querySelector('.t-tree')).toHaveTextContent('空数据(string)'); }); }); - test('operations[function]', async () => { + test('operations[TNode/function]', async () => { const fn1 = vi.fn(); const renderOperations = (node) => ( <> @@ -206,50 +270,22 @@ describe('Tree', () => { }); await mockDelay(300); expect(container.querySelector('.t-tree__operations')).not.toBeNull(); - }); - test('operations[TNode]', async () => { - // operations 类型定义有些问题 - const renderOperations: any = ( + const renderOperationsNode: any = ( <> ); - const { container } = await renderTreeWithProps({ - operations: renderOperations, + const { container: nodeContainer } = await renderTreeWithProps({ + operations: renderOperationsNode, }); await mockDelay(300); - expect(container.querySelector('.t-tree__operations')).not.toBeNull(); + expect(nodeContainer.querySelector('.t-tree__operations')).not.toBeNull(); }); - test('line, line[TNode] and line[function]', async () => { - const lineData = [ - { - label: '第 1 段', - value: 1, - children: [ - { - label: '第 1-1 段', - value: '1-1', - children: [{ label: '第 1-1-1 段', value: '1-1-1' }], - }, - ], - }, - { - label: '第 2 段', - value: 2, - children: [ - { - label: '第 2-1 段', - value: '2-1', - children: [{ label: '第 2-1-1 段', value: '2-1-1' }], - }, - ], - }, - ]; - + test('line[TNode/function]', async () => { const { container: booleanLineContainer } = getTreeDefaultMount(Tree, { expandAll: true, line: true, @@ -275,7 +311,7 @@ describe('Tree', () => { expect(fnLineContainer.querySelectorAll('.custom-line').length).toBe(6); }); - test('icon[false], icon[TNode] and icon[function]', async () => { + test('icon[false/TNode/function]', async () => { const { container: falseIconContainer } = getTreeDefaultMount(Tree, { icon: false, }); @@ -296,6 +332,103 @@ describe('Tree', () => { await mockDelay(300); expect(fnIconContainer.querySelectorAll('.t-tree__icon').length).toBe(2); }); + + test('checkable', async () => { + const data = [ + { + label: 't-1', + value: 1, + children: [ + { + label: 't-1-1', + value: '1-1', + }, + ], + }, + ]; + const { container } = await renderTreeWithProps({ + data, + checkable: true, + }); + expect(container.querySelector('.t-tree')).not.toBeNull(); + // show node list + await mockTimeout(() => { + expect(container.querySelector('.t-tree__list').children.length).not.toBe(0); + }, 100); + }); + + test('label[function]', async () => { + const data = [ + { + label: '第 1 段', + value: 1, + children: [ + { + label: '第 1-1 段', + value: '1-1', + }, + ], + }, + { + label: '第 2 段', + value: 2, + }, + ]; + const { container } = await renderTreeWithProps({ + data, + label: ({ data, setData }) => + data.isEditing ? ( + { + console.log('blur', { ...data, isEditing: false }); + setData({ ...data, label: e.target.value, isEditing: false }); + }} + onKeyDown={(e) => { + console.log('keydown', e.key); + if (e.key === 'Enter') { + setData({ + ...data, + label: e.currentTarget.value, + isEditing: false, + }); + console.log('Enter setData({ ...data, name: e.target.value, isEditing: false })'); + } + if (e.key === 'Escape') { + setData({ ...data, isEditing: false }); + console.log('ESC setData({ ...data, isEditing: false })'); + } + }} + /> + ) : ( + { + e.stopPropagation(); + setData({ ...data, isEditing: true }); + }} + > + {data.label} + + ), + }); + await mockDelay(300); + expect(container.querySelector('.tree-item-span')).not.toBeNull(); + fireEvent.dblClick(container.querySelector('.tree-item-span')); + await mockDelay(300); + expect(container.querySelector('.tree-item-input')).not.toBeNull(); + fireEvent.change(container.querySelector('.tree-item-input'), { + target: { value: '123' }, + }); + expect((container.querySelector('.tree-item-input') as HTMLInputElement).value).toBe('123'); + (container.querySelector('.tree-item-input') as HTMLInputElement).focus(); + (container.querySelector('.tree-item-input') as HTMLInputElement).blur(); + await mockDelay(300); + expect(container.querySelector('.tree-item-input')).toBeNull(); + expect(container.querySelector('.tree-item-span')).not.toBeNull(); + expect(container.querySelector('.tree-item-span').textContent).toBe('123'); + }); }); describe('events', () => { @@ -347,41 +480,10 @@ describe('Tree', () => { describe('instanceFunctions', () => { test('query and mutation methods', async () => { - const data = [ - { - label: 't-1', - value: 1, - children: [ - { - label: 't-1-1', - value: '1-1', - }, - { - label: 't-1-2', - value: '1-2', - children: [ - { - label: 't-1-2-1', - value: '1-2-1', - }, - { - label: 't-1-2-2', - value: '1-2-2', - }, - { - label: 't-1-2-3', - value: '1-2-3', - }, - ], - }, - ], - }, - { - label: 't-2', - value: 2, - }, - ]; - const { ref } = await renderTreeWithProps({ data, checkable: true }); + const { ref } = await renderTreeWithProps({ + data: instanceData, + checkable: true, + }); expect(ref.current.getIndex(1)).toBe(0); const node = ref.current.getItem(1); @@ -418,39 +520,219 @@ describe('Tree', () => { }); }); - describe('props', () => { - test('data[array]', async () => { - const { container } = render(); - await mockTimeout(() => { - expect(container.querySelector('.t-tree')).toHaveTextContent('暂无数据'); + describe('scenarios', () => { + const data = [ + { + value: 'parent', + label: 'Parent', + children: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + { value: 'disabled', label: 'Disabled', disabled: true }, + ], + }, + ]; + + const valueModes = [ + ['onlyLeaf', ['a', 'b']], + ['all', ['parent', 'a', 'b']], + ['parentFirst', ['parent']], + ] as const; + + async function setup(props: Partial = {}) { + const onChange = vi.fn(); + const result = render(); + await waitFor(() => expect(result.getAllByRole('checkbox')).toHaveLength(4)); + return { + ...result, + onChange, + inputs: result.getAllByRole('checkbox') as HTMLInputElement[], + }; + } + + describe('disabled children', () => { + valueModes.forEach(([valueMode, expected]) => { + test(`valueMode[${valueMode}] round-trips controlled values without checking disabled children`, async () => { + const onChange = vi.fn(); + function ControlledTree() { + const [value, setValue] = React.useState([]); + return ( + { + onChange(nextValue, context); + setValue(nextValue); + }} + /> + ); + } + const { getAllByRole } = render(); + await waitFor(() => expect(getAllByRole('checkbox')).toHaveLength(4)); + const inputs = getAllByRole('checkbox'); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(expected); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[3]).not.toBeChecked(); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual([]); + await waitFor(() => expect(inputs[0]).not.toBeChecked()); + }); + + test(`valueMode[${valueMode}] checks and unchecks the parent without selecting disabled children`, async () => { + const { inputs, onChange } = await setup({ valueMode }); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(expected); + expect(onChange.mock.lastCall[1].node.value).toBe('parent'); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[1]).toBeChecked(); + expect(inputs[2]).toBeChecked(); + expect(inputs[3]).not.toBeChecked(); + expect(inputs[3]).toBeDisabled(); + + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual([]); + await waitFor(() => expect(inputs[0]).not.toBeChecked()); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[1]).not.toBeChecked(); + expect(inputs[2]).not.toBeChecked(); + }); + + test(`valueMode[${valueMode}] derives full and partial selection from enabled children`, async () => { + const { inputs, onChange } = await setup({ valueMode }); + fireEvent.click(inputs[1]); + expect(onChange.mock.lastCall[0]).toEqual(['a']); + await waitFor(() => expect(inputs[0].closest('label')).toHaveClass('t-is-indeterminate')); + + fireEvent.click(inputs[2]); + expect(onChange.mock.lastCall[0]).toEqual(expected); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + + fireEvent.click(inputs[1]); + expect(onChange.mock.lastCall[0]).toEqual(['b']); + await waitFor(() => expect(inputs[0].closest('label')).toHaveClass('t-is-indeterminate')); + expect(inputs[0]).not.toBeChecked(); + }); + + test(`valueMode[${valueMode}] does not check a parent with only disabled children`, async () => { + const { inputs, onChange } = await setup({ + valueMode, + data: [ + { + ...data[0], + children: data[0].children.map((child) => ({ + ...child, + disabled: true, + })), + }, + ], + }); + expect(inputs[0]).not.toBeChecked(); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual([]); + expect(inputs[0]).not.toBeChecked(); + }); }); - }); - test('checkable', async () => { - const data = [ - { - label: 't-1', - value: 1, - children: [ - { - label: 't-1-1', - value: '1-1', - }, - ], - }, - ]; - const { container } = await renderTreeWithProps({ - data, - checkable: true, + (['all', 'parentFirst'] as const).forEach((valueMode) => { + test(`valueMode[${valueMode}] preserves a selected disabled child`, async () => { + const { inputs, onChange } = await setup({ + valueMode, + defaultValue: ['disabled'], + }); + expect(inputs[0]).not.toBeChecked(); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[3]).toBeChecked(); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual( + valueMode === 'all' ? ['parent', 'a', 'b', 'disabled'] : ['parent', 'disabled'], + ); + await waitFor(() => expect(inputs[0]).toBeChecked()); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(['disabled']); + await waitFor(() => expect(inputs[0]).not.toBeChecked()); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[3]).toBeChecked(); + }); + }); + + test('valueMode[all] excludes disabled branches and their descendants from parent selection', async () => { + const onChange = vi.fn(); + const { getAllByRole } = render( + , + ); + await waitFor(() => expect(getAllByRole('checkbox')).toHaveLength(4)); + const inputs = getAllByRole('checkbox'); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(['root', 'enabled']); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[2]).not.toBeChecked(); + expect(inputs[3]).not.toBeChecked(); + expect(inputs[2]).toBeDisabled(); + expect(inputs[3]).toBeDisabled(); + }); + + test('valueMode[all] propagates full selection through nested enabled branches', async () => { + const onChange = vi.fn(); + const { getAllByRole } = render( + , + ); + await waitFor(() => expect(getAllByRole('checkbox')).toHaveLength(5)); + const inputs = getAllByRole('checkbox') as HTMLInputElement[]; + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(['root', 'parent', 'a', 'b']); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[1]).toBeChecked(); + expect(inputs[0].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[1].closest('label')).not.toHaveClass('t-is-indeterminate'); + expect(inputs[4]).not.toBeChecked(); + }); + + test('checkStrictly keeps parent checking independent of child selection', async () => { + const { inputs, onChange } = await setup({ checkStrictly: true }); + fireEvent.click(inputs[0]); + expect(onChange.mock.lastCall[0]).toEqual(['parent']); + await waitFor(() => expect(inputs[0]).toBeChecked()); + expect(inputs[1]).not.toBeChecked(); + expect(inputs[2]).not.toBeChecked(); + expect(inputs[3]).not.toBeChecked(); }); - expect(container.querySelector('.t-tree')).not.toBeNull(); - // show node list - await mockTimeout(() => { - expect(container.querySelector('.t-tree__list').children.length).not.toBe(0); - }, 100); }); - test('operations[Error]', async () => { + test('operations throws when given an invalid type', async () => { const renderOperations: any = new Error('error operations'); try { await renderTreeWithProps({ operations: renderOperations }); @@ -458,88 +740,5 @@ describe('Tree', () => { expect(err).toEqual(new Error('invalid type of operations')); } }); - - // TODO: icon error can't be caught by vitest - // it('test render Tree icon error', async () => { - // const icon: any = new Error('error icon'); - // try { - // renderTreeWithProps({ icon }); - // } catch (err) { - // expect(err).toEqual(new Error('invalid type of icon')); - // } - // }); - - test('label[function]', async () => { - const data = [ - { - label: '第 1 段', - value: 1, - children: [ - { - label: '第 1-1 段', - value: '1-1', - }, - ], - }, - { - label: '第 2 段', - value: 2, - }, - ]; - const { container } = await renderTreeWithProps({ - data, - label: ({ data, setData }) => - data.isEditing ? ( - { - console.log('blur', { ...data, isEditing: false }); - setData({ ...data, label: e.target.value, isEditing: false }); - }} - onKeyDown={(e) => { - console.log('keydown', e.key); - if (e.key === 'Enter') { - setData({ - ...data, - label: e.currentTarget.value, - isEditing: false, - }); - console.log('Enter setData({ ...data, name: e.target.value, isEditing: false })'); - } - if (e.key === 'Escape') { - setData({ ...data, isEditing: false }); - console.log('ESC setData({ ...data, isEditing: false })'); - } - }} - /> - ) : ( - { - e.stopPropagation(); - setData({ ...data, isEditing: true }); - }} - > - {data.label} - - ), - }); - await mockDelay(300); - expect(container.querySelector('.tree-item-span')).not.toBeNull(); - fireEvent.dblClick(container.querySelector('.tree-item-span')); - await mockDelay(300); - expect(container.querySelector('.tree-item-input')).not.toBeNull(); - fireEvent.change(container.querySelector('.tree-item-input'), { - target: { value: '123' }, - }); - expect((container.querySelector('.tree-item-input') as HTMLInputElement).value).toBe('123'); - (container.querySelector('.tree-item-input') as HTMLInputElement).focus(); - (container.querySelector('.tree-item-input') as HTMLInputElement).blur(); - await mockDelay(300); - expect(container.querySelector('.tree-item-input')).toBeNull(); - expect(container.querySelector('.tree-item-span')).not.toBeNull(); - expect(container.querySelector('.tree-item-span').textContent).toBe('123'); - }); }); });