From 65b1ee2cd73341c6ca65c4bad439d7a480517b06 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 14 Oct 2022 14:54:26 +0200 Subject: [PATCH] Navigator: refactor tests to TypeScript and user-event (#44970) * Rename file * Fix types for framer-motion mock * Fix types for helper components * Fix types for helper getter functions * Fix types for getClientRects mock * Switch from fireEvent to user-event * Extract all screen and button text to a variable * Fix getters errors by removing unnecessary complexity * CHANGELOG * Format other changelog entries --- packages/components/CHANGELOG.md | 5 +- .../components/src/navigator/test/index.js | 472 ------------------ .../components/src/navigator/test/index.tsx | 470 +++++++++++++++++ 3 files changed, 473 insertions(+), 474 deletions(-) delete mode 100644 packages/components/src/navigator/test/index.js create mode 100644 packages/components/src/navigator/test/index.tsx diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 206aac7a711a7b..b98fda2e4373c1 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -4,15 +4,16 @@ ### Bug Fix -- `FontSizePicker`: Ensure that fluid font size presets appear correctly in the UI controls ([#44791](https://github.com/WordPress/gutenberg/pull/44791)) +- `FontSizePicker`: Ensure that fluid font size presets appear correctly in the UI controls ([#44791](https://github.com/WordPress/gutenberg/pull/44791)). ### Documentation -- `VisuallyHidden`: Add some notes on best practices around stacking contexts when using this component ([#44867](https://github.com/WordPress/gutenberg/pull/44867)) +- `VisuallyHidden`: Add some notes on best practices around stacking contexts when using this component ([#44867](https://github.com/WordPress/gutenberg/pull/44867)). ### Internal - `Modal`: Convert to TypeScript ([#42949](https://github.com/WordPress/gutenberg/pull/42949)). +- `Navigator`: refactor unit tests to TypeScript and to `user-event` ([#44970](https://github.com/WordPress/gutenberg/pull/44970)). ## 21.2.0 (2022-10-05) diff --git a/packages/components/src/navigator/test/index.js b/packages/components/src/navigator/test/index.js deleted file mode 100644 index 7374ede686e815..00000000000000 --- a/packages/components/src/navigator/test/index.js +++ /dev/null @@ -1,472 +0,0 @@ -/** - * External dependencies - */ -import { render, screen, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { - NavigatorProvider, - NavigatorScreen, - NavigatorButton, - NavigatorBackButton, -} from '../'; - -jest.mock( 'framer-motion', () => { - const actual = jest.requireActual( 'framer-motion' ); - return { - __esModule: true, - ...actual, - AnimatePresence: ( { children } ) =>
{ children }
, - motion: { - ...actual.motion, - div: require( 'react' ).forwardRef( ( { children }, ref ) => ( -
{ children }
- ) ), - }, - }; -} ); - -const INVALID_HTML_ATTRIBUTE = { - raw: ' "\'><=invalid_path', - escaped: " "'><=invalid_path", -}; - -const PATHS = { - HOME: '/', - CHILD: '/child', - NESTED: '/child/nested', - INVALID_HTML_ATTRIBUTE: INVALID_HTML_ATTRIBUTE.raw, - NOT_FOUND: '/not-found', -}; - -function CustomNavigatorButton( { path, onClick, ...props } ) { - return ( - { - // Used to spy on the values passed to `navigator.goTo`. - onClick?.( { type: 'goTo', path } ); - } } - path={ path } - { ...props } - /> - ); -} - -function CustomNavigatorButtonWithFocusRestoration( { - path, - onClick, - ...props -} ) { - return ( - { - // Used to spy on the values passed to `navigator.goTo`. - onClick?.( { type: 'goTo', path } ); - } } - path={ path } - { ...props } - /> - ); -} - -function CustomNavigatorBackButton( { onClick, ...props } ) { - return ( - { - // Used to spy on the values passed to `navigator.goBack`. - onClick?.( { type: 'goBack' } ); - } } - { ...props } - /> - ); -} - -const MyNavigation = ( { - initialPath = PATHS.HOME, - onNavigatorButtonClick, -} ) => { - const [ inputValue, setInputValue ] = useState( '' ); - return ( - - -

This is the home screen.

- - Navigate to non-existing screen. - - - Navigate to child screen. - - - Navigate to screen with an invalid HTML value as a path. - -
- - -

This is the child screen.

- - Navigate to nested screen. - - - Go back - - - - { - setInputValue( e.target.value ); - } } - value={ inputValue } - /> -
- - -

