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/common b/packages/common index b8c4417066..2d5d75f737 160000 --- a/packages/common +++ b/packages/common @@ -1 +1 @@ -Subproject commit b8c44170662a28b9f8c1d11cdc48875899fc8e0f +Subproject commit 2d5d75f7373854b8590f9e1ef2e5fe8467350f9c 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'); - }); }); });