Skip to content

Commit c20d90d

Browse files
srividyaramasSriRamas23Copilot
authored
Agentic Checkbox: extend contract tests and non-input native coverage (#4412)
* test(checkbox): cover remaining SPEC behaviors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 * Scope Checkbox coverage to unit and non-input native cases Preserve deferred pointer and keyboard cases on checkbox-input-tests-held. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 * test(checkbox): observe native accessible names directly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --------- Co-authored-by: Srividya Ramaswamy <sriramas@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1
1 parent 9e55a6a commit c20d90d

3 files changed

Lines changed: 273 additions & 3 deletions

File tree

‎.changeset/curly-rabbits-study.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
3+
---
4+
5+

‎packages/agentic/components/src/components/checkbox/checkbox.stories.tsx‎

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,28 @@ export const ExternallyDrivenStatus: Story = {
166166
export const HiddenLabel: Story = {
167167
render: () => (
168168
<StoryGroup label="Label fallback">
169-
<Checkbox accessibilityLabel="Enable notifications" label="Hidden label" showLabel={false} />
169+
<Checkbox accessibilityLabel="Enable notifications" label="Hidden label" showLabel={false} testID="checkbox-explicit-name" />
170+
<Checkbox label="Save drafts" showLabel={false} testID="checkbox-fallback-name" />
170171
</StoryGroup>
171172
),
173+
wdio: {
174+
'CBX-004 preserves native explicit and fallback names without visible labels': async ({ browser, expect }) => {
175+
const { expectNativeState } = await import('../../common/desktopFocus.wdio.ts');
176+
const explicit = await browser.$('~checkbox-explicit-name');
177+
const fallback = await browser.$('~checkbox-fallback-name');
178+
expect(await explicit.getTagName()).toBe('checkbox');
179+
expect(await fallback.getTagName()).toBe('checkbox');
180+
expect(await explicit.getProperty('name')).toBe('Enable notifications');
181+
expect(await fallback.getProperty('name')).toBe('Save drafts');
182+
await expectNativeState(browser, 'checkbox-explicit-name', 'checked', false);
183+
await expectNativeState(browser, 'checkbox-fallback-name', 'checked', false);
184+
},
185+
},
172186
parameters: {
173187
docs: {
174188
description: {
175-
story: 'When the visible label is hidden, the component still requires an accessible name.',
189+
story:
190+
'When the visible label is hidden, the component still requires an accessible name. Native name and checked state are checked separately; macOS exposes checkbox state through AXValue.',
176191
},
177192
},
178193
},
@@ -199,9 +214,27 @@ export const ConstrainedText: Story = {
199214
<StoryGroup label="Constrained label">
200215
<Checkbox
201216
label="Long checkbox label that wraps inside constrained layouts without truncating"
202-
secondaryText="Secondary text can wrap too."
217+
style={styles.constrained}
218+
testID="checkbox-narrow-label"
219+
/>
220+
<Checkbox
221+
label="Long checkbox label that wraps inside constrained layouts without truncating"
222+
style={styles.unconstrained}
223+
testID="checkbox-wide-label"
224+
/>
225+
<Checkbox
226+
label="Option"
227+
secondaryText="Long supporting text that wraps inside constrained layouts without truncating"
203228
showSecondaryText
204229
style={styles.constrained}
230+
testID="checkbox-narrow-secondary"
231+
/>
232+
<Checkbox
233+
label="Option"
234+
secondaryText="Long supporting text that wraps inside constrained layouts without truncating"
235+
showSecondaryText
236+
style={styles.unconstrained}
237+
testID="checkbox-wide-secondary"
205238
/>
206239
</StoryGroup>
207240
),
@@ -212,6 +245,24 @@ export const ConstrainedText: Story = {
212245
},
213246
},
214247
},
248+
wdio: {
249+
'CBX-003 wraps the label into a taller constrained row': async ({ browser, expect }) => {
250+
const narrow = await browser.$('~checkbox-narrow-label');
251+
const wide = await browser.$('~checkbox-wide-label');
252+
const narrowRect = await browser.getElementRect(await narrow.elementId);
253+
const wideRect = await browser.getElementRect(await wide.elementId);
254+
expect(narrowRect.width).toBeLessThan(wideRect.width);
255+
expect(narrowRect.height).toBeGreaterThan(wideRect.height);
256+
},
257+
'CBX-003 wraps secondary text into a taller constrained row': async ({ browser, expect }) => {
258+
const narrow = await browser.$('~checkbox-narrow-secondary');
259+
const wide = await browser.$('~checkbox-wide-secondary');
260+
const narrowRect = await browser.getElementRect(await narrow.elementId);
261+
const wideRect = await browser.getElementRect(await wide.elementId);
262+
expect(narrowRect.width).toBeLessThan(wideRect.width);
263+
expect(narrowRect.height).toBeGreaterThan(wideRect.height);
264+
},
265+
},
215266
};
216267

