Skip to content

Commit 2af3a62

Browse files
committed
test: add _utils unit tests, form demo controls, and eslint fixes
Add tests for general.ts (16), useCombobox (19), and hooks (5) — 40 new tests. Add Select, DatePicker, TimePicker, ColorPicker to Form "Other Controls" demo. Fix jest-dom/prefer-to-have-text-content eslint errors in test files.
1 parent 383ccf5 commit 2af3a62

7 files changed

Lines changed: 417 additions & 40 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { isOneOf, camelCaseToDash, convertHexToRGBA, getPrefixCls } from '../general';
2+
3+
describe('isOneOf', () => {
4+
it('should return true when target is in array', () => {
5+
expect(isOneOf('a', ['a', 'b', 'c'])).toBe(true);
6+
});
7+
8+
it('should return false when target is not in array', () => {
9+
expect(isOneOf('d', ['a', 'b', 'c'])).toBe(false);
10+
});
11+
12+
it('should handle string comparison', () => {
13+
expect(isOneOf('hello', 'hello')).toBe(true);
14+
expect(isOneOf('hello', 'world')).toBe(false);
15+
});
16+
17+
it('should handle empty array', () => {
18+
expect(isOneOf('a', [])).toBe(false);
19+
});
20+
});
21+
22+
describe('camelCaseToDash', () => {
23+
it('should convert camelCase to dash-case', () => {
24+
expect(camelCaseToDash('backgroundColor')).toBe('background-color');
25+
});
26+
27+
it('should convert multiple uppercase letters', () => {
28+
expect(camelCaseToDash('borderTopWidth')).toBe('border-top-width');
29+
});
30+
31+
it('should leave lowercase strings unchanged', () => {
32+
expect(camelCaseToDash('color')).toBe('color');
33+
});
34+
35+
it('should handle empty string', () => {
36+
expect(camelCaseToDash('')).toBe('');
37+
});
38+
});
39+
40+
describe('convertHexToRGBA', () => {
41+
it('should convert 6-digit hex to rgba', () => {
42+
expect(convertHexToRGBA('#FF0000')).toBe('rgba(255,0,0,1)');
43+
});
44+
45+
it('should convert with custom opacity', () => {
46+
expect(convertHexToRGBA('#00FF00', 0.5)).toBe('rgba(0,255,0,0.5)');
47+
});
48+
49+
it('should handle lowercase hex', () => {
50+
expect(convertHexToRGBA('#0000ff')).toBe('rgba(0,0,255,1)');
51+
});
52+
53+
it('should return original string for invalid hex', () => {
54+
expect(convertHexToRGBA('red')).toBe('red');
55+
expect(convertHexToRGBA('#FFF')).toBe('#FFF');
56+
expect(convertHexToRGBA('not-a-color')).toBe('not-a-color');
57+
});
58+
});
59+
60+
describe('getPrefixCls', () => {
61+
it('should return default prefix with ty-', () => {
62+
expect(getPrefixCls('button')).toBe('ty-button');
63+
});
64+
65+
it('should use context prefix when provided', () => {
66+
expect(getPrefixCls('button', 'ant')).toBe('ant-button');
67+
});
68+
69+
it('should return customised class when provided', () => {
70+
expect(getPrefixCls('button', undefined, 'my-btn')).toBe('my-btn');
71+
});
72+
73+
it('should prefer customised class over context prefix', () => {
74+
expect(getPrefixCls('button', 'ant', 'my-btn')).toBe('my-btn');
75+
});
76+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import React, { useState } from 'react';
2+
import { render, fireEvent, act } from '@testing-library/react';
3+
import { useClickOutside, useDebounce } from '../hooks';
4+
5+
describe('useClickOutside', () => {
6+
// useClickOutside reads the target element directly (not a ref), so we need
7+
// a state-based ref pattern that triggers a re-render once the element mounts.
8+
function ClickOutsideHarness() {
9+
const [clicked, setClicked] = useState(false);
10+
const [el, setEl] = useState<HTMLDivElement | null>(null);
11+
12+
useClickOutside(el as HTMLDivElement, () => setClicked(true));
13+
14+
return (
15+
<div>
16+
<div ref={setEl} data-testid="inside">Inside</div>
17+
<div data-testid="outside">Outside</div>
18+
<span data-testid="result">{String(clicked)}</span>
19+
</div>
20+
);
21+
}
22+
23+
it('should call handler when clicking outside target', () => {
24+
const { getByTestId } = render(<ClickOutsideHarness />);
25+
fireEvent.click(getByTestId('outside'));
26+
expect(getByTestId('result')).toHaveTextContent('true');
27+
});
28+
29+
it('should not call handler when clicking inside target', () => {
30+
const { getByTestId } = render(<ClickOutsideHarness />);
31+
fireEvent.click(getByTestId('inside'));
32+
expect(getByTestId('result')).toHaveTextContent('false');
33+
});
34+
});
35+
36+
describe('useDebounce', () => {
37+
function DebounceHarness({ value, delay }: { value: string; delay?: number }) {
38+
const [debounced] = useDebounce(value, delay);
39+
return <span data-testid="debounced">{debounced}</span>;
40+
}
41+
42+
beforeEach(() => {
43+
jest.useFakeTimers();
44+
});
45+
46+
afterEach(() => {
47+
jest.useRealTimers();
48+
});
49+
50+
it('should return initial value immediately', () => {
51+
const { getByTestId } = render(<DebounceHarness value="hello" />);
52+
expect(getByTestId('debounced')).toHaveTextContent('hello');
53+
});
54+
55+
it('should debounce value changes', () => {
56+
const { getByTestId, rerender } = render(<DebounceHarness value="a" delay={200} />);
57+
expect(getByTestId('debounced')).toHaveTextContent('a');
58+
59+
rerender(<DebounceHarness value="b" delay={200} />);
60+
// Not yet updated
61+
expect(getByTestId('debounced')).toHaveTextContent('a');
62+
63+
act(() => {
64+
jest.advanceTimersByTime(200);
65+
});
66+
expect(getByTestId('debounced')).toHaveTextContent('b');
67+
});
68+
69+
it('should cancel pending update on new value', () => {
70+
const { getByTestId, rerender } = render(<DebounceHarness value="a" delay={200} />);
71+
72+
rerender(<DebounceHarness value="b" delay={200} />);
73+
act(() => { jest.advanceTimersByTime(100); });
74+
75+
rerender(<DebounceHarness value="c" delay={200} />);
76+
act(() => { jest.advanceTimersByTime(100); });
77+
78+
// 'b' should have been cancelled, still showing 'a'
79+
expect(getByTestId('debounced')).toHaveTextContent('a');
80+
81+
act(() => { jest.advanceTimersByTime(100); });
82+
expect(getByTestId('debounced')).toHaveTextContent('c');
83+
});
84+
});
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import React from 'react';
2+
import { render, fireEvent } from '@testing-library/react';
3+
import { useCombobox } from '../useCombobox';
4+
5+
interface Item {
6+
value: string;
7+
label?: string;
8+
disabled?: boolean;
9+
}
10+
11+
const items: Item[] = [
12+
{ value: 'apple', label: 'Apple' },
13+
{ value: 'banana', label: 'Banana' },
14+
{ value: 'cherry', label: 'Cherry' },
15+
{ value: 'grape', label: 'Grape', disabled: true },
16+
];
17+
18+
// Test harness that exposes hook state via data attributes and DOM
19+
function Harness(props: Parameters<typeof useCombobox<Item>>[0]) {
20+
const combo = useCombobox<Item>(props);
21+
return (
22+
<div>
23+
<input
24+
data-testid="trigger"
25+
onKeyDown={combo.handleKeyDown}
26+
readOnly
27+
/>
28+
<span data-testid="isOpen">{String(combo.isOpen)}</span>
29+
<span data-testid="focusedIndex">{combo.focusedIndex}</span>
30+
<span data-testid="filteredCount">{combo.filteredItems.length}</span>
31+
<ul data-testid="items">
32+
{combo.filteredItems.map((item) => (
33+
<li key={item.value}>{item.value}</li>
34+
))}
35+
</ul>
36+
<button data-testid="open" onClick={combo.openDropdown}>Open</button>
37+
<button data-testid="close" onClick={combo.closeDropdown}>Close</button>
38+
</div>
39+
);
40+
}
41+
42+
function renderCombobox(overrides: Partial<Parameters<typeof useCombobox<Item>>[0]> = {}) {
43+
const defaultProps = { items, searchValue: '', ...overrides };
44+
return render(<Harness {...defaultProps} />);
45+
}
46+
47+
describe('useCombobox', () => {
48+
describe('filtering', () => {
49+
it('should show all items when searchValue is empty', () => {
50+
const { getByTestId } = renderCombobox();
51+
expect(getByTestId('filteredCount')).toHaveTextContent('4');
52+
});
53+
54+
it('should filter items by label (case-insensitive)', () => {
55+
const { getByTestId } = renderCombobox({ searchValue: 'ap' });
56+
expect(getByTestId('filteredCount')).toHaveTextContent('2');
57+
});
58+
59+
it('should return all items when filterOption is false', () => {
60+
const { getByTestId } = renderCombobox({ searchValue: 'xyz', filterOption: false });
61+
expect(getByTestId('filteredCount')).toHaveTextContent('4');
62+
});
63+
64+
it('should use custom filter function', () => {
65+
const filterOption = (_input: string, item: Item) => item.value.startsWith('b');
66+
const { getByTestId } = renderCombobox({ searchValue: 'anything', filterOption });
67+
expect(getByTestId('filteredCount')).toHaveTextContent('1');
68+
expect(getByTestId('items')).toHaveTextContent('banana');
69+
});
70+
71+
it('should filter by value when label is not a string', () => {
72+
const itemsNoLabel: Item[] = [
73+
{ value: 'alpha' },
74+
{ value: 'beta' },
75+
];
76+
const { getByTestId } = renderCombobox({ items: itemsNoLabel, searchValue: 'bet' });
77+
expect(getByTestId('filteredCount')).toHaveTextContent('1');
78+
});
79+
});
80+
81+
describe('open/close', () => {
82+
it('should start closed by default', () => {
83+
const { getByTestId } = renderCombobox();
84+
expect(getByTestId('isOpen')).toHaveTextContent('false');
85+
});
86+
87+
it('should respect defaultOpen', () => {
88+
const { getByTestId } = renderCombobox({ defaultOpen: true });
89+
expect(getByTestId('isOpen')).toHaveTextContent('true');
90+
});
91+
92+
it('should open and close via helpers', () => {
93+
const onOpenChange = jest.fn();
94+
const { getByTestId } = renderCombobox({ onOpenChange });
95+
96+
fireEvent.click(getByTestId('open'));
97+
expect(getByTestId('isOpen')).toHaveTextContent('true');
98+
expect(onOpenChange).toHaveBeenCalledWith(true);
99+
100+
fireEvent.click(getByTestId('close'));
101+
expect(getByTestId('isOpen')).toHaveTextContent('false');
102+
expect(onOpenChange).toHaveBeenCalledWith(false);
103+
});
104+
105+
it('should not change internal state when controlled', () => {
106+
const { getByTestId } = renderCombobox({ isOpen: false });
107+
fireEvent.click(getByTestId('open'));
108+
expect(getByTestId('isOpen')).toHaveTextContent('false');
109+
});
110+
});
111+
112+
describe('focusedIndex', () => {
113+
it('should default to 0 when defaultActiveFirstOption is true and items exist', () => {
114+
const { getByTestId } = renderCombobox();
115+
expect(getByTestId('focusedIndex')).toHaveTextContent('0');
116+
});
117+
118+
it('should default to -1 when defaultActiveFirstOption is false', () => {
119+
const { getByTestId } = renderCombobox({ defaultActiveFirstOption: false });
120+
expect(getByTestId('focusedIndex')).toHaveTextContent('-1');
121+
});
122+
123+
it('should default to -1 when items are empty', () => {
124+
const { getByTestId } = renderCombobox({ items: [] });
125+
expect(getByTestId('focusedIndex')).toHaveTextContent('-1');
126+
});
127+
});
128+
129+
describe('keyboard navigation', () => {
130+
it('should open on ArrowDown when closed', () => {
131+
const { getByTestId } = renderCombobox();
132+
fireEvent.keyDown(getByTestId('trigger'), { key: 'ArrowDown' });
133+
expect(getByTestId('isOpen')).toHaveTextContent('true');
134+
});
135+
136+
it('should close on Escape', () => {
137+
const { getByTestId } = renderCombobox({ defaultOpen: true });
138+
fireEvent.keyDown(getByTestId('trigger'), { key: 'Escape' });
139+
expect(getByTestId('isOpen')).toHaveTextContent('false');
140+
});
141+
142+
it('should cycle focusedIndex with ArrowDown/ArrowUp', () => {
143+
const { getByTestId } = renderCombobox({ defaultOpen: true });
144+
145+
// Start at 0, go down
146+
fireEvent.keyDown(getByTestId('trigger'), { key: 'ArrowDown' });
147+
expect(getByTestId('focusedIndex')).toHaveTextContent('1');
148+
149+
fireEvent.keyDown(getByTestId('trigger'), { key: 'ArrowDown' });
150+
expect(getByTestId('focusedIndex')).toHaveTextContent('2');
151+
152+
// Go up
153+
fireEvent.keyDown(getByTestId('trigger'), { key: 'ArrowUp' });
154+
expect(getByTestId('focusedIndex')).toHaveTextContent('1');
155+
});
156+
157+
it('should wrap around at boundaries', () => {
158+
const { getByTestId } = renderCombobox({ defaultOpen: true });
159+
160+
// Go up from 0 → wraps to last
161+
fireEvent.keyDown(getByTestId('trigger'), { key: 'ArrowUp' });
162+
expect(getByTestId('focusedIndex')).toHaveTextContent('3');
163+
164+
// Go down from last → wraps to 0
165+
fireEvent.keyDown(getByTestId('trigger'), { key: 'ArrowDown' });
166+
expect(getByTestId('focusedIndex')).toHaveTextContent('0');
167+
});
168+
169+
it('should select focused item on Enter', () => {
170+
const onSelect = jest.fn();
171+
const { getByTestId } = renderCombobox({ defaultOpen: true, onSelect });
172+
173+
// Focus is at 0 (Apple)
174+
fireEvent.keyDown(getByTestId('trigger'), { key: 'Enter' });
175+
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ value: 'apple' }));
176+
});
177+
178+
it('should not select disabled item on Enter', () => {
179+
const onSelect = jest.fn();
180+
const { getByTestId } = renderCombobox({ defaultOpen: true, onSelect });
181+
182+
// Navigate to grape (index 3, disabled)
183+
fireEvent.keyDown(getByTestId('trigger'), { key: 'ArrowDown' });
184+
fireEvent.keyDown(getByTestId('trigger'), { key: 'ArrowDown' });
185+
fireEvent.keyDown(getByTestId('trigger'), { key: 'ArrowDown' });
186+
expect(getByTestId('focusedIndex')).toHaveTextContent('3');
187+
188+
fireEvent.keyDown(getByTestId('trigger'), { key: 'Enter' });
189+
expect(onSelect).not.toHaveBeenCalled();
190+
});
191+
192+
it('should ignore keyboard when disabled', () => {
193+
const { getByTestId } = renderCombobox({ disabled: true });
194+
fireEvent.keyDown(getByTestId('trigger'), { key: 'ArrowDown' });
195+
expect(getByTestId('isOpen')).toHaveTextContent('false');
196+
});
197+
});
198+
});

0 commit comments

Comments
 (0)