diff --git a/packages/components/steps/__tests__/steps.branches.test.tsx b/packages/components/steps/__tests__/steps.branches.test.tsx new file mode 100644 index 0000000000..6261fe1315 --- /dev/null +++ b/packages/components/steps/__tests__/steps.branches.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, waitFor } from '@test/utils'; +import { vi } from 'vitest'; +import Steps from '../Steps'; +import StepItem from '../StepItem'; + +const TEST_ROOT_ID = 'step-test-branches'; + +test('options reverse order and index mapping', async () => { + const opts = [ + { title: 'first', content: 'c1', value: 0 }, + { title: 'second', content: 'c2', value: 1 }, + { title: 'third', content: 'c3', value: 2 }, + ]; + const { getByTestId } = render( +
+ +
, + ); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const titles = Array.from(root.querySelectorAll('.t-steps-item__title')).map((n) => n.textContent); + // when reverse, display order should be same length but titles still present + expect(titles.length).toBe(3); + expect(titles).toContain('first'); + expect(titles).toContain('second'); + expect(titles).toContain('third'); +}); + +test('children cloneElement branch and FINISH current', async () => { + const onChange = vi.fn?.() ?? (() => undefined); + const { getByTestId } = render( +
+ + + + +
, + ); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + // all items should be finish + const finish = root.querySelectorAll('.t-steps-item--finish'); + expect(finish.length).toBe(2); +}); + +test('StepItem default icon number rendered when icon=true and theme=default', async () => { + const { getByTestId } = render( +
+ + + +
, + ); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const number = root.querySelector('.t-steps-item__icon--number'); + expect(number).not.toBeNull(); + expect(number).toHaveTextContent('1'); +}); diff --git a/packages/components/steps/__tests__/steps.extra.test.tsx b/packages/components/steps/__tests__/steps.extra.test.tsx new file mode 100644 index 0000000000..5ebbb9e36e --- /dev/null +++ b/packages/components/steps/__tests__/steps.extra.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, waitFor, fireEvent, vi } from '@test/utils'; +import Steps from '../Steps'; +import StepItem from '../StepItem'; +import StepsContext from '../StepsContext'; + +const TEST_ROOT_ID = 'step-test-root-extra'; + +const defaultOptions = [ + { title: '0', content: '这里', value: 0 }, + { title: '1', content: '这里', value: 1 }, + { title: '2', content: '这里', value: 2 }, +]; + +describe('Steps extra branches', () => { + test("current='FINISH' -> all items finish (options)", async () => { + const { getByTestId } = render( +
+ +
, + ); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const finishes = root.querySelectorAll('.t-steps-item--finish'); + expect(finishes.length).toBe(defaultOptions.length); + }); + + test('item.status override (error) renders error class', async () => { + const opts = [ + { title: 'a', content: 'a', value: 0, status: 'error' as any }, + { title: 'b', content: 'b', value: 1 }, + ]; + const { getByTestId } = render( +
+ +
, + ); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const errorItem = root.querySelector('.t-steps-item--error'); + expect(errorItem).not.toBeNull(); + }); + + test('current value not exist triggers console.warn and defaults to wait', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const opts = [ + { title: 'a', content: 'a', value: 'a' }, + { title: 'b', content: 'b', value: 'b' }, + ]; + const { getByTestId } = render( +
+ +
, + ); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + expect(warnSpy).toHaveBeenCalledWith('TDesign Steps Warn: The current `value` is not exist.'); + const waitItems = root.querySelectorAll('.t-steps-item--wait'); + // both should be in default/wait state + expect(waitItems.length).toBe(opts.length); + warnSpy.mockRestore(); + }); + + test('status=process prevents click triggering onChange', async () => { + const onChange = vi.fn(); + const { getByTestId } = render( +
+ + + +
, + ); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const inner = root.querySelector('.t-steps-item__inner'); + fireEvent.click(inner); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('icon=false results in no icon content', async () => { + const { getByTestId } = render( +
+ + + +
, + ); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const icon = root.querySelector('.t-steps-item__icon'); + // icon container exists but should have no child nodes when icon=false + expect(icon).not.toBeNull(); + expect(icon.childNodes.length).toBe(0); + }); +}); diff --git a/packages/components/steps/__tests__/steps.test.tsx b/packages/components/steps/__tests__/steps.test.tsx index 11650e575a..5c07fae13e 100644 --- a/packages/components/steps/__tests__/steps.test.tsx +++ b/packages/components/steps/__tests__/steps.test.tsx @@ -1,86 +1,303 @@ -import React from 'react'; +import React, { useState } from 'react'; import { render, waitFor, fireEvent, vi } from '@test/utils'; +import { omit } from '@tdesign/common-js/utils/helper'; import Steps from '../Steps'; +import { TdStepsProps } from '../type'; const { StepItem } = Steps; -const stepOptions = [ - { - title: '1', - content: '这里是提示文字', - value: 1, - }, - { - title: '2', - content: '这里是提示文字', - value: 2, - }, - { - title: '3', - content: '这里是提示文字', - value: 3, - }, +type Layout = 'horizontal' | 'vertical'; +type Theme = 'default' | 'dot'; +type Sequence = 'positive' | 'reverse'; + +const defaultOptions = [ + { title: '0', content: '这里是提示文字', value: 0 }, + { title: '1', content: '这里是提示文字', value: 1 }, + { title: '2', content: '这里是提示文字', value: 2 }, ]; -describe('Steps 组件测试', () => { - test('mount, unmount 测试', () => { - const wrapper = render( - - - - - - , - ); +const TEST_ROOT_ID = 'step-test-root'; - expect(() => { - wrapper.unmount(); - }).not.toThrow(); - }); +const StepRender = (props: TdStepsProps) => { + const [current, setCurrent] = useState(0); + return ( +
+ { + setCurrent(c); + props?.onChange && props.onChange(c, p); + }} + /> +
+ ); +}; - test('options 测试', async () => { - const testId = 'step options test'; - const handleChange = vi.fn(); +const SlotRender = (props: TdStepsProps & { initial?: number }) => { + const [current, setCurrent] = useState(props.initial ?? 0); + return ( +
+ { + setCurrent(c); + props?.onChange && props.onChange(c, p); + }} + > + + + + +
+ ); +}; - const { getByTestId } = render( -
- -
, - ); +// --- Helpers to reduce repetition and assert node states --- +const nodeHasState = (el: Element | null, state: 'process' | 'finish' | 'wait') => { + if (!el) return false; + const cls = el.className || ''; + const re = new RegExp(`\\bt-steps-item(?:--|-)?${state}\\b`); + return re.test(cls); +}; + +const getStepItems = (root: Element) => Array.from(root.querySelectorAll('.t-steps-item')); - const stepsInstance = await waitFor(() => getByTestId(testId)); - const stepsItems = stepsInstance.querySelectorAll('.t-steps-item'); - expect(stepsItems.length).toBe(3); +const findProcessingIndex = (items: Element[]) => items.findIndex((it) => nodeHasState(it, 'process')); - fireEvent.click(stepsInstance.querySelector('.t-steps-item__inner')); - expect(handleChange).toBeCalledTimes(1); +const verifyDistribution = (root: Element, sequence: Sequence = 'positive') => { + const items = getStepItems(root); + const active = findProcessingIndex(items); + expect(active).toBeGreaterThanOrEqual(0); + items.forEach((it, idx) => { + if (idx === active) { + expect(nodeHasState(it, 'process')).toBe(true); + } else if (sequence === 'positive') { + if (idx < active) expect(nodeHasState(it, 'finish')).toBe(true); + if (idx > active) expect(nodeHasState(it, 'wait')).toBe(true); + } else { + if (idx > active) expect(nodeHasState(it, 'finish')).toBe(true); + if (idx < active) expect(nodeHasState(it, 'wait')).toBe(true); + } }); +}; + +const clickEachAndVerify = async function clickEachAndVerify(root: Element, sequence: Sequence = 'positive') { + const icons = Array.from(root.querySelectorAll('.t-steps-item__icon')); + await icons.reduce( + (prev, icon) => + prev.then(async () => { + fireEvent.click(icon); + await waitFor(() => { + const items = getStepItems(root); + const hasProcess = items.some((it) => nodeHasState(it, 'process')); + if (!hasProcess) throw new Error('no process state yet'); + }); + verifyDistribution(root, sequence); + }), + Promise.resolve(), + ); +}; + +describe('Steps 组件测试', () => { + describe('options', () => { + test('mount/unmount', () => { + const wrapper = render(); + expect(() => wrapper.unmount()).not.toThrow(); + }); + + test('layout vertical', async () => { + const { getByTestId } = render(); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + expect(root.querySelectorAll('.t-steps--vertical').length).toBe(1); + }); + + test('readonly 不可点击', async () => { + const { getByTestId } = render(); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + expect(root.querySelectorAll('.t-steps-item--clickable').length).toBe(0); + }); + + describe('theme / behavior', () => { + test('theme=default 基本渲染与切换', async () => { + const { getByTestId } = render(); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + + const items = root.querySelectorAll('.t-steps-item'); + expect(items.length).toBe(3); + + const icons = root.querySelectorAll('.t-steps-item__icon'); + expect(icons.length).toBe(3); + + const numbers = root.querySelectorAll('.t-steps-item__icon--number'); + expect(numbers.length).toBe(3); + expect(numbers[0]).toHaveTextContent('1'); + + const titles = root.querySelectorAll('.t-steps-item__title'); + expect(titles[0]).toHaveTextContent('0'); - test('layout vertical 测试', async () => { - const testId = 'step layout test'; + // 初始状态: first process, others wait + expect(items[0]).toHaveClass('t-steps-item--process'); + expect(items[1]).toHaveClass('t-steps-item--wait'); - const { getByTestId } = render( -
- -
, + // 切换到第二个 + fireEvent.click(numbers[1]); + expect(items[0]).toHaveClass('t-steps-item--finish'); + expect(items[1]).toHaveClass('t-steps-item--process'); + + // 切换到第三个(通过 content 区域) + const contents = root.querySelectorAll('.t-steps-item__content'); + fireEvent.click(contents[2]); + expect(items[2]).toHaveClass('t-steps-item--process'); + expect(items[1]).toHaveClass('t-steps-item--finish'); + }); + + test('theme=dot 不渲染数字,切换行为', async () => { + const { getByTestId } = render(); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + + const numbers = root.querySelectorAll('.t-steps-item__icon--number'); + expect(numbers.length).toBe(0); + + const icons = root.querySelectorAll('.t-steps-item__icon'); + fireEvent.click(icons[1]); + const items = root.querySelectorAll('.t-steps-item'); + expect(items[1]).toHaveClass('t-steps-item--process'); + }); + + test('@change called with correct args (options)', async () => { + const onChange = vi.fn(); + const { getByTestId } = render( +
+ +
, + ); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + fireEvent.click(root.querySelector('.t-steps-item__inner')); + expect(onChange).toHaveBeenCalledTimes(1); + }); + }); + + describe('sequence 行为 (options)', () => { + test('positive 正序', async () => { + const onChange = vi.fn(); + const { getByTestId } = render(); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const icons = root.querySelectorAll('.t-steps-item__icon'); + fireEvent.click(icons[1]); + expect(onChange.mock.calls[0][0]).toBe(1); + expect(onChange.mock.calls[0][1]).toBe(0); + }); + + test('reverse 倒序', async () => { + const onChange = vi.fn(); + const { getByTestId } = render(); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const icons = root.querySelectorAll('.t-steps-item__icon'); + // 初始倒序:最后一个应为 finish/process 视实现而定,此处断言为 finish + expect(icons[2]).toHaveClass('t-steps-item-finish'); + fireEvent.click(icons[0]); + expect(onChange.mock.calls[0][0]).toBe(2); + }); + }); + + // broader combinations and edge cases for options-based rendering + const sequenceValues = ['positive', 'reverse'] as const; + const layoutValues = ['horizontal', 'vertical'] as const; + const themeValues = ['default', 'dot'] as const; + + const optionCombos: ReadonlyArray = sequenceValues.flatMap((s) => + layoutValues.flatMap((l) => themeValues.map((t) => [s, l, t] as const)), + ); + + test.each(optionCombos)( + 'sequence=%s layout=%s theme=%s 点击每个节点并校验分布', + async (sequence, layout, theme) => { + const { getByTestId } = render(); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + await clickEachAndVerify(root, sequence); + }, ); - const stepsInstance = await waitFor(() => getByTestId(testId)); - const stepsItems = stepsInstance.querySelectorAll('.t-steps--vertical'); - expect(stepsItems.length).toBe(1); + test('空 options 不报错且无节点', async () => { + const { getByTestId } = render(); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const items = root.querySelectorAll('.t-steps-item'); + expect(items.length).toBe(0); + }); + + test('readonly 时点击不触发 onChange', async () => { + const onChange = vi.fn(); + const { getByTestId } = render(); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + fireEvent.click(root.querySelector('.t-steps-item__icon')); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('非数值 value 正确映射与切换', async () => { + const opts = [ + { title: 'a', content: 'a', value: 'a' }, + { title: 'b', content: 'b', value: 'b' }, + { title: 'c', content: 'c', value: 'c' }, + ]; + const onChange = vi.fn(); + const { getByTestId } = render( +
+ +
, + ); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const icons = root.querySelectorAll('.t-steps-item__icon'); + expect(icons.length).toBe(3); + fireEvent.click(icons[2]); + expect(onChange).toHaveBeenCalled(); + }); }); - test('layout readonly 测试', async () => { - const testId = 'step readonly test'; + describe('slot children', () => { + test('slot 渲染与自定义 icon', async () => { + const { getByTestId } = render( +
+ + + 按钮} /> + + + +
, + ); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const iconItems = root.querySelectorAll('.t-steps-item__icon'); + expect(iconItems.length).toBe(4); + expect((iconItems[1].children[0] as Element).tagName.toLowerCase()).toBe('button'); + }); - const { getByTestId } = render( -
- -
, - ); + test('slot @change called and status change', async () => { + const onChange = vi.fn(); + const { getByTestId } = render(); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const inner = root.querySelector('.t-steps-item__inner'); + fireEvent.click(inner); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + const slotSequenceThemeCombos: ReadonlyArray = [ + ['positive', 'default'], + ['reverse', 'default'], + ['positive', 'dot'], + ['reverse', 'dot'], + ]; - const stepsInstance = await waitFor(() => getByTestId(testId)); - const stepsItems = stepsInstance.querySelectorAll('.t-steps-item--clickable'); - expect(stepsItems.length).toBe(0); + test.each(slotSequenceThemeCombos)('sequence=%s theme=%s (slot)', async (sequence, theme) => { + const onChange = vi.fn(); + const { getByTestId } = render(); + const root = await waitFor(() => getByTestId(TEST_ROOT_ID)); + const icons = root.querySelectorAll('.t-steps-item__icon'); + expect(icons.length).toBe(3); + await clickEachAndVerify(root, sequence); + expect(onChange).toHaveBeenCalled(); + }); }); });