This is the nested screen.

- - Go back - -
- - -

This is the screen with an invalid HTML value as a path.

- - Go back - -
- - { /* A `NavigatorScreen` with `path={ PATHS.NOT_FOUND }` is purposefully not included. */ } -
- ); -}; - -const getNavigationScreenByText = ( text, { throwIfNotFound = true } = {} ) => { - const fnName = throwIfNotFound ? 'getByText' : 'queryByText'; - return screen[ fnName ]( text ); -}; -const getHomeScreen = ( { throwIfNotFound } = {} ) => - getNavigationScreenByText( 'This is the home screen.', { - throwIfNotFound, - } ); -const getChildScreen = ( { throwIfNotFound } = {} ) => - getNavigationScreenByText( 'This is the child screen.', { - throwIfNotFound, - } ); -const getNestedScreen = ( { throwIfNotFound } = {} ) => - getNavigationScreenByText( 'This is the nested screen.', { - throwIfNotFound, - } ); -const getInvalidHTMLPathScreen = ( { throwIfNotFound } = {} ) => - getNavigationScreenByText( - 'This is the screen with an invalid HTML value as a path.', - { - throwIfNotFound, - } - ); - -const getNavigationButtonByText = ( text, { throwIfNotFound = true } = {} ) => { - const fnName = throwIfNotFound ? 'getByRole' : 'queryByRole'; - return screen[ fnName ]( 'button', { name: text } ); -}; -const getToNonExistingScreenButton = ( { throwIfNotFound } = {} ) => - getNavigationButtonByText( 'Navigate to non-existing screen.', { - throwIfNotFound, - } ); -const getToChildScreenButton = ( { throwIfNotFound } = {} ) => - getNavigationButtonByText( 'Navigate to child screen.', { - throwIfNotFound, - } ); -const getToNestedScreenButton = ( { throwIfNotFound } = {} ) => - getNavigationButtonByText( 'Navigate to nested screen.', { - throwIfNotFound, - } ); -const getToInvalidHTMLPathScreenButton = ( { throwIfNotFound } = {} ) => - getNavigationButtonByText( - 'Navigate to screen with an invalid HTML value as a path.', - { - throwIfNotFound, - } - ); -const getBackButton = ( { throwIfNotFound } = {} ) => - getNavigationButtonByText( 'Go back', { - throwIfNotFound, - } ); - -describe( 'Navigator', () => { - const originalGetClientRects = window.Element.prototype.getClientRects; - - // `getClientRects` needs to be mocked so that `isVisible` from the `@wordpress/dom` - // `focusable` module can pass, in a JSDOM env where the DOM elements have no width/height. - const mockedGetClientRects = jest.fn( () => [ - { - x: 0, - y: 0, - width: 100, - height: 100, - }, - ] ); - - beforeAll( () => { - window.Element.prototype.getClientRects = - jest.fn( mockedGetClientRects ); - } ); - - afterAll( () => { - window.Element.prototype.getClientRects = originalGetClientRects; - } ); - - it( 'should render', () => { - render( ); - - expect( getHomeScreen() ).toBeInTheDocument(); - expect( - getChildScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - expect( - getNestedScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - } ); - - it( 'should show a different screen on the first render depending on the value of `initialPath`', () => { - render( ); - - expect( - getHomeScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - expect( getChildScreen() ).toBeInTheDocument(); - expect( - getNestedScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - } ); - - it( 'should ignore changes to `initialPath` after the first render', () => { - const { rerender } = render( ); - - expect( getHomeScreen() ).toBeInTheDocument(); - expect( - getChildScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - expect( - getNestedScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - - rerender( ); - - expect( getHomeScreen() ).toBeInTheDocument(); - expect( - getChildScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - expect( - getNestedScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - } ); - - it( 'should not rended anything if the `initialPath` does not match any available screen', () => { - render( ); - - expect( - getHomeScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - expect( - getChildScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - expect( - getNestedScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - } ); - - it( 'should navigate across screens', () => { - const spy = jest.fn(); - - render( ); - - expect( getHomeScreen() ).toBeInTheDocument(); - expect( getToChildScreenButton() ).toBeInTheDocument(); - - // Navigate to child screen. - fireEvent.click( getToChildScreenButton() ); - - expect( getChildScreen() ).toBeInTheDocument(); - expect( getBackButton() ).toBeInTheDocument(); - - // Navigate back to home screen. - fireEvent.click( getBackButton() ); - expect( getHomeScreen() ).toBeInTheDocument(); - expect( getToChildScreenButton() ).toBeInTheDocument(); - - // Navigate again to child screen. - fireEvent.click( getToChildScreenButton() ); - - expect( getChildScreen() ).toBeInTheDocument(); - expect( getToNestedScreenButton() ).toBeInTheDocument(); - - // Navigate to nested screen. - fireEvent.click( getToNestedScreenButton() ); - - expect( getNestedScreen() ).toBeInTheDocument(); - expect( getBackButton() ).toBeInTheDocument(); - - // Navigate back to child screen. - fireEvent.click( getBackButton() ); - - expect( getChildScreen() ).toBeInTheDocument(); - expect( getToNestedScreenButton() ).toBeInTheDocument(); - - // Navigate back to home screen. - fireEvent.click( getBackButton() ); - - expect( getHomeScreen() ).toBeInTheDocument(); - expect( getToChildScreenButton() ).toBeInTheDocument(); - - // Check the values passed to `navigator.goTo()`. - expect( spy ).toHaveBeenCalledTimes( 6 ); - expect( spy ).toHaveBeenNthCalledWith( 1, { - path: PATHS.CHILD, - type: 'goTo', - } ); - expect( spy ).toHaveBeenNthCalledWith( 2, { - type: 'goBack', - } ); - expect( spy ).toHaveBeenNthCalledWith( 3, { - path: PATHS.CHILD, - type: 'goTo', - } ); - expect( spy ).toHaveBeenNthCalledWith( 4, { - path: PATHS.NESTED, - type: 'goTo', - } ); - expect( spy ).toHaveBeenNthCalledWith( 5, { - type: 'goBack', - } ); - expect( spy ).toHaveBeenNthCalledWith( 6, { - type: 'goBack', - } ); - } ); - - it( 'should not rended anything if the path does not match any available screen', () => { - const spy = jest.fn(); - - render( ); - - expect( getToNonExistingScreenButton() ).toBeInTheDocument(); - - // Attempt to navigate to non-existing screen. No screens get rendered. - fireEvent.click( getToNonExistingScreenButton() ); - - expect( - getHomeScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - expect( - getChildScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - expect( - getNestedScreen( { throwIfNotFound: false } ) - ).not.toBeInTheDocument(); - - // Check the values passed to `navigator.goTo()`. - expect( spy ).toHaveBeenCalledTimes( 1 ); - expect( spy ).toHaveBeenNthCalledWith( 1, { - path: PATHS.NOT_FOUND, - type: 'goTo', - } ); - } ); - - it( 'should restore focus correctly', () => { - render( ); - - expect( getHomeScreen() ).toBeInTheDocument(); - - // Navigate to child screen. - fireEvent.click( getToChildScreenButton() ); - - expect( getChildScreen() ).toBeInTheDocument(); - - // Navigate to nested screen. - fireEvent.click( getToNestedScreenButton() ); - - expect( getNestedScreen() ).toBeInTheDocument(); - - // Navigate back to child screen, check that focus was correctly restored. - fireEvent.click( getBackButton() ); - - expect( getChildScreen() ).toBeInTheDocument(); - expect( getToNestedScreenButton() ).toHaveFocus(); - - // Navigate back to home screen, check that focus was correctly restored. - fireEvent.click( getBackButton() ); - - expect( getHomeScreen() ).toBeInTheDocument(); - expect( getToChildScreenButton() ).toHaveFocus(); - } ); - - it( 'should escape the value of the `path` prop', () => { - render( ); - - expect( getHomeScreen() ).toBeInTheDocument(); - expect( getToInvalidHTMLPathScreenButton() ).toBeInTheDocument(); - - // The following line tests the implementation details, but it's necessary - // as this would be otherwise transparent to the user. - expect( getToInvalidHTMLPathScreenButton() ).toHaveAttribute( - 'id', - INVALID_HTML_ATTRIBUTE.escaped - ); - - // Navigate to screen with an invalid HTML value for its `path`. - fireEvent.click( getToInvalidHTMLPathScreenButton() ); - - expect( getInvalidHTMLPathScreen() ).toBeInTheDocument(); - expect( getBackButton() ).toBeInTheDocument(); - - // Navigate back to home screen, check that the focus restoration selector - // worked correctly despite the escaping. - fireEvent.click( getBackButton() ); - - expect( getHomeScreen() ).toBeInTheDocument(); - expect( getToInvalidHTMLPathScreenButton() ).toHaveFocus(); - } ); - - it( 'should keep focus on the element that is being interacted with, while re-rendering', async () => { - const user = userEvent.setup( { - advanceTimers: jest.advanceTimersByTime, - } ); - - render( ); - - expect( getHomeScreen() ).toBeInTheDocument(); - expect( getToChildScreenButton() ).toBeInTheDocument(); - - // Navigate to child screen. - await user.click( getToChildScreenButton() ); - - expect( getChildScreen() ).toBeInTheDocument(); - expect( getBackButton() ).toBeInTheDocument(); - expect( getToNestedScreenButton() ).toHaveFocus(); - - // Interact with the input, the focus should stay on the input element. - const input = screen.getByLabelText( 'This is a test input' ); - await user.type( input, 'd' ); - expect( input ).toHaveFocus(); - } ); -} ); diff --git a/packages/components/src/navigator/test/index.tsx b/packages/components/src/navigator/test/index.tsx new file mode 100644 index 00000000000000..ffdaf0e2c19678 --- /dev/null +++ b/packages/components/src/navigator/test/index.tsx @@ -0,0 +1,470 @@ +/** + * External dependencies + */ +import type { ReactNode, ForwardedRef, ComponentPropsWithoutRef } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + NavigatorProvider, + NavigatorScreen, + NavigatorButton, + NavigatorBackButton, +} from '..'; + +jest.mock( 'framer-motion', () => { + const actual = jest.requireActual( 'framer-motion' ); + return { + __esModule: true, + ...actual, + AnimatePresence: + ( { children }: { children?: ReactNode } ) => + () => +
{ children }
, + motion: { + ...actual.motion, + div: require( 'react' ).forwardRef( + ( + { children }: { children?: ReactNode }, + ref: ForwardedRef< HTMLDivElement > + ) =>
{ children }
+ ), + }, + }; +} ); + +const INVALID_HTML_ATTRIBUTE = { + raw: ' "\'><=invalid_path', + escaped: " "'><=invalid_path", +}; + +const PATHS = { + HOME: '/', + CHILD: '/child', + NESTED: '/child/nested', + INVALID_HTML_ATTRIBUTE: INVALID_HTML_ATTRIBUTE.raw, + NOT_FOUND: '/not-found', +}; + +const SCREEN_TEXT = { + home: 'This is the home screen.', + child: 'This is the child screen.', + nested: 'This is the nested screen.', + invalidHtmlPath: 'This is the screen with an invalid HTML value as a path.', +}; + +const BUTTON_TEXT = { + toNonExistingScreen: 'Navigate to non-existing screen.', + toChildScreen: 'Navigate to child screen.', + toNestedScreen: 'Navigate to nested screen.', + toInvalidHtmlPathScreen: + 'Navigate to screen with an invalid HTML value as a path.', + back: 'Go back', +}; + +type CustomTestOnClickHandler = ( + args: + | { + type: 'goTo'; + path: string; + } + | { type: 'goBack' } +) => void; + +function CustomNavigatorButton( { + path, + onClick, + ...props +}: Omit< ComponentPropsWithoutRef< typeof NavigatorButton >, 'onClick' > & { + onClick?: CustomTestOnClickHandler; +} ) { + return ( + { + // Used to spy on the values passed to `navigator.goTo`. + onClick?.( { type: 'goTo', path } ); + } } + path={ path } + { ...props } + /> + ); +} + +function CustomNavigatorButtonWithFocusRestoration( { + path, + onClick, + ...props +}: Omit< ComponentPropsWithoutRef< typeof NavigatorButton >, 'onClick' > & { + onClick?: CustomTestOnClickHandler; +} ) { + return ( + { + // Used to spy on the values passed to `navigator.goTo`. + onClick?.( { type: 'goTo', path } ); + } } + path={ path } + { ...props } + /> + ); +} + +function CustomNavigatorBackButton( { + onClick, + ...props +}: Omit< ComponentPropsWithoutRef< typeof NavigatorBackButton >, 'onClick' > & { + onClick?: CustomTestOnClickHandler; +} ) { + return ( + { + // Used to spy on the values passed to `navigator.goBack`. + onClick?.( { type: 'goBack' } ); + } } + { ...props } + /> + ); +} + +const MyNavigation = ( { + initialPath = PATHS.HOME, + onNavigatorButtonClick, +}: { + initialPath?: string; + onNavigatorButtonClick?: CustomTestOnClickHandler; +} ) => { + const [ inputValue, setInputValue ] = useState( '' ); + return ( + + +

{ SCREEN_TEXT.home }

+ + { BUTTON_TEXT.toNonExistingScreen } + + + { BUTTON_TEXT.toChildScreen } + + + { BUTTON_TEXT.toInvalidHtmlPathScreen } + +
+ + +

{ SCREEN_TEXT.child }

+ + { BUTTON_TEXT.toNestedScreen } + + + { BUTTON_TEXT.back } + + + + { + setInputValue( e.target.value ); + } } + value={ inputValue } + /> +
+ + +