217268
const styles = StyleSheet.create({
@@ -235,4 +286,7 @@ const styles = StyleSheet.create({
235286
alignItems: 'flex-start',
236287
gap: 16,
237288
},
289+
unconstrained: {
290+
width: 480,
291+
},
238292
});

‎packages/agentic/components/src/components/checkbox/checkbox.test.tsx‎

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { RenderResult } from '@testing-library/react-native';
99
import { defaultFlexTokens } from '@fluentui-react-native/design/testing';
1010

1111
import { Checkbox } from './checkbox';
12+
import type { CheckboxStatus } from './checkbox.types';
1213

1314
function renderCheckbox(props: React.ComponentProps<typeof Checkbox>): Promise<RenderResult> {
1415
return render(<Checkbox {...props} />);
@@ -330,4 +331,214 @@ describe('Checkbox', () => {
330331
});
331332
expect(StyleSheet.flatten(component.getByTestId('checkbox-label').props.style).color).toBe(tokens.foregroundNeutralDisabled);
332333
});
334+
335+
describe('SPEC regression coverage', () => {
336+
afterEach(() => jest.restoreAllMocks());
337+
338+
it('CBX-001 resolves omitted defaults rather than caller-supplied values', async () => {
339+
const component = await renderCheckbox({});
340+
expect(getRoot(component).props).toMatchObject({
341+
accessible: true,
342+
accessibilityLabel: 'Label',
343+
accessibilityState: { checked: false, disabled: false },
344+
focusable: true,
345+
});
346+
expect(getRoot(component)).toBeEnabled();
347+
expect(component.getByText('Label')).toBeOnTheScreen();
348+
expect(component.queryByTestId('checkbox-secondary', { includeHiddenElements: true })).toBeNull();
349+
expect(component.queryByTestId('checkbox-check-icon')).toBeNull();
350+
expect(component.queryByTestId('checkbox-dash-icon')).toBeNull();
351+
expect(getIndicatorStyle(component)).toMatchObject({
352+
borderRadius: defaultFlexTokens.borderRadius.base100,
353+
height: 16,
354+
width: 16,
355+
});
356+
});
357+
358+
it('CBX-001 forwards native root props and the original layout event', async () => {
359+
const onLayout = jest.fn();
360+
const hitSlop = { bottom: 4, left: 8, right: 8, top: 4 };
361+
const component = await renderCheckbox({ focusable: false, hitSlop, nativeID: 'choice', onLayout, testID: 'consumer-checkbox' });
362+
const root = getRoot(component);
363+
expect(root).toBe(component.getByTestId('consumer-checkbox'));
364+
expect(root.props).toMatchObject({ focusable: false, hitSlop, nativeID: 'choice' });
365+
const event = { nativeEvent: { layout: { height: 24, width: 120, x: 0, y: 0 } } };
366+
await fireEvent(root, 'layout', event);
367+
expect(onLayout).toHaveBeenCalledTimes(1);
368+
expect(onLayout).toHaveBeenCalledWith(event);
369+
});
370+
371+
describe.each(['unchecked', 'checked', 'indeterminate'] as const)('CBX-002 starting from %s', (status) => {
372+
it.each([false, true])('preserves status ownership and callback order with controlled=%s', async (controlled) => {
373+
const calls: string[] = [];
374+
const onStatusChange = jest.fn((next: CheckboxStatus) => calls.push(next));
375+
const onPress = jest.fn(() => calls.push('press'));
376+
const component = await renderCheckbox({
377+
defaultStatus: controlled ? (status === 'checked' ? 'unchecked' : 'checked') : status,
378+
onPress,
379+
onStatusChange,
380+
...(controlled ? { status } : {}),
381+
});
382+
const initialChecked = status === 'indeterminate' ? 'mixed' : status === 'checked';
383+
expect(getRoot(component).props.accessibilityState.checked).toBe(initialChecked);
384+
const first = status === 'checked' ? 'unchecked' : 'checked';
385+
const second = controlled ? first : first === 'checked' ? 'unchecked' : 'checked';
386+
const event = { nativeEvent: { pageX: 12, pageY: 12 } };
387+
388+
await fireEvent.press(getRoot(component), event);
389+
expect(getRoot(component).props.accessibilityState.checked).toBe(controlled ? initialChecked : first === 'checked');
390+
await fireEvent.press(getRoot(component), event);
391+
expect(getRoot(component).props.accessibilityState.checked).toBe(controlled ? initialChecked : second === 'checked');
392+
expect(onStatusChange.mock.calls).toEqual([[first], [second]]);
393+
expect(onPress).toHaveBeenCalledTimes(2);
394+
for (const call of [1, 2]) {
395+
expect(onPress).toHaveBeenNthCalledWith(
396+
call,
397+
expect.objectContaining({ nativeEvent: expect.objectContaining(event.nativeEvent) }),
398+
);
399+
}
400+
expect(calls).toEqual([first, 'press', second, 'press']);
401+
});
402+
403+
it('does not report or apply a disabled press', async () => {
404+
const onPress = jest.fn();
405+
const onStatusChange = jest.fn();
406+
const component = await renderCheckbox({ defaultStatus: status, disabled: true, onPress, onStatusChange });
407+
await fireEvent.press(getRoot(component));
408+
expect(onPress).not.toHaveBeenCalled();
409+
expect(onStatusChange).not.toHaveBeenCalled();
410+
expect(getRoot(component).props.accessibilityState.checked).toBe(status === 'indeterminate' ? 'mixed' : status === 'checked');
411+
});
412+
});
413+
414+
it('CBX-002 keeps defaultStatus as an initializer and accepts later controlled values', async () => {
415+
const component = await renderCheckbox({ defaultStatus: 'checked' });
416+
await component.rerender(<Checkbox defaultStatus="unchecked" />);
417+
expect(getRoot(component).props.accessibilityState.checked).toBe(true);
418+
419+
const controlled = await renderCheckbox({ status: 'unchecked', defaultStatus: 'checked' });
420+
await controlled.rerender(<Checkbox status="indeterminate" defaultStatus="checked" />);
421+
expect(getRoot(controlled).props.accessibilityState.checked).toBe('mixed');
422+
});
423+
424+
it.each([
425+
[false, false],
426+
[false, true],
427+
[true, false],
428+
[true, true],
429+
])('CBX-003 gates text and development warnings with showLabel=%s and showSecondaryText=%s', async (showLabel, showSecondaryText) => {
430+
const warn = jest.spyOn(console, 'warn').mockImplementation();
431+
const component = await renderCheckbox({ showLabel, showSecondaryText, accessibilityHint: 'Caller hint' });
432+
expect(component.queryByTestId('checkbox-label') !== null).toBe(showLabel);
433+
expect(component.queryByTestId('checkbox-secondary', { includeHiddenElements: true }) !== null).toBe(showLabel && showSecondaryText);
434+
expect(getRoot(component).props.accessibilityHint).toBe(showLabel && showSecondaryText ? 'Caller hint. Description' : 'Caller hint');
435+
if (showSecondaryText && !showLabel) {
436+
expect(warn.mock.calls).toEqual([['Checkbox: secondary text requires a visible label.']]);
437+
} else {
438+
expect(warn).not.toHaveBeenCalled();
439+
}
440+
});
441+
442+
it('CBX-003 and CBX-004 render indicator, label, and secondary text in order without duplicate accessible nodes', async () => {
443+
const component = await renderCheckbox({ showSecondaryText: true });
444+
const indicator = component.getByTestId('checkbox-indicator');
445+
const label = component.getByTestId('checkbox-label');
446+
const secondary = component.getByTestId('checkbox-secondary', { includeHiddenElements: true });
447+
expect(getRoot(component).children).toEqual([
448+
indicator,
449+
expect.objectContaining({
450+
props: expect.objectContaining({ accessible: false }),
451+
children: [label, secondary],
452+
}),
453+
]);
454+
expect(indicator.props.accessible).toBe(false);
455+
expect(label.props.accessible).toBe(false);
456+
expect(secondary.props).toMatchObject({ accessible: false, accessibilityElementsHidden: true });
457+
expect(secondary).toHaveTextContent('Description');
458+
});
459+
460+
it('CBX-004 owns role and checked/disabled state while preserving caller context', async () => {
461+
const component = await renderCheckbox({
462+
accessibilityRole: 'button',
463+
accessibilityState: { busy: true, checked: false, disabled: true },
464+
accessibilityLabel: 'Explicit option name',
465+
accessibilityHint: 'Caller context',
466+
secondaryText: 'Supporting context',
467+
showSecondaryText: true,
468+
status: 'indeterminate',
469+
});
470+
expect(getRoot(component).props).toMatchObject({
471+
accessibilityRole: 'checkbox',
472+
accessibilityState: { busy: true, checked: 'mixed', disabled: false },
473+
accessibilityLabel: 'Explicit option name',
474+
accessibilityHint: 'Caller context. Supporting context',
475+
});
476+
});
477+
478+
it.each(['unchecked', 'checked', 'indeterminate'] as const)(
479+
'CBX-005 resolves all %s colors and restores hover after release',
480+
async (status) => {
481+
const colors = defaultFlexTokens.color;
482+
const component = await renderCheckbox({ status, showSecondaryText: true });
483+
for (const [event, palette] of [
484+
['hoverIn', colors.hover],
485+
['pressIn', colors.pressed],
486+
['pressOut', colors.hover],
487+
['hoverOut', colors],
488+
] as const) {
489+
await fireEvent(getRoot(component), event, {});
490+
expect(getIndicatorStyle(component)).toMatchObject({
491+
backgroundColor: status === 'unchecked' ? palette.backgroundNeutralTransparent : palette.backgroundBrandHeavy,
492+
borderColor: status === 'unchecked' ? palette.strokeNeutralHeavy : palette.strokeNeutralTransparent,
493+
});
494+
expect(StyleSheet.flatten(component.getByTestId('checkbox-label').props.style).color).toBe(
495+
status === 'unchecked' ? palette.foregroundNeutralSecondary : palette.foregroundNeutralPrimary,
496+
);
497+
expect(StyleSheet.flatten(component.getByTestId('checkbox-secondary', { includeHiddenElements: true }).props.style).color).toBe(
498+
palette.foregroundNeutralSecondary,
499+
);
500+
if (status !== 'unchecked') {
501+
expect(StyleSheet.flatten(component.getByText(status === 'checked' ? '\u2713' : '\u2212').props.style).color).toBe(
502+
palette.foregroundBrandOnloud,
503+
);
504+
}
505+
}
506+
},
507+
);
508+
509+
it.each(['unchecked', 'checked', 'indeterminate'] as const)(
510+
'CBX-005 selects disabled %s colors during an active press',
511+
async (status) => {
512+
const component = await renderCheckbox({ status, showSecondaryText: true });
513+
await fireEvent(getRoot(component), 'hoverIn', {});
514+
await fireEvent(getRoot(component), 'pressIn', {});
515+
await component.rerender(<Checkbox status={status} showSecondaryText disabled />);
516+
const colors = defaultFlexTokens.color;
517+
expect(getIndicatorStyle(component)).toMatchObject({
518+
backgroundColor: status === 'unchecked' ? colors.backgroundNeutralTransparent : colors.backgroundNeutralHeavyDisabled,
519+
borderColor: status === 'unchecked' ? colors.strokeNeutralDisabled : colors.strokeNeutralTransparent,
520+
});
521+
for (const id of ['checkbox-label', 'checkbox-secondary']) {
522+
expect(StyleSheet.flatten(component.getByTestId(id, { includeHiddenElements: true }).props.style).color).toBe(
523+
colors.foregroundNeutralDisabled,
524+
);
525+
}
526+
if (status !== 'unchecked') {
527+
expect(StyleSheet.flatten(component.getByText(status === 'checked' ? '\u2713' : '\u2212').props.style).color).toBe(
528+
colors.foregroundNeutralDisabled,
529+
);
530+
}
531+
},
532+
);
533+
534+
it('CBX-005 applies the last caller style after structural and token styles', async () => {
535+
const component = await renderCheckbox({
536+
style: [
537+
{ minHeight: 40, gap: 20 },
538+
{ minHeight: 48, gap: 12, flexDirection: 'column' },
539+
],
540+
});
541+
expect(getRootStyle(component)).toMatchObject({ minHeight: 48, gap: 12, flexDirection: 'column' });
542+
});
543+
});
333544
});

0 commit comments

Comments
 (0)