Skip to content

Commit 3de0c98

Browse files
committed
add tests
add tests for form compatibility layer and autocomplete Signed-off-by: Jonathan Winters <wintersjonathan0@gmail.com>
1 parent 85f776b commit 3de0c98

File tree

2 files changed

+378
-0
lines changed

2 files changed

+378
-0
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import * as React from 'react';
2+
import {render, screen} from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import '@testing-library/jest-dom';
5+
import {Autocomplete, AutocompleteApi} from './autocomplete';
6+
7+
const ITEMS = ['apple', 'banana', 'cherry', 'apricot'];
8+
9+
function renderAutocomplete(props: Partial<React.ComponentProps<typeof Autocomplete>> = {}) {
10+
return render(<Autocomplete items={ITEMS} {...props} />);
11+
}
12+
13+
describe('Autocomplete', () => {
14+
test('1: renders input; menu hidden when closed', () => {
15+
renderAutocomplete();
16+
expect(screen.getByRole('combobox')).toBeInTheDocument();
17+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
18+
});
19+
20+
test('2: opens menu on input click showing all items', async () => {
21+
renderAutocomplete();
22+
await userEvent.click(screen.getByRole('combobox'));
23+
expect(screen.getByRole('listbox')).toBeInTheDocument();
24+
expect(screen.getAllByRole('option')).toHaveLength(ITEMS.length);
25+
});
26+
27+
test('3: filters suggestions while typing when filterSuggestions=true', async () => {
28+
renderAutocomplete({filterSuggestions: true});
29+
const input = screen.getByRole('combobox');
30+
await userEvent.click(input);
31+
await userEvent.clear(input);
32+
await userEvent.type(input, 'ap');
33+
// 'apple' and 'apricot' match 'ap'
34+
const options = screen.getAllByRole('option');
35+
expect(options).toHaveLength(2);
36+
expect(options[0]).toHaveTextContent('apple');
37+
expect(options[1]).toHaveTextContent('apricot');
38+
});
39+
40+
test('4: shows all items when filterSuggestions is not set (default behaviour)', async () => {
41+
renderAutocomplete();
42+
const input = screen.getByRole('combobox');
43+
await userEvent.click(input);
44+
await userEvent.type(input, 'ap');
45+
expect(screen.getAllByRole('option')).toHaveLength(ITEMS.length);
46+
});
47+
48+
test('5: onSelect fires with correct value on item click', async () => {
49+
const onSelect = jest.fn();
50+
renderAutocomplete({onSelect});
51+
await userEvent.click(screen.getByRole('combobox'));
52+
const options = screen.getAllByRole('option');
53+
await userEvent.click(options[1]); // 'banana'
54+
expect(onSelect).toHaveBeenCalledWith('banana', expect.objectContaining({value: 'banana'}));
55+
});
56+
57+
test('6: onChange fires on keystroke with event and value', async () => {
58+
const onChange = jest.fn();
59+
renderAutocomplete({onChange});
60+
const input = screen.getByRole('combobox');
61+
await userEvent.click(input);
62+
await userEvent.type(input, 'b');
63+
expect(onChange).toHaveBeenCalledWith(expect.any(Object), expect.stringContaining('b'));
64+
});
65+
66+
test('7: controlled value — prop change updates input', () => {
67+
const {rerender} = renderAutocomplete({value: 'apple'});
68+
expect(screen.getByRole('combobox')).toHaveValue('apple');
69+
rerender(<Autocomplete items={ITEMS} value='banana' />);
70+
expect(screen.getByRole('combobox')).toHaveValue('banana');
71+
});
72+
73+
test('8: autoHighlight=false — no item has selected class on open', async () => {
74+
renderAutocomplete({autoHighlight: false});
75+
await userEvent.click(screen.getByRole('combobox'));
76+
const options = screen.getAllByRole('option');
77+
options.forEach((opt) => expect(opt).not.toHaveClass('selected'));
78+
});
79+
80+
test('9: renderItem custom renderer output appears in menu', async () => {
81+
renderAutocomplete({
82+
renderItem: (item) => <span data-testid='custom-item'>{item.value.toUpperCase()}</span>,
83+
});
84+
await userEvent.click(screen.getByRole('combobox'));
85+
const customItems = screen.getAllByTestId('custom-item');
86+
expect(customItems).toHaveLength(ITEMS.length);
87+
expect(customItems[0]).toHaveTextContent('APPLE');
88+
});
89+
90+
test('10: renderInput custom renderer replaces default input', () => {
91+
renderAutocomplete({
92+
renderInput: (inputProps) => <input {...inputProps} data-testid='custom-input' />,
93+
});
94+
expect(screen.getByTestId('custom-input')).toBeInTheDocument();
95+
});
96+
97+
test('11: string items auto-converted and displayed correctly', async () => {
98+
render(<Autocomplete items={['foo', 'bar']} />);
99+
await userEvent.click(screen.getByRole('combobox'));
100+
const options = screen.getAllByRole('option');
101+
expect(options[0]).toHaveTextContent('foo');
102+
expect(options[1]).toHaveTextContent('bar');
103+
});
104+
105+
test('12: autoCompleteRef receives object with refresh method', () => {
106+
let api: AutocompleteApi | undefined;
107+
renderAutocomplete({autoCompleteRef: (ref) => { api = ref; }});
108+
expect(api).toBeDefined();
109+
expect(typeof api!.refresh).toBe('function');
110+
});
111+
112+
test('13: menu hidden when filterSuggestions=true and no items match', async () => {
113+
renderAutocomplete({filterSuggestions: true});
114+
const input = screen.getByRole('combobox');
115+
await userEvent.click(input);
116+
await userEvent.clear(input);
117+
await userEvent.type(input, 'zzz');
118+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
119+
});
120+
121+
test('14: object items with label use label as display text', async () => {
122+
render(<Autocomplete items={[{value: 'v1', label: 'Label One'}, {value: 'v2', label: 'Label Two'}]} />);
123+
await userEvent.click(screen.getByRole('combobox'));
124+
const options = screen.getAllByRole('option');
125+
expect(options[0]).toHaveTextContent('Label One');
126+
expect(options[1]).toHaveTextContent('Label Two');
127+
});
128+
129+
test('15: object items without label fall back to value', async () => {
130+
render(<Autocomplete items={[{value: 'only-value'}]} />);
131+
await userEvent.click(screen.getByRole('combobox'));
132+
expect(screen.getByRole('option')).toHaveTextContent('only-value');
133+
});
134+
});
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import * as React from 'react';
2+
import {render, screen, act} from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import '@testing-library/jest-dom';
5+
import {Form, Text, TextArea, Checkbox, FormField, NestedForm, FormApi, FieldApi, FieldProps} from './compat';
6+
7+
// ---------------------------------------------------------------------------
8+
// Helpers
9+
// ---------------------------------------------------------------------------
10+
11+
function renderSimpleForm(formProps: Partial<React.ComponentProps<typeof Form>> = {}, defaultValues: Record<string, any> = {}) {
12+
let capturedApi!: FormApi;
13+
const onSubmit = jest.fn();
14+
const result = render(
15+
<Form defaultValues={defaultValues} onSubmit={onSubmit} getApi={(api) => { capturedApi = api; }} {...formProps}>
16+
{(api) => (
17+
<>
18+
<Text field='name' data-testid='name-input' />
19+
<button onClick={() => api.submitForm()}>Submit</button>
20+
</>
21+
)}
22+
</Form>
23+
);
24+
return {...result, onSubmit, getApi: () => capturedApi};
25+
}
26+
27+
// ---------------------------------------------------------------------------
28+
// Form — basic rendering
29+
// ---------------------------------------------------------------------------
30+
31+
describe('Form', () => {
32+
test('1: renders children via render prop', () => {
33+
render(
34+
<Form>
35+
{() => <span data-testid='child'>hello</span>}
36+
</Form>
37+
);
38+
expect(screen.getByTestId('child')).toBeInTheDocument();
39+
});
40+
41+
test('2: defaultValues populates Text field', () => {
42+
renderSimpleForm({}, {name: 'Alice'});
43+
expect(screen.getByTestId('name-input')).toHaveValue('Alice');
44+
});
45+
46+
test('3: typing into Text field updates formApi.values', async () => {
47+
const {getApi} = renderSimpleForm();
48+
await userEvent.type(screen.getByTestId('name-input'), 'Bob');
49+
expect(getApi().values.name).toBe('Bob');
50+
});
51+
52+
test('4: blurring a Text field sets touched[field]=true', async () => {
53+
const {getApi} = renderSimpleForm();
54+
const input = screen.getByTestId('name-input');
55+
await userEvent.click(input);
56+
await userEvent.tab(); // moves focus away, triggers blur
57+
expect(getApi().touched.name).toBe(true);
58+
});
59+
60+
test('5: submitForm calls onSubmit with current values', async () => {
61+
const {onSubmit} = renderSimpleForm({}, {name: 'Carol'});
62+
await userEvent.click(screen.getByText('Submit'));
63+
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({name: 'Carol'}), undefined, expect.objectContaining({submitForm: expect.any(Function)}));
64+
});
65+
66+
test('6: validateError with errors calls onSubmitFailure, not onSubmit', async () => {
67+
const onSubmit = jest.fn();
68+
const onSubmitFailure = jest.fn();
69+
render(
70+
<Form
71+
onSubmit={onSubmit}
72+
onSubmitFailure={onSubmitFailure}
73+
validateError={() => ({name: 'required'})}>
74+
{(api) => <button onClick={() => api.submitForm()}>Submit</button>}
75+
</Form>
76+
);
77+
await userEvent.click(screen.getByText('Submit'));
78+
expect(onSubmitFailure).toHaveBeenCalledWith({name: 'required'});
79+
expect(onSubmit).not.toHaveBeenCalled();
80+
});
81+
82+
test('7: validateError returning no errors allows onSubmit', async () => {
83+
const onSubmit = jest.fn();
84+
render(
85+
<Form onSubmit={onSubmit} validateError={() => ({})}>
86+
{(api) => <button onClick={() => api.submitForm()}>Submit</button>}
87+
</Form>
88+
);
89+
await userEvent.click(screen.getByText('Submit'));
90+
expect(onSubmit).toHaveBeenCalled();
91+
});
92+
93+
test('8: preSubmit transforms values before onSubmit', async () => {
94+
const onSubmit = jest.fn();
95+
render(
96+
<Form
97+
defaultValues={{name: 'dave'}}
98+
onSubmit={onSubmit}
99+
preSubmit={(vals) => ({...vals, name: vals.name.toUpperCase()})}>
100+
{(api) => <button onClick={() => api.submitForm()}>Submit</button>}
101+
</Form>
102+
);
103+
await userEvent.click(screen.getByText('Submit'));
104+
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({name: 'DAVE'}), undefined, expect.objectContaining({submitForm: expect.any(Function)}));
105+
});
106+
107+
test('9: getApi exposes FormApi with expected methods', () => {
108+
const {getApi} = renderSimpleForm();
109+
const api = getApi();
110+
expect(typeof api.submitForm).toBe('function');
111+
expect(typeof api.setValue).toBe('function');
112+
expect(typeof api.resetAll).toBe('function');
113+
expect(typeof api.getFormState).toBe('function');
114+
});
115+
116+
test('10: formDidUpdate fires when values change', async () => {
117+
const formDidUpdate = jest.fn();
118+
renderSimpleForm({formDidUpdate});
119+
await userEvent.type(screen.getByTestId('name-input'), 'x');
120+
expect(formDidUpdate).toHaveBeenCalled();
121+
const lastCall = formDidUpdate.mock.calls[formDidUpdate.mock.calls.length - 1][0];
122+
expect(lastCall.values.name).toContain('x');
123+
});
124+
125+
test('11: resetAll reverts to defaultValues', async () => {
126+
const {getApi} = renderSimpleForm({}, {name: 'Eve'});
127+
await userEvent.clear(screen.getByTestId('name-input'));
128+
await userEvent.type(screen.getByTestId('name-input'), 'Frank');
129+
act(() => getApi().resetAll());
130+
expect(screen.getByTestId('name-input')).toHaveValue('Eve');
131+
expect(getApi().values.name).toBe('Eve');
132+
});
133+
134+
test('12: setAllValues replaces entire values object', () => {
135+
const {getApi} = renderSimpleForm();
136+
act(() => getApi().setAllValues({name: 'Grace', extra: 42}));
137+
expect(getApi().values).toEqual({name: 'Grace', extra: 42});
138+
expect(screen.getByTestId('name-input')).toHaveValue('Grace');
139+
});
140+
141+
test('13: setError adds field error visible in formApi.errors', () => {
142+
const {getApi} = renderSimpleForm();
143+
act(() => getApi().setError('name', 'too short'));
144+
expect(getApi().errors.name).toBe('too short');
145+
});
146+
147+
test('14: setFormState updates values, touched, and errors together', () => {
148+
const {getApi} = renderSimpleForm();
149+
act(() => getApi().setFormState({
150+
values: {name: 'Hank'},
151+
touched: {name: true},
152+
errors: {name: 'bad'},
153+
}));
154+
const state = getApi().getFormState();
155+
expect(state.values.name).toBe('Hank');
156+
expect(state.touched.name).toBe(true);
157+
expect(state.errors.name).toBe('bad');
158+
});
159+
160+
test('15: nested dot-notation field path set and retrieved correctly', () => {
161+
let capturedApi!: FormApi;
162+
render(
163+
<Form getApi={(api) => { capturedApi = api; }}>
164+
{() => <Text field='spec.source.repoURL' data-testid='repo-input' />}
165+
</Form>
166+
);
167+
act(() => capturedApi.setValue('spec.source.repoURL', 'https://example.com'));
168+
expect(capturedApi.values.spec.source.repoURL).toBe('https://example.com');
169+
expect(screen.getByTestId('repo-input')).toHaveValue('https://example.com');
170+
});
171+
172+
test('16: TextArea setValue and setTouched on blur', async () => {
173+
let capturedApi!: FormApi;
174+
render(
175+
<Form getApi={(api) => { capturedApi = api; }}>
176+
{() => <TextArea field='notes' data-testid='notes-input' />}
177+
</Form>
178+
);
179+
await userEvent.type(screen.getByTestId('notes-input'), 'hello');
180+
expect(capturedApi.values.notes).toBe('hello');
181+
await userEvent.tab();
182+
expect(capturedApi.touched.notes).toBe(true);
183+
});
184+
185+
test('17: Checkbox field converts checked to boolean via fieldApi', async () => {
186+
let capturedApi!: FormApi;
187+
render(
188+
<Form defaultValues={{active: false}} getApi={(api) => { capturedApi = api; }}>
189+
{() => <Checkbox field='active' data-testid='active-cb' />}
190+
</Form>
191+
);
192+
expect(screen.getByTestId('active-cb')).not.toBeChecked();
193+
await userEvent.click(screen.getByTestId('active-cb'));
194+
expect(capturedApi.values.active).toBe(true);
195+
expect(screen.getByTestId('active-cb')).toBeChecked();
196+
});
197+
198+
test('18: FormField HOC passes fieldApi to wrapped component', () => {
199+
let receivedFieldApi: FieldApi | undefined;
200+
const Probe = FormField((props: FieldProps & {fieldApi: FieldApi}) => {
201+
receivedFieldApi = props.fieldApi;
202+
return null;
203+
});
204+
render(
205+
<Form defaultValues={{x: 'test'}}>
206+
{() => <Probe field='x' />}
207+
</Form>
208+
);
209+
expect(receivedFieldApi).toBeDefined();
210+
expect(typeof receivedFieldApi!.getValue).toBe('function');
211+
expect(receivedFieldApi!.getValue()).toBe('test');
212+
});
213+
214+
test('19: FormField outside Form without formApi prop throws', () => {
215+
const Probe = FormField((_props: FieldProps & {fieldApi?: FieldApi}) => null);
216+
// Suppress React's error boundary console output in test
217+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
218+
expect(() => render(<Probe field='x' />)).toThrow('FormField components must be used inside <Form> or be passed formApi');
219+
spy.mockRestore();
220+
});
221+
222+
test('20: submitForm calls e.preventDefault when passed event', async () => {
223+
let capturedApi!: FormApi;
224+
render(
225+
<Form getApi={(api) => { capturedApi = api; }} onSubmit={jest.fn()}>
226+
{() => null}
227+
</Form>
228+
);
229+
const mockEvent = {preventDefault: jest.fn()};
230+
act(() => capturedApi.submitForm(mockEvent));
231+
expect(mockEvent.preventDefault).toHaveBeenCalled();
232+
});
233+
});
234+
235+
// ---------------------------------------------------------------------------
236+
// NestedForm — legacy no-op
237+
// ---------------------------------------------------------------------------
238+
239+
describe('NestedForm', () => {
240+
test('21: renders children unchanged', () => {
241+
render(<NestedForm field='ignored'><span data-testid='nested-child'>ok</span></NestedForm>);
242+
expect(screen.getByTestId('nested-child')).toBeInTheDocument();
243+
});
244+
});

0 commit comments

Comments
 (0)