{ SCREEN_TEXT.nested }

+ + { BUTTON_TEXT.back } + +
+ + +

{ SCREEN_TEXT.invalidHtmlPath }

+ + { BUTTON_TEXT.back } + +
+ + { /* A `NavigatorScreen` with `path={ PATHS.NOT_FOUND }` is purposefully not included. */ } +
+ ); +}; + +const getScreen = ( screenKey: keyof typeof SCREEN_TEXT ) => + screen.getByText( SCREEN_TEXT[ screenKey ] ); +const queryScreen = ( screenKey: keyof typeof SCREEN_TEXT ) => + screen.queryByText( SCREEN_TEXT[ screenKey ] ); +const getNavigationButton = ( buttonKey: keyof typeof BUTTON_TEXT ) => + screen.getByRole( 'button', { name: BUTTON_TEXT[ buttonKey ] } ); + +describe( 'Navigator', () => { + const originalGetClientRects = window.Element.prototype.getClientRects; + + // `getClientRects` needs to be mocked so that `isVisible` from the `@wordpress/dom` + // `focusable` module can pass, in a JSDOM env where the DOM elements have no width/height. + const mockedGetClientRects = jest.fn( () => [ + { + x: 0, + y: 0, + width: 100, + height: 100, + }, + ] ); + + beforeAll( () => { + // @ts-expect-error There's no need for an exact mock, this is just needed + // for the tests to pass (see `mockedGetClientRects` inline comments). + window.Element.prototype.getClientRects = + jest.fn( mockedGetClientRects ); + } ); + + afterAll( () => { + window.Element.prototype.getClientRects = originalGetClientRects; + } ); + + it( 'should render', () => { + render( ); + + expect( getScreen( 'home' ) ).toBeInTheDocument(); + expect( queryScreen( 'child' ) ).not.toBeInTheDocument(); + expect( queryScreen( 'nested' ) ).not.toBeInTheDocument(); + } ); + + it( 'should show a different screen on the first render depending on the value of `initialPath`', () => { + render( ); + + expect( queryScreen( 'home' ) ).not.toBeInTheDocument(); + expect( getScreen( 'child' ) ).toBeInTheDocument(); + expect( queryScreen( 'nested' ) ).not.toBeInTheDocument(); + } ); + + it( 'should ignore changes to `initialPath` after the first render', () => { + const { rerender } = render( ); + + expect( getScreen( 'home' ) ).toBeInTheDocument(); + expect( queryScreen( 'child' ) ).not.toBeInTheDocument(); + expect( queryScreen( 'nested' ) ).not.toBeInTheDocument(); + + rerender( ); + + expect( getScreen( 'home' ) ).toBeInTheDocument(); + expect( queryScreen( 'child' ) ).not.toBeInTheDocument(); + expect( queryScreen( 'nested' ) ).not.toBeInTheDocument(); + } ); + + it( 'should not rended anything if the `initialPath` does not match any available screen', () => { + render( ); + + expect( queryScreen( 'home' ) ).not.toBeInTheDocument(); + expect( queryScreen( 'child' ) ).not.toBeInTheDocument(); + expect( queryScreen( 'nested' ) ).not.toBeInTheDocument(); + } ); + + it( 'should navigate across screens', async () => { + const spy = jest.fn(); + + const user = userEvent.setup( { + advanceTimers: jest.advanceTimersByTime, + } ); + + render( ); + + expect( getScreen( 'home' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'toChildScreen' ) ).toBeInTheDocument(); + + // Navigate to child screen. + await user.click( getNavigationButton( 'toChildScreen' ) ); + + expect( getScreen( 'child' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'back' ) ).toBeInTheDocument(); + + // Navigate back to home screen. + await user.click( getNavigationButton( 'back' ) ); + expect( getScreen( 'home' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'toChildScreen' ) ).toBeInTheDocument(); + + // Navigate again to child screen. + await user.click( getNavigationButton( 'toChildScreen' ) ); + + expect( getScreen( 'child' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'toNestedScreen' ) ).toBeInTheDocument(); + + // Navigate to nested screen. + await user.click( getNavigationButton( 'toNestedScreen' ) ); + + expect( getScreen( 'nested' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'back' ) ).toBeInTheDocument(); + + // Navigate back to child screen. + await user.click( getNavigationButton( 'back' ) ); + + expect( getScreen( 'child' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'toNestedScreen' ) ).toBeInTheDocument(); + + // Navigate back to home screen. + await user.click( getNavigationButton( 'back' ) ); + + expect( getScreen( 'home' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'toChildScreen' ) ).toBeInTheDocument(); + + // Check the values passed to `navigator.goTo()`. + expect( spy ).toHaveBeenCalledTimes( 6 ); + expect( spy ).toHaveBeenNthCalledWith( 1, { + path: PATHS.CHILD, + type: 'goTo', + } ); + expect( spy ).toHaveBeenNthCalledWith( 2, { + type: 'goBack', + } ); + expect( spy ).toHaveBeenNthCalledWith( 3, { + path: PATHS.CHILD, + type: 'goTo', + } ); + expect( spy ).toHaveBeenNthCalledWith( 4, { + path: PATHS.NESTED, + type: 'goTo', + } ); + expect( spy ).toHaveBeenNthCalledWith( 5, { + type: 'goBack', + } ); + expect( spy ).toHaveBeenNthCalledWith( 6, { + type: 'goBack', + } ); + } ); + + it( 'should not rended anything if the path does not match any available screen', async () => { + const spy = jest.fn(); + + const user = userEvent.setup( { + advanceTimers: jest.advanceTimersByTime, + } ); + + render( ); + + expect( + getNavigationButton( 'toNonExistingScreen' ) + ).toBeInTheDocument(); + + // Attempt to navigate to non-existing screen. No screens get rendered. + await user.click( getNavigationButton( 'toNonExistingScreen' ) ); + + expect( queryScreen( 'home' ) ).not.toBeInTheDocument(); + expect( queryScreen( 'child' ) ).not.toBeInTheDocument(); + expect( queryScreen( 'nested' ) ).not.toBeInTheDocument(); + + // Check the values passed to `navigator.goTo()`. + expect( spy ).toHaveBeenCalledTimes( 1 ); + expect( spy ).toHaveBeenNthCalledWith( 1, { + path: PATHS.NOT_FOUND, + type: 'goTo', + } ); + } ); + + it( 'should restore focus correctly', async () => { + const user = userEvent.setup( { + advanceTimers: jest.advanceTimersByTime, + } ); + + render( ); + + expect( getScreen( 'home' ) ).toBeInTheDocument(); + + // Navigate to child screen. + await user.click( getNavigationButton( 'toChildScreen' ) ); + + expect( getScreen( 'child' ) ).toBeInTheDocument(); + + // Navigate to nested screen. + await user.click( getNavigationButton( 'toNestedScreen' ) ); + + expect( getScreen( 'nested' ) ).toBeInTheDocument(); + + // Navigate back to child screen, check that focus was correctly restored. + await user.click( getNavigationButton( 'back' ) ); + + expect( getScreen( 'child' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'toNestedScreen' ) ).toHaveFocus(); + + // Navigate back to home screen, check that focus was correctly restored. + await user.click( getNavigationButton( 'back' ) ); + + expect( getScreen( 'home' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'toChildScreen' ) ).toHaveFocus(); + } ); + + it( 'should escape the value of the `path` prop', async () => { + const user = userEvent.setup( { + advanceTimers: jest.advanceTimersByTime, + } ); + + render( ); + + expect( getScreen( 'home' ) ).toBeInTheDocument(); + expect( + getNavigationButton( 'toInvalidHtmlPathScreen' ) + ).toBeInTheDocument(); + + // The following line tests the implementation details, but it's necessary + // as this would be otherwise transparent to the user. + expect( + getNavigationButton( 'toInvalidHtmlPathScreen' ) + ).toHaveAttribute( 'id', INVALID_HTML_ATTRIBUTE.escaped ); + + // Navigate to screen with an invalid HTML value for its `path`. + await user.click( getNavigationButton( 'toInvalidHtmlPathScreen' ) ); + + expect( getScreen( 'invalidHtmlPath' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'back' ) ).toBeInTheDocument(); + + // Navigate back to home screen, check that the focus restoration selector + // worked correctly despite the escaping. + await user.click( getNavigationButton( 'back' ) ); + + expect( getScreen( 'home' ) ).toBeInTheDocument(); + expect( + getNavigationButton( 'toInvalidHtmlPathScreen' ) + ).toHaveFocus(); + } ); + + it( 'should keep focus on the element that is being interacted with, while re-rendering', async () => { + const user = userEvent.setup( { + advanceTimers: jest.advanceTimersByTime, + } ); + + render( ); + + expect( getScreen( 'home' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'toChildScreen' ) ).toBeInTheDocument(); + + // Navigate to child screen. + await user.click( getNavigationButton( 'toChildScreen' ) ); + + expect( getScreen( 'child' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'back' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'toNestedScreen' ) ).toHaveFocus(); + + // Interact with the input, the focus should stay on the input element. + const input = screen.getByLabelText( 'This is a test input' ); + await user.type( input, 'd' ); + expect( input ).toHaveFocus(); + } ); +} );