From 10f2eab08dafcea02c78eb14042da995552a9bd6 Mon Sep 17 00:00:00 2001 From: Ryan Gossiaux Date: Sun, 26 Dec 2021 11:50:26 -0800 Subject: [PATCH] Add initial batch of Dialog tests --- .../components/dialog/_ManagedDialog.svelte | 24 ++ .../components/dialog/_TestTabSentinel.svelte | 2 + src/lib/components/dialog/dialog.test.ts | 358 ++++++++++++++++++ .../components/switch/_ManagedSwitch.svelte | 7 +- src/lib/test-utils/TestRenderer.svelte | 32 +- src/lib/test-utils/suppress-console-logs.ts | 17 + 6 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 src/lib/components/dialog/_ManagedDialog.svelte create mode 100644 src/lib/components/dialog/_TestTabSentinel.svelte create mode 100644 src/lib/components/dialog/dialog.test.ts create mode 100644 src/lib/test-utils/suppress-console-logs.ts diff --git a/src/lib/components/dialog/_ManagedDialog.svelte b/src/lib/components/dialog/_ManagedDialog.svelte new file mode 100644 index 0000000..19a3000 --- /dev/null +++ b/src/lib/components/dialog/_ManagedDialog.svelte @@ -0,0 +1,24 @@ + + +{#if buttonText !== null} + +{/if} + (state = e.detail)} + on:close={onClose} +> + + diff --git a/src/lib/components/dialog/_TestTabSentinel.svelte b/src/lib/components/dialog/_TestTabSentinel.svelte new file mode 100644 index 0000000..8463d0c --- /dev/null +++ b/src/lib/components/dialog/_TestTabSentinel.svelte @@ -0,0 +1,2 @@ +
+ diff --git a/src/lib/components/dialog/dialog.test.ts b/src/lib/components/dialog/dialog.test.ts new file mode 100644 index 0000000..ea8f2ff --- /dev/null +++ b/src/lib/components/dialog/dialog.test.ts @@ -0,0 +1,358 @@ +import { Dialog, DialogDescription, DialogOverlay, DialogTitle } from "." +import TestTabSentinel from "./_TestTabSentinel.svelte"; +import ManagedDialog from "./_ManagedDialog.svelte"; +import { suppressConsoleLogs } from "$lib/test-utils/suppress-console-logs"; +import { render } from "@testing-library/svelte"; +import TestRenderer from "$lib/test-utils/TestRenderer.svelte"; +import Button from "$lib/internal/elements/Button.svelte"; +import P from "$lib/internal/elements/P.svelte"; +import Input from "$lib/internal/elements/Input.svelte"; +import { assertDialog, assertDialogDescription, DialogState, getDialog } from "$lib/test-utils/accessibility-assertions"; +import { click, Keys, press } from "$lib/test-utils/interactions"; +import Transition from "$lib/components/transitions/TransitionRoot.svelte"; +import { tick } from "svelte"; + +jest.mock('../../hooks/use-id') + +// @ts-expect-error +global.IntersectionObserver = class FakeIntersectionObserver { + observe() { } + disconnect() { } +} + +afterAll(() => jest.restoreAllMocks()) + +describe('Safe guards', () => { + it.each([ + ['DialogOverlay', DialogOverlay], + ['DialogTitle', DialogTitle], + ])( + 'should error when we are using a <%s /> without a parent ', + suppressConsoleLogs((name, Component) => { + expect(() => render(Component)).toThrowError( + `<${name} /> is missing a parent component.` + ) + expect.hasAssertions() + }) + ) + + it( + 'should be possible to render a Dialog without crashing', + suppressConsoleLogs(async () => { + render( + TestRenderer, { + allProps: [ + Dialog, + { open: false, onClose: console.log }, + [ + [Button, + {}, + "Trigger"], + [DialogOverlay], + [DialogTitle], + [P, {}, "Contents"], + [DialogDescription] + ] + ] + }) + + assertDialog({ + state: DialogState.InvisibleUnmounted, + attributes: { id: 'headlessui-dialog-1' }, + }) + }) + ) +}) + +describe('Rendering', () => { + describe('Dialog', () => { + it( + 'should complain when the `open` and `onClose` prop are missing', + suppressConsoleLogs(async () => { + expect(() => render(Dialog, { as: "div" })).toThrowErrorMatchingInlineSnapshot( + `"You forgot to provide an \`open\` prop to the \`Dialog\` component."` + ) + expect.hasAssertions() + }) + ) + + it( + 'should complain when an `open` prop is not a boolean', + suppressConsoleLogs(async () => { + expect(() => + render( + TestRenderer, { + allProps: [ + Dialog, + { open: null, onClose: console.log, as: "div" }, + ] + }) + ).toThrowErrorMatchingInlineSnapshot( + `"You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: null"` + ) + expect.hasAssertions() + }) + ) + + // TODO: render prop tests! + + // it( + // 'should be possible to render a Dialog using a render prop', + // suppressConsoleLogs(async () => { + // function Example() { + // let [isOpen, setIsOpen] = useState(false) + + // return ( + // <> + // + // < /> + // ) + // } + // render() + + // assertDialog({ state: DialogState.InvisibleUnmounted }) + + // await click(document.getElementById('trigger')) + + // assertDialog({ state: DialogState.Visible, textContent: JSON.stringify({ open: true }) }) + // }) + // ) + + it('should be possible to always render the Dialog if we provide it a `static` prop (and enable focus trapping based on `open`)', async () => { + let focusCounter = jest.fn() + render( + TestRenderer, { + allProps: [ + [Button, {}, "Trigger"], + [Dialog, { open: true, onClose: console.log, static: true }, [ + [P, {}, "Contents"], + [TestTabSentinel, { onFocus: focusCounter }] + ]], + ] + }) + + // Wait for the focus to take effect + await tick(); + // Let's verify that the Dialog is already there + expect(getDialog()).not.toBe(null) + expect(focusCounter).toHaveBeenCalledTimes(1) + }) + + it('should be possible to always render the Dialog if we provide it a `static` prop (and disable focus trapping based on `open`)', () => { + let focusCounter = jest.fn() + render( + TestRenderer, { + allProps: [ + [Button, {}, "Trigger"], + [Dialog, { open: false, onClose: console.log, static: true }, [ + [P, {}, "Contents"], + [TestTabSentinel, { onFocus: focusCounter }] + ]], + ] + }) + + + // Let's verify that the Dialog is already there + expect(getDialog()).not.toBe(null) + expect(focusCounter).toHaveBeenCalledTimes(0) + }) + + it('should be possible to use a different render strategy for the Dialog', async () => { + let focusCounter = jest.fn() + render( + TestRenderer, { + allProps: [ + [ManagedDialog, { unmount: false, buttonText: "Trigger", buttonProps: { id: "trigger" } }, [ + [Input, { onFocus: focusCounter }], + ]], + ] + }) + + assertDialog({ state: DialogState.InvisibleHidden }) + expect(focusCounter).toHaveBeenCalledTimes(0) + + // Let's open the Dialog, to see if it is not hidden anymore + await click(document.getElementById('trigger')) + expect(focusCounter).toHaveBeenCalledTimes(1) + + assertDialog({ state: DialogState.Visible }) + + // Let's close the Dialog + await press(Keys.Escape) + expect(focusCounter).toHaveBeenCalledTimes(1) + + assertDialog({ state: DialogState.InvisibleHidden }) + }) + + it( + 'should add a scroll lock to the html tag', + suppressConsoleLogs(async () => { + render( + TestRenderer, { + allProps: [ + [ManagedDialog, { buttonText: "Trigger", buttonProps: { id: "trigger" } }, [ + [Input, { id: "a", type: "text" }], + [Input, { id: "b", type: "text" }], + [Input, { id: "c", type: "text" }], + ]], + ] + }) + + + // No overflow yet + expect(document.documentElement.style.overflow).toBe('') + + let btn = document.getElementById('trigger') + + // Open the dialog + await click(btn) + + // Expect overflow + expect(document.documentElement.style.overflow).toBe('hidden') + }) + ) + }) + // TODO: more render prop tests! + + // describe('Dialog.Overlay', () => { + // it( + // 'should be possible to render Dialog.Overlay using a render prop', + // suppressConsoleLogs(async () => { + // let overlay = jest.fn().mockReturnValue(null) + // function Example() { + // let [isOpen, setIsOpen] = useState(false) + // return ( + // <> + // + // + // {overlay} + // + // + // + // ) + // } + + // render() + + // assertDialogOverlay({ + // state: DialogState.InvisibleUnmounted, + // attributes: { id: 'headlessui-dialog-overlay-2' }, + // }) + + // await click(document.getElementById('trigger')) + + // assertDialogOverlay({ + // state: DialogState.Visible, + // attributes: { id: 'headlessui-dialog-overlay-2' }, + // }) + // expect(overlay).toHaveBeenCalledWith({ open: true }) + // }) + // ) + // }) + + // describe('Dialog.Title', () => { + // it( + // 'should be possible to render Dialog.Title using a render prop', + // suppressConsoleLogs(async () => { + // render( + // + // {JSON.stringify} + // + // + // ) + + // assertDialog({ + // state: DialogState.Visible, + // attributes: { id: 'headlessui-dialog-1' }, + // }) + // assertDialogTitle({ + // state: DialogState.Visible, + // textContent: JSON.stringify({ open: true }), + // }) + // }) + // ) + // }) + + // describe('Dialog.Description', () => { + // it( + // 'should be possible to render Dialog.Description using a render prop', + // suppressConsoleLogs(async () => { + // render( + // + // {JSON.stringify} + // + // + // ) + + // assertDialog({ + // state: DialogState.Visible, + // attributes: { id: 'headlessui-dialog-1' }, + // }) + // assertDialogDescription({ + // state: DialogState.Visible, + // textContent: JSON.stringify({ open: true }), + // }) + // }) + // ) + // }) +}) + +describe('Composition', () => { + it( + 'should be possible to open the Dialog via a Transition component', + suppressConsoleLogs(async () => { + render( + TestRenderer, { + allProps: [ + Transition, { show: true }, [ + Dialog, { onClose: console.log }, [ + [DialogDescription, {}, "Description"], + [TestTabSentinel] + ] + ] + ] + }) + + assertDialog({ state: DialogState.Visible }) + assertDialogDescription({ + state: DialogState.Visible, + textContent: "Description", + }) + }) + ) + + it( + 'should be possible to close the Dialog via a Transition component', + suppressConsoleLogs(async () => { + render( + TestRenderer, { + allProps: [ + Transition, { show: false }, [ + Dialog, { onClose: console.log }, [ + [DialogDescription, {}, "Description"], + [TestTabSentinel] + ] + ] + ] + }) + + assertDialog({ state: DialogState.InvisibleUnmounted }) + }) + ) +}) + + diff --git a/src/lib/components/switch/_ManagedSwitch.svelte b/src/lib/components/switch/_ManagedSwitch.svelte index 55423df..c94bb44 100644 --- a/src/lib/components/switch/_ManagedSwitch.svelte +++ b/src/lib/components/switch/_ManagedSwitch.svelte @@ -3,9 +3,14 @@ // This component is only for use in tests export let initialChecked = false; + export let onChange = () => {}; let state = initialChecked; - (state = e.detail)} on:change> + (state = e.detail)} + on:change={onChange} +> diff --git a/src/lib/test-utils/TestRenderer.svelte b/src/lib/test-utils/TestRenderer.svelte index 215c920..06d013f 100644 --- a/src/lib/test-utils/TestRenderer.svelte +++ b/src/lib/test-utils/TestRenderer.svelte @@ -2,6 +2,8 @@ type HandlerType = (event?: CustomEvent) => any; interface ComponentProps { onChange?: HandlerType; + onClose?: HandlerType; + onFocus?: HandlerType; } type SingleComponent = [SvelteComponent, ComponentProps, TestRendererProps]; export type TestRendererProps = @@ -9,22 +11,30 @@ | string | SingleComponent | SingleComponent[]; - - - + + @@ -36,7 +46,13 @@ {/each} {:else} - + {/if} diff --git a/src/lib/test-utils/suppress-console-logs.ts b/src/lib/test-utils/suppress-console-logs.ts new file mode 100644 index 0000000..da8e6d1 --- /dev/null +++ b/src/lib/test-utils/suppress-console-logs.ts @@ -0,0 +1,17 @@ +type FunctionPropertyNames = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never +}[keyof T] & + string + +export function suppressConsoleLogs( + cb: (...args: T) => unknown, + type: FunctionPropertyNames = 'error' +) { + return (...args: T) => { + let spy = jest.spyOn(global.console, type).mockImplementation(jest.fn()) + + return new Promise((resolve, reject) => { + Promise.resolve(cb(...args)).then(resolve, reject) + }).finally(() => spy.mockRestore()) + } +}