diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts index b29088ad9..a2b1cca88 100644 --- a/src/helpers/accessiblity.ts +++ b/src/helpers/accessiblity.ts @@ -160,3 +160,10 @@ export function getAccessibilityState(element: ReactTestInstance) { selected: ariaSelected ?? accessibilityState?.selected, }; } + +export function getAccessibilityCheckedState( + element: ReactTestInstance +): AccessibilityState['checked'] { + const { accessibilityState, 'aria-checked': ariaChecked } = element.props; + return ariaChecked ?? accessibilityState?.checked; +} diff --git a/src/matchers/__tests__/to-be-checked.test.tsx b/src/matchers/__tests__/to-be-checked.test.tsx new file mode 100644 index 000000000..83f75c3b2 --- /dev/null +++ b/src/matchers/__tests__/to-be-checked.test.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { type AccessibilityRole, View } from 'react-native'; +import render from '../../render'; +import { screen } from '../../screen'; +import '../extend-expect'; + +function renderViewsWithRole(role: AccessibilityRole) { + return render( + <> + + + + + + ); +} + +test('toBeCheck() with checkbox role', () => { + renderViewsWithRole('checkbox'); + + const checked = screen.getByTestId('checkbox-checked'); + const unchecked = screen.getByTestId('checkbox-unchecked'); + const mixed = screen.getByTestId('checkbox-mixed'); + const defaultView = screen.getByTestId('checkbox-default'); + + expect(checked).toBeChecked(); + expect(unchecked).not.toBeChecked(); + expect(mixed).not.toBeChecked(); + expect(defaultView).not.toBeChecked(); + + expect(() => expect(checked).not.toBeChecked()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toBeChecked() + + Received element is checked: + " + `); + expect(() => expect(unchecked).toBeChecked()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeChecked() + + Received element is not checked: + " + `); + expect(() => expect(mixed).toBeChecked()).toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeChecked() + + Received element is not checked: + " + `); + expect(() => expect(defaultView).toBeChecked()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeChecked() + + Received element is not checked: + " + `); +}); + +test('toBeCheck() with radio role', () => { + renderViewsWithRole('radio'); + + const checked = screen.getByTestId('radio-checked'); + const unchecked = screen.getByTestId('radio-unchecked'); + const defaultView = screen.getByTestId('radio-default'); + + expect(checked).toBeChecked(); + expect(unchecked).not.toBeChecked(); + expect(defaultView).not.toBeChecked(); + + expect(() => expect(checked).not.toBeChecked()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toBeChecked() + + Received element is checked: + " + `); + expect(() => expect(unchecked).toBeChecked()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeChecked() + + Received element is not checked: + " + `); + expect(() => expect(defaultView).toBeChecked()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeChecked() + + Received element is not checked: + " + `); +}); + +test('throws error for invalid role', () => { + renderViewsWithRole('adjustable'); + + const checked = screen.getByTestId('adjustable-checked'); + const unchecked = screen.getByTestId('adjustable-unchecked'); + + expect(() => + expect(checked).toBeChecked() + ).toThrowErrorMatchingInlineSnapshot( + `"toBeChecked() works only on accessibility elements with "checkbox" or "radio" role."` + ); + expect(() => + expect(unchecked).not.toBeChecked() + ).toThrowErrorMatchingInlineSnapshot( + `"toBeChecked() works only on accessibility elements with "checkbox" or "radio" role."` + ); +}); diff --git a/src/matchers/__tests__/to-be-partially-checked.test.tsx b/src/matchers/__tests__/to-be-partially-checked.test.tsx new file mode 100644 index 000000000..b4f781801 --- /dev/null +++ b/src/matchers/__tests__/to-be-partially-checked.test.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { type AccessibilityRole, View } from 'react-native'; +import render from '../../render'; +import { screen } from '../../screen'; +import '../extend-expect'; + +function renderViewsWithRole(role: AccessibilityRole) { + return render( + <> + + + + + + ); +} + +test('toBePartiallyCheck() with checkbox role', () => { + renderViewsWithRole('checkbox'); + + const checked = screen.getByTestId('checkbox-checked'); + const unchecked = screen.getByTestId('checkbox-unchecked'); + const mixed = screen.getByTestId('checkbox-mixed'); + const defaultView = screen.getByTestId('checkbox-default'); + + expect(mixed).toBePartiallyChecked(); + + expect(checked).not.toBePartiallyChecked(); + expect(unchecked).not.toBePartiallyChecked(); + expect(defaultView).not.toBePartiallyChecked(); + + expect(() => expect(mixed).not.toBePartiallyChecked()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toBePartiallyChecked() + + Received element is partially checked: + " + `); + + expect(() => expect(checked).toBePartiallyChecked()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBePartiallyChecked() + + Received element is not partially checked: + " + `); + expect(() => expect(defaultView).toBePartiallyChecked()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBePartiallyChecked() + + Received element is not partially checked: + " + `); +}); + +test('toBeCheck() with radio role', () => { + renderViewsWithRole('radio'); + + const checked = screen.getByTestId('radio-checked'); + const mixed = screen.getByTestId('radio-mixed'); + + expect(() => + expect(checked).toBePartiallyChecked() + ).toThrowErrorMatchingInlineSnapshot( + `"toBePartiallyChecked() works only on accessibility elements with "checkbox" role."` + ); + expect(() => + expect(mixed).toBePartiallyChecked() + ).toThrowErrorMatchingInlineSnapshot( + `"toBePartiallyChecked() works only on accessibility elements with "checkbox" role."` + ); +}); diff --git a/src/matchers/extend-expect.d.ts b/src/matchers/extend-expect.d.ts index 7123d9d0b..781db2d38 100644 --- a/src/matchers/extend-expect.d.ts +++ b/src/matchers/extend-expect.d.ts @@ -2,9 +2,11 @@ import type { TextMatch, TextMatchOptions } from '../matches'; export interface JestNativeMatchers { toBeOnTheScreen(): R; + toBeChecked(): R; toBeDisabled(): R; toBeEmptyElement(): R; toBeEnabled(): R; + toBePartiallyChecked(): R; toBeVisible(): R; toHaveDisplayValue(expectedValue: TextMatch, options?: TextMatchOptions): R; toHaveProp(name: string, expectedValue?: unknown): R; diff --git a/src/matchers/extend-expect.ts b/src/matchers/extend-expect.ts index 601d158f9..25b5d3c16 100644 --- a/src/matchers/extend-expect.ts +++ b/src/matchers/extend-expect.ts @@ -1,8 +1,10 @@ /// import { toBeOnTheScreen } from './to-be-on-the-screen'; +import { toBeChecked } from './to-be-checked'; import { toBeDisabled, toBeEnabled } from './to-be-disabled'; import { toBeEmptyElement } from './to-be-empty-element'; +import { toBePartiallyChecked } from './to-be-partially-checked'; import { toBeVisible } from './to-be-visible'; import { toHaveDisplayValue } from './to-have-display-value'; import { toHaveProp } from './to-have-prop'; @@ -10,9 +12,11 @@ import { toHaveTextContent } from './to-have-text-content'; expect.extend({ toBeOnTheScreen, + toBeChecked, toBeDisabled, toBeEmptyElement, toBeEnabled, + toBePartiallyChecked, toBeVisible, toHaveDisplayValue, toHaveProp, diff --git a/src/matchers/index.tsx b/src/matchers/index.tsx index ffe850f05..72718a184 100644 --- a/src/matchers/index.tsx +++ b/src/matchers/index.tsx @@ -1,3 +1,9 @@ export { toBeOnTheScreen } from './to-be-on-the-screen'; +export { toBeChecked } from './to-be-checked'; +export { toBeDisabled, toBeEnabled } from './to-be-disabled'; export { toBeEmptyElement } from './to-be-empty-element'; +export { toBePartiallyChecked } from './to-be-partially-checked'; export { toBeVisible } from './to-be-visible'; +export { toHaveDisplayValue } from './to-have-display-value'; +export { toHaveProp } from './to-have-prop'; +export { toHaveTextContent } from './to-have-text-content'; diff --git a/src/matchers/to-be-checked.tsx b/src/matchers/to-be-checked.tsx new file mode 100644 index 000000000..7a9a67e0d --- /dev/null +++ b/src/matchers/to-be-checked.tsx @@ -0,0 +1,43 @@ +import type { ReactTestInstance } from 'react-test-renderer'; +import { matcherHint } from 'jest-matcher-utils'; +import { + getAccessibilityCheckedState, + getAccessibilityRole, + isAccessibilityElement, +} from '../helpers/accessiblity'; +import { ErrorWithStack } from '../helpers/errors'; +import { checkHostElement, formatElement } from './utils'; + +export function toBeChecked( + this: jest.MatcherContext, + element: ReactTestInstance +) { + checkHostElement(element, toBeChecked, this); + + if (!hasValidAccessibilityRole(element)) { + throw new ErrorWithStack( + `toBeChecked() works only on accessibility elements with "checkbox" or "radio" role.`, + toBeChecked + ); + } + + return { + pass: getAccessibilityCheckedState(element) === true, + message: () => { + const is = this.isNot ? 'is' : 'is not'; + return [ + matcherHint(`${this.isNot ? '.not' : ''}.toBeChecked`, 'element', ''), + '', + `Received element ${is} checked:`, + formatElement(element), + ].join('\n'); + }, + }; +} + +const VALID_ROLES = new Set(['checkbox', 'radio']); + +function hasValidAccessibilityRole(element: ReactTestInstance) { + const role = getAccessibilityRole(element); + return isAccessibilityElement(element) && VALID_ROLES.has(role); +} diff --git a/src/matchers/to-be-disabled.tsx b/src/matchers/to-be-disabled.tsx index 18be1bcc2..16bb96aa3 100644 --- a/src/matchers/to-be-disabled.tsx +++ b/src/matchers/to-be-disabled.tsx @@ -16,7 +16,7 @@ export function toBeDisabled( return { pass: isDisabled, message: () => { - const is = isDisabled ? 'is' : 'is not'; + const is = this.isNot ? 'is' : 'is not'; return [ matcherHint(`${this.isNot ? '.not' : ''}.toBeDisabled`, 'element', ''), '', @@ -38,7 +38,7 @@ export function toBeEnabled( return { pass: isEnabled, message: () => { - const is = isEnabled ? 'is' : 'is not'; + const is = this.isNot ? 'is' : 'is not'; return [ matcherHint(`${this.isNot ? '.not' : ''}.toBeEnabled`, 'element', ''), '', diff --git a/src/matchers/to-be-partially-checked.tsx b/src/matchers/to-be-partially-checked.tsx new file mode 100644 index 000000000..079d80269 --- /dev/null +++ b/src/matchers/to-be-partially-checked.tsx @@ -0,0 +1,45 @@ +import type { ReactTestInstance } from 'react-test-renderer'; +import { matcherHint } from 'jest-matcher-utils'; +import { + getAccessibilityCheckedState, + getAccessibilityRole, + isAccessibilityElement, +} from '../helpers/accessiblity'; +import { ErrorWithStack } from '../helpers/errors'; +import { checkHostElement, formatElement } from './utils'; + +export function toBePartiallyChecked( + this: jest.MatcherContext, + element: ReactTestInstance +) { + checkHostElement(element, toBePartiallyChecked, this); + + if (!hasValidAccessibilityRole(element)) { + throw new ErrorWithStack( + 'toBePartiallyChecked() works only on accessibility elements with "checkbox" role.', + toBePartiallyChecked + ); + } + + return { + pass: getAccessibilityCheckedState(element) === 'mixed', + message: () => { + const is = this.isNot ? 'is' : 'is not'; + return [ + matcherHint( + `${this.isNot ? '.not' : ''}.toBePartiallyChecked`, + 'element', + '' + ), + '', + `Received element ${is} partially checked:`, + formatElement(element), + ].join('\n'); + }, + }; +} + +function hasValidAccessibilityRole(element: ReactTestInstance) { + const role = getAccessibilityRole(element); + return isAccessibilityElement(element) && role === 'checkbox'; +}