From 68618cc9cdd3da8041267eaca89c0ae7c4003489 Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Thu, 2 Jun 2022 19:27:53 +0200 Subject: [PATCH] Fix and improve feature tests --- .npmrc | 3 + CONTRIBUTING.md | 112 ++++ packages/app/src/App.tsx | 6 +- .../{LoadingFallback.tsx => EntityLoader.tsx} | 9 +- packages/app/src/__tests__/CorePack.test.tsx | 180 ++--- .../src/__tests__/DimensionMapper.test.tsx | 70 +- .../app/src/__tests__/DomainSlider.test.tsx | 248 +++---- packages/app/src/__tests__/Explorer.test.tsx | 64 +- .../app/src/__tests__/MetadataViewer.test.tsx | 85 +-- packages/app/src/__tests__/NexusPack.test.tsx | 614 ++++++------------ .../app/src/__tests__/VisSelector.test.tsx | 72 +- .../app/src/__tests__/Visualizer.test.tsx | 204 +++--- .../src/dimension-mapper/SlicingSlider.tsx | 15 +- packages/app/src/explorer/EntityItem.tsx | 3 +- packages/app/src/explorer/Explorer.tsx | 1 + .../src/metadata-viewer/AttrValueLoader.tsx | 2 +- packages/app/src/providers/mock/mock-api.ts | 2 +- packages/app/src/setupTests.ts | 6 + packages/app/src/test-utils.tsx | 101 ++- packages/app/src/vis-packs/ValueLoader.tsx | 67 +- .../src/vis-packs/core/line/MappedLineVis.tsx | 1 + .../controls/DomainSlider/BoundEditor.tsx | 2 + .../controls/DomainSlider/DomainSlider.tsx | 2 +- .../controls/DomainSlider/DomainTooltip.tsx | 2 + .../toolbar/controls/DomainSlider/Thumb.tsx | 1 + packages/lib/src/vis/line/LineVis.tsx | 3 + packages/lib/src/vis/matrix/StickyGrid.tsx | 1 + pnpm-lock.yaml | 20 +- 28 files changed, 906 insertions(+), 990 deletions(-) rename packages/app/src/{LoadingFallback.tsx => EntityLoader.tsx} (69%) diff --git a/.npmrc b/.npmrc index 4020d069e..a1ac2fda6 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,6 @@ +# Enforce Node and pnpm versions as specified in `package.json` +engine-strict=true + # Save exact dependency versions in `package.json` save-prefix="" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c3a0f739..fad322ea2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,9 @@ - [Fixing and formatting](#fixing-and-formatting) - [Editor integration](#editor-integration) - [Testing](#testing) + - [Feature tests](#feature-tests) + - [Fake timers](#fake-timers) + - [Debugging](#debugging) - [Visual regression](#visual-regression) - [Deployment](#deployment) - [Release process](#release-process) @@ -204,6 +207,115 @@ install the recommended extensions. > located in `jest.config.json`. This results in a nicer terminal output when > running tests on the entire workspace. +### Feature tests + +The `@h5web/app` package includes feature tests written with +[React Testing Library](https://testing-library.com/docs/react-testing-library/intro). +They are located under `src/__tests__`. Each file covers a particular subtree of +components of H5Web. + +H5Web's feature tests typically consist in rendering the entire app with mock +data (i.e. inside `MockProvider`), executing an action like a real user would +(e.g. clicking on a button, pressing a key, etc.), and then expecting something +to happen in the DOM as a result. Most tests, perform multiple actions and +expectations consecutively to minimise the overhead of rendering the entire app +again and again. + +`MockProvider` resolves most requests instantaneously to save time in tests, but +its API's methods are still called asynchronously like other providers. This +means that during tests, `Suspense` loading fallbacks render just like they +would normally; they just don't stick around in the DOM for long. + +This adds a bit of complexity when testing, as React doesn't like when something +happens after a test has completed. In fact, we have to ensure that every +component that suspends inside a test **finishes loading before the end of that +test**. This is where Testing Library's +[asynchronous methods](https://testing-library.com/docs/dom-testing-library/api-async) +come in. + +To keep tests readable and focused, H5Web's testing utilities `renderApp` and +`selectExplorerNode` call Testing Library's `waitFor` utility for you to wait +for suspended components to finish loading (cf. `waitForAllLoaders` in +`test-utils.tsx`). As a result, in most cases, you can just use synchronous +queries like `getBy` in your tests. + +#### Fake timers + +To allow developing and testing loading interfaces, as well as features like +cancel/retry, `MockProvider` adds an artificial delay of 3s (`SLOW_TIMEOUT`) to +some requests, notably to value requests for datasets prefixed with `slow_`. + +In order for this artificial delay to not slow down feature tests, we must use +[fake timers](https://testing-library.com/docs/using-fake-timers/). This is done +by setting the `withFakeTimers` option when calling `renderApp()`: + +```ts +renderApp({ withFakeTimers: true }); +``` + +When `withFakeTimers` is set, `renderApp` and `selectExplorerNode` no longer +wait for suspended components to finish loading. It is up to you to wait for and +expect the loaders you're interested in testing to appear, and then to wait for +and expect their corresponding suspended components to replace them on the +screen. You can do so with Testing Library's async methods: `findBy` or +`waitFor`. + +Indeed, when Jest's fake timers are enabled, `findBy` and `waitFor` use +`jest.advanceTimersByTime()` internally to simulate the passing of time. By +default, they advance the clock by intervals of +[50 ms](https://github.com/testing-library/dom-testing-library/blob/main/src/wait-for.js#L25) +and give up after +[1000 ms](https://github.com/testing-library/dom-testing-library/blob/main/src/config.ts#L14) +(20 attempts). Therefore, the easiest way to skip the artificial delay +introduced by `MockProvider` is to configure the `waitFor` timeout: + +```ts +import { SLOW_TIMEOUT } from '../providers/mock/mock-api'; +import { renderApp } from '../test-utils'; + +test('show loader while fetching dataset value', async () => { + await renderApp({ + initialPath: '/resilience/slow_value', + withFakeTimers: true, + }); + + // Wait for value loader to appear + await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); + + // Wait for all slow fetches to succeed (i.e. for visualization to appear) + await expect( + screen.findByText(/42/, undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); +}); +``` + +> It's important _not_ to use Jest's `advanceTimersByTime` directly to avoid +> [`act()` warnings](https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning#the-dreaded-act-warning). + +Note that when using fake timers, you may also need to use `findBy` and +`waitFor` after triggering an action. For instance: + +```ts +await user.click(screen.getByRole('button', { name: /Retry/ })); +await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); +``` + +#### Debugging + +You can use Testing Library's +[`prettyDOM` utility](https://testing-library.com/docs/dom-testing-library/api-debugging#prettydom) +to log the state of the DOM anywhere in your tests: + +```ts +console.debug(prettyDOM()); // if you use `console.log` without mocking it, the test will fail +console.debug(prettyDOM(screen.getByText('foo'))); // you can also print out a specific element +``` + +To ensure that the entire DOM is printed out in the terminal, you may have to +set environment variable `DEBUG_PRINT_LIMIT` +[to a large value](https://testing-library.com/docs/dom-testing-library/api-debugging#automatic-logging) +when calling `pnpm test`. + ### Visual regression Cypress is used for end-to-end testing but also for visual regression testing. diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 79c1990a2..414a24213 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -6,8 +6,8 @@ import { ErrorBoundary } from 'react-error-boundary'; import { ReflexContainer, ReflexElement, ReflexSplitter } from 'react-reflex'; import styles from './App.module.css'; +import EntityLoader from './EntityLoader'; import ErrorFallback from './ErrorFallback'; -import LoadingFallback from './LoadingFallback'; import VisConfigProvider from './VisConfigProvider'; import BreadcrumbsBar from './breadcrumbs/BreadcrumbsBar'; import type { FeedbackContext } from './breadcrumbs/models'; @@ -72,9 +72,7 @@ function App(props: Props) { resetKeys={[selectedPath, isInspecting]} FallbackComponent={ErrorFallback} > - } - > + }> {isInspecting ? (
{isReady() &&

{message}...

} ); } -export default LoadingFallback; +export default EntityLoader; diff --git a/packages/app/src/__tests__/CorePack.test.tsx b/packages/app/src/__tests__/CorePack.test.tsx index d172be895..6fefa2d1b 100644 --- a/packages/app/src/__tests__/CorePack.test.tsx +++ b/packages/app/src/__tests__/CorePack.test.tsx @@ -1,143 +1,145 @@ import { mockValues } from '@h5web/shared'; -import { screen } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; +import { SLOW_TIMEOUT } from '../providers/mock/mock-api'; import { - findVisSelectorTabs, + getVisTabs, + getSelectedVisTab, mockConsoleMethod, renderApp, } from '../test-utils'; import { Vis } from '../vis-packs/core/visualizations'; -test('visualise raw dataset', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('entities/raw'); +test('visualize raw dataset', async () => { + await renderApp('/entities/raw'); - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(1); - expect(tabs[0]).toHaveTextContent(Vis.Raw); - expect(tabs[0]).toHaveAttribute('aria-selected', 'true'); - - await expect(screen.findByText(/"int": 42/)).resolves.toBeVisible(); + expect(getVisTabs()).toEqual([Vis.Raw]); + expect(getSelectedVisTab()).toBe(Vis.Raw); + expect(screen.getByText(/"int": 42/)).toBeVisible(); }); test('log raw dataset to console if too large', async () => { - const { selectExplorerNode } = await renderApp(); - const logSpy = mockConsoleMethod('log'); - await selectExplorerNode('entities/raw_large'); + await renderApp('/entities/raw_large'); - await expect(screen.findByText(/dataset is too big/)).resolves.toBeVisible(); + expect(screen.getByText(/dataset is too big/)).toBeVisible(); expect(logSpy).toHaveBeenCalledWith(mockValues.raw_large); }); -test('visualise scalar dataset', async () => { - const { selectExplorerNode } = await renderApp(); +test('visualize scalar dataset', async () => { + // Integer scalar + const { selectExplorerNode } = await renderApp('/entities/scalar_int'); - await selectExplorerNode('entities/scalar_int'); - await expect(screen.findByText('0')).resolves.toBeVisible(); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(1); - expect(tabs[0]).toHaveTextContent(Vis.Scalar); - expect(tabs[0]).toHaveAttribute('aria-selected', 'true'); + expect(getVisTabs()).toEqual([Vis.Scalar]); + expect(getSelectedVisTab()).toBe(Vis.Scalar); + expect(screen.getByText('0')).toBeVisible(); + // String scalar await selectExplorerNode('scalar_str'); - await expect(screen.findByText(mockValues.scalar_str)).resolves.toBeVisible(); + + expect(getVisTabs()).toEqual([Vis.Scalar]); + expect(getSelectedVisTab()).toBe(Vis.Scalar); + expect(screen.getByText('foo')).toBeVisible(); }); test('visualize 1D dataset', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nD_datasets/oneD'); + await renderApp('/nD_datasets/oneD'); - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(2); - expect(tabs[0]).toHaveTextContent(Vis.Matrix); - expect(tabs[1]).toHaveTextContent(Vis.Line); - expect(tabs[1]).toHaveAttribute('aria-selected', 'true'); + expect(getVisTabs()).toEqual([Vis.Matrix, Vis.Line]); + expect(getSelectedVisTab()).toBe(Vis.Line); + expect(screen.getByRole('figure', { name: 'oneD' })).toBeVisible(); +}); - await expect( - screen.findByRole('figure', { name: 'oneD' }) - ).resolves.toBeVisible(); +test('visualize 1D complex dataset', async () => { + await renderApp('/nD_datasets/oneD_cplx'); + + expect(getVisTabs()).toEqual([Vis.Matrix, Vis.Line]); + expect(getSelectedVisTab()).toBe(Vis.Line); + expect(screen.getByRole('figure', { name: 'oneD_cplx' })).toBeVisible(); }); -test('visualize 2D datasets', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nD_datasets/twoD'); +test('visualize 2D dataset', async () => { + await renderApp('/nD_datasets/twoD'); - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(3); - expect(tabs[0]).toHaveTextContent(Vis.Matrix); - expect(tabs[1]).toHaveTextContent(Vis.Line); - expect(tabs[2]).toHaveTextContent(Vis.Heatmap); - expect(tabs[2]).toHaveAttribute('aria-selected', 'true'); + expect(getVisTabs()).toEqual([Vis.Matrix, Vis.Line, Vis.Heatmap]); + expect(getSelectedVisTab()).toBe(Vis.Heatmap); - await expect( - screen.findByRole('figure', { name: 'twoD' }) - ).resolves.toBeVisible(); + const figure = screen.getByRole('figure', { name: 'twoD' }); + expect(figure).toBeVisible(); + expect(within(figure).getByText('4e+2')).toBeVisible(); // color bar limit }); -test('visualize 1D slice of a 3D dataset with and without autoscale', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('resilience/slow_slicing'); +test('visualize 2D complex dataset', async () => { + const { user } = await renderApp('/nD_datasets/twoD_cplx'); - // Heatmap is selected by default and fetches a 2D slice. - await expect( - screen.findByText(/Loading current slice/) - ).resolves.toBeVisible(); + expect(getVisTabs()).toEqual([Vis.Matrix, Vis.Line, Vis.Heatmap]); + expect(getSelectedVisTab()).toBe(Vis.Heatmap); + + const figure = screen.getByRole('figure', { name: 'twoD_cplx (amplitude)' }); + expect(figure).toBeVisible(); + expect(within(figure).getByText('5e+0')).toBeVisible(); // color bar limit - // Let the 2D slice fetch succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); + const selector = screen.getByRole('button', { name: '𝓐 Amplitude' }); + await user.click(selector); + const phaseItem = screen.getByRole('menuitem', { name: 'φ Phase' }); + await user.click(phaseItem); - // Select the LineVis. The autoscale is on by default: it should fetch a 1D slice. - await user.click(await screen.findByRole('tab', { name: Vis.Line })); + expect( + screen.getByRole('figure', { name: 'twoD_cplx (phase)' }) + ).toBeVisible(); +}); + +test('visualize 1D slice of 3D dataset as Line with and without autoscale', async () => { + const { user } = await renderApp({ + initialPath: '/resilience/slow_slicing', + preferredVis: Vis.Line, + withFakeTimers: true, + }); + + // Wait for slice loader to appear (since autoscale is on by default, only the first slice gets fetched) await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); - // Let the 1D slice fetch succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); + // Wait for fetch of first slice to succeed + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); - // Check that autoscale is truly on - const autoScaleBtn = await screen.findByRole('button', { - name: 'Auto-scale', - }); + // Confirm that autoscale is indeed on + const autoScaleBtn = screen.getByRole('button', { name: 'Auto-scale' }); expect(autoScaleBtn).toHaveAttribute('aria-pressed', 'true'); - // Move to other slice to fetch new slice - // eslint-disable-next-line prefer-destructuring - const d0Slider = screen.getAllByRole('slider', { - name: 'Dimension slider', - })[0]; - d0Slider.focus(); - await user.keyboard('{ArrowUp}'); + // Move to next slice + const d0Slider = screen.getByRole('slider', { name: 'D0' }); + await user.type(d0Slider, '{ArrowUp}'); + + // Wait for slice loader to re-appear after debounce await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); - // Let the new slice fetch succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); + // Wait for fetch of second slice to succeed + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); - // Activate autoscale. It should trigger the fetch of the entire dataset. + // Activate autoscale await user.click(autoScaleBtn); + + // Now, the entire dataset gets fetched await expect( screen.findByText(/Loading entire dataset/) ).resolves.toBeVisible(); - // Let the dataset fetch succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - - // Check that entire dataset is fetched - d0Slider.focus(); - await user.keyboard('{ArrowUp}'); + // Wait for fetch of entire dataset to succeed + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - d0Slider.blur(); // remove focus to avoid state update after unmount + // Move to third slice + await user.type(d0Slider, '{ArrowUp}'); - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + // Wait for new slicing to apply to Line visualization to confirm that no more slow fetching is performed + await expect(screen.findByTestId('2,0,x', undefined)).resolves.toBeVisible(); }); diff --git a/packages/app/src/__tests__/DimensionMapper.test.tsx b/packages/app/src/__tests__/DimensionMapper.test.tsx index 9a9004dbe..b3aad570e 100644 --- a/packages/app/src/__tests__/DimensionMapper.test.tsx +++ b/packages/app/src/__tests__/DimensionMapper.test.tsx @@ -3,13 +3,13 @@ import { screen, waitFor, within } from '@testing-library/react'; import { renderApp } from '../test-utils'; import { Vis } from '../vis-packs/core/visualizations'; -test('display mapping for X axis when visualizing 2D dataset as Line', async () => { - const { user, selectExplorerNode, selectVisTab } = await renderApp(); +test('control mapping for X axis when visualizing 2D dataset as Line', async () => { + const { user } = await renderApp({ + initialPath: '/nD_datasets/twoD', + preferredVis: Vis.Line, + }); - await selectExplorerNode('nD_datasets/twoD'); - await selectVisTab(Vis.Line); - - // Ensure that the dimension mapper is only visible for X (and not for Y) + // Ensure that only one dimension mapper for X is visible const xRadioGroup = screen.getByLabelText('Dimension as x axis'); const yRadioGroup = screen.queryByLabelText('Dimension as y axis'); expect(xRadioGroup).toBeVisible(); @@ -21,26 +21,31 @@ test('display mapping for X axis when visualizing 2D dataset as Line', async () expect(xDimsButtons[0]).not.toBeChecked(); expect(xDimsButtons[1]).toBeChecked(); - const d0Slider = screen.getByRole('slider'); + // Ensure that only one dimension slider for D0 is visible + const d0Slider = screen.getByRole('slider', { name: 'D0' }); + expect(d0Slider).toBeVisible(); expect(d0Slider).toHaveAttribute('aria-valueNow', '0'); + expect(screen.queryByRole('slider', { name: 'D1' })).not.toBeInTheDocument(); - // Ensure that the swap from [0, 'x'] to ['x', 0] works + // Change mapping from [0, 'x'] to ['x', 0] await user.click(xDimsButtons[0]); - await waitFor(() => expect(xDimsButtons[0]).toBeChecked()); expect(xDimsButtons[1]).not.toBeChecked(); - const d1Slider = screen.getByRole('slider'); + // Ensure that the dimension slider is now for D1 + const d1Slider = screen.getByRole('slider', { name: 'D1' }); + expect(d1Slider).toBeVisible(); expect(d1Slider).toHaveAttribute('aria-valueNow', '0'); + expect(screen.queryByRole('slider', { name: 'D0' })).not.toBeInTheDocument(); }); -test('display mappings for X and Y axes when visualizing 2D dataset as Heatmap', async () => { - const { user, selectExplorerNode, selectVisTab } = await renderApp(); - - await selectExplorerNode('nD_datasets/twoD'); - await selectVisTab(Vis.Heatmap); +test('control mapping for X and Y axes when visualizing 2D dataset as Heatmap', async () => { + const { user } = await renderApp({ + initialPath: '/nD_datasets/twoD', + preferredVis: Vis.Heatmap, + }); - // Ensure that the dimension mapper is visible for X and Y + // Ensure that two dimension mappers for X and Y are visible const xRadioGroup = screen.getByLabelText('Dimension as x axis'); const yRadioGroup = screen.getByLabelText('Dimension as y axis'); expect(xRadioGroup).toBeVisible(); @@ -55,22 +60,21 @@ test('display mappings for X and Y axes when visualizing 2D dataset as Heatmap', expect(xD1Button).toBeChecked(); expect(yD0Button).toBeChecked(); - // Ensure that the swap from ['y', 'x'] to ['x', 'y'] works + // Change mapping from ['y', 'x'] to ['x', 'y'] await user.click(xD0Button); - await waitFor(() => expect(xD0Button).toBeChecked()); expect(xD1Button).not.toBeChecked(); expect(yD0Button).not.toBeChecked(); expect(yD1Button).toBeChecked(); }); -test('display one dimension slider and mappings for X and Y axes when visualizing 3D dataset as Matrix', async () => { - const { selectExplorerNode, selectVisTab } = await renderApp(); - - await selectExplorerNode('nD_datasets/threeD'); - await selectVisTab(Vis.Matrix); +test('display one slider and two mappers when visualizing 3D dataset as Matrix', async () => { + await renderApp({ + initialPath: '/nD_datasets/threeD', + preferredVis: Vis.Matrix, + }); - // Ensure that the dimension mapper is visible for X and Y + // Ensure that two dimension mappers for X and Y are visible const xRadioGroup = await screen.findByLabelText('Dimension as x axis'); expect(xRadioGroup).toBeVisible(); const xDimsButtons = within(xRadioGroup).getAllByRole('radio'); @@ -84,6 +88,22 @@ test('display one dimension slider and mappings for X and Y axes when visualizin // Ensure that the default mapping is [0, 'y', 'x'] expect(xDimsButtons[2]).toBeChecked(); expect(yDimsButtons[1]).toBeChecked(); - const d0Slider = screen.getByRole('slider'); + + // Ensure that only one dimension slider for D0 is visible + const d0Slider = screen.getByRole('slider', { name: 'D0' }); expect(d0Slider).toHaveAttribute('aria-valueNow', '0'); + expect(screen.queryByRole('slider', { name: 'D1' })).not.toBeInTheDocument(); +}); + +test('slice through 2D dataset', async () => { + const { user } = await renderApp({ + initialPath: '/nD_datasets/twoD', + preferredVis: Vis.Line, + }); + + // Move to next slice with keyboard + const d0Slider = screen.getByRole('slider', { name: 'D0' }); + await user.type(d0Slider, '{ArrowUp}'); + + expect(d0Slider).toHaveAttribute('aria-valuenow', '1'); }); diff --git a/packages/app/src/__tests__/DomainSlider.test.tsx b/packages/app/src/__tests__/DomainSlider.test.tsx index 850759ecf..a8c79b6cc 100644 --- a/packages/app/src/__tests__/DomainSlider.test.tsx +++ b/packages/app/src/__tests__/DomainSlider.test.tsx @@ -1,45 +1,42 @@ -import { screen, waitFor, within } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import { renderApp } from '../test-utils'; -test('show slider with two thumbs', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/nx_data'); +test('show slider with two thumbs and reveal tooltip on hover', async () => { + const { user } = await renderApp('/nexus_entry/nx_process/nx_data'); - const thumbs = await screen.findAllByRole('slider'); + const thumbs = screen.getAllByRole('slider'); expect(thumbs).toHaveLength(2); expect(thumbs[0]).toHaveAttribute('aria-valuenow', '20'); expect(thumbs[1]).toHaveAttribute('aria-valuenow', '81'); -}); - -test('show tooltip on hover', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/nx_data'); - const editBtn = await screen.findByRole('button', { name: 'Edit domain' }); + const editBtn = screen.getByRole('button', { name: 'Edit domain' }); const tooltip = screen.getByRole('dialog', { hidden: true }); expect(editBtn).toHaveAttribute('aria-expanded', 'false'); expect(tooltip).not.toBeVisible(); + // Hover to show tooltip await user.hover(editBtn); expect(editBtn).toHaveAttribute('aria-expanded', 'true'); expect(tooltip).toBeVisible(); + // Unhover to hide tooltip await user.unhover(editBtn); expect(editBtn).toHaveAttribute('aria-expanded', 'false'); expect(tooltip).not.toBeVisible(); + // Hover and press escape to hide tooltip await user.hover(editBtn); await user.keyboard('{Escape}'); expect(tooltip).not.toBeVisible(); }); test('show min/max and data range in tooltip', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/nx_data'); + const { user } = await renderApp('/nexus_entry/nx_process/nx_data'); - const editBtn = await screen.findByRole('button', { name: 'Edit domain' }); + // Hover edit button to reveal tooltip + const editBtn = screen.getByRole('button', { name: 'Edit domain' }); await user.hover(editBtn); const minInput = screen.getByLabelText('min'); @@ -55,166 +52,143 @@ test('show min/max and data range in tooltip', async () => { expect(within(range).getByTitle('400')).toHaveTextContent('4e+2'); }); -test('update domain when moving thumbs (with keyboard)', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/nx_data'); +test('move thumbs with keyboard to update domain', async () => { + const { user } = await renderApp('/nexus_entry/nx_process/nx_data'); // Hover min thumb to reveal tooltip - const minThumb = await screen.findByRole('slider', { name: /min/ }); + const minThumb = screen.getByRole('slider', { name: /min/ }); await user.hover(minThumb); - const visArea = await screen.findByRole('figure'); + const visArea = screen.getByRole('figure'); const minInput = screen.getByLabelText('min'); const maxInput = screen.getByLabelText('max'); // Move min thumb one step to the left with keyboard - minThumb.focus(); - await user.keyboard('{ArrowLeft}'); + await user.type(minThumb, '{ArrowLeft}'); expect(minInput).toHaveValue('−1.04671e+2'); - expect(within(visArea).getByText('−1.047e+2')).toBeInTheDocument(); + expect(within(visArea).getByText('−1.047e+2')).toBeVisible(); - // Move thumb five steps to the left (in a single press) - await user.keyboard('{ArrowLeft>5/}'); // press key and hold for 5 keydown events, then release + // Move min thumb five steps to the left (in a single press) + await user.type(minThumb, '{ArrowLeft>5/}'); // press key and hold for 5 keydown events, then release expect(minInput).toHaveValue('−2.30818e+2'); - expect(within(visArea).getByText('−2.308e+2')).toBeInTheDocument(); + expect(within(visArea).getByText('−2.308e+2')).toBeVisible(); expect(minThumb).toHaveAttribute('aria-valuenow', '20'); // still at original position expect(maxInput).toHaveValue('4e+2'); // unaffected - // Give focus to max thumb - const maxThumb = await screen.findByRole('slider', { name: /max/ }); - maxThumb.focus(); - - // Jump ten steps to the left - await user.keyboard('{PageDown}'); + // Move max thumb ten steps to the left + const maxThumb = screen.getByRole('slider', { name: /max/ }); + await user.type(maxThumb, '{PageDown}'); expect(maxInput).toHaveValue('5.72182e+1'); - expect(within(visArea).getByText('5.722e+1')).toBeInTheDocument(); + expect(within(visArea).getByText('5.722e+1')).toBeVisible(); - // Jump ten steps to the right - await user.keyboard('{PageUp}'); + // Move max thumb ten steps to the right + await user.type(maxThumb, '{PageUp}'); expect(maxInput).toHaveValue('2.52142e+2'); // not back to 4e+2 because of domain extension behaviour - expect(within(visArea).getByText('2.521e+2')).toBeInTheDocument(); + expect(within(visArea).getByText('2.521e+2')).toBeVisible(); expect(maxThumb).toHaveAttribute('aria-valuenow', '81'); // still at original position expect(minInput).toHaveValue('−2.30818e+2'); // unaffected - - // Remove focus now to avoid state update after unmount - maxThumb.blur(); }); -test('allow editing bounds manually', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/nx_data'); +test('edit bounds manually', async () => { + const { user } = await renderApp('/nexus_entry/nx_process/nx_data'); - const editBtn = await screen.findByRole('button', { name: 'Edit domain' }); + const editBtn = screen.getByRole('button', { name: 'Edit domain' }); expect(editBtn).toHaveAttribute('aria-pressed', 'false'); - // Open tooltip with both min and max in edit mode + // Click on edit button to open tooltip with both min and max in edit mode await user.click(editBtn); expect(editBtn).toHaveAttribute('aria-pressed', 'true'); expect(editBtn).toHaveAttribute('aria-expanded', 'true'); - const minInput = screen.getByLabelText('min'); const maxInput = screen.getByLabelText('max'); - const applyBtn = screen.getByRole('button', { name: 'Apply min' }); - const cancelBtn = screen.getByRole('button', { name: 'Cancel min' }); - expect(applyBtn).toBeEnabled(); - expect(cancelBtn).toBeEnabled(); - await waitFor(() => expect(minInput).toHaveFocus()); // input needs time to receive focus + const applyMinBtn = screen.getByRole('button', { name: 'Apply min' }); + const cancelMinBtn = screen.getByRole('button', { name: 'Cancel min' }); + expect(applyMinBtn).toBeEnabled(); + expect(cancelMinBtn).toBeEnabled(); + expect(minInput).toHaveFocus(); // Type '1' in min input field (at the end, after the current value) await user.type(minInput, '1'); expect(minInput).toHaveValue('−9.5e+11'); - const visArea = await screen.findByRole('figure'); - expect(within(visArea).getByText('−9.5e+1')).toBeInTheDocument(); // not applied yet + const visArea = screen.getByRole('figure'); + expect(within(visArea).getByText('−9.5e+1')).toBeVisible(); // not applied yet // Cancel min edit - await user.click(cancelBtn); + await user.click(cancelMinBtn); expect(minInput).toHaveValue('−9.5e+1'); - expect(applyBtn).toBeDisabled(); - expect(cancelBtn).toBeDisabled(); + expect(applyMinBtn).toBeDisabled(); + expect(cancelMinBtn).toBeDisabled(); // Turn min editing back on, type '1' again and apply new min await user.type(minInput, '1'); - await user.click(applyBtn); - expect(within(visArea).getByText('−9.5e+11')).toBeInTheDocument(); // applied + await user.click(applyMinBtn); + expect(within(visArea).getByText('−9.5e+11')).toBeVisible(); // applied expect(editBtn).toHaveAttribute('aria-pressed', 'true'); // because max still in edit mode - // Replace value of max input field and apply new max with Enter + // Replace value of max input field and apply new max await user.clear(maxInput); - await user.type(maxInput, '100000{enter}'); + // Submiting `BoundEditor` form with Enter key leads to `act` warning + // https://github.com/testing-library/user-event/discussions/964 + // await user.type(maxInput, '100000{Enter}'); + await user.type(maxInput, '100000'); + await user.click(screen.getByRole('button', { name: 'Apply max' })); expect(maxInput).toHaveValue('1e+5'); // auto-format - expect(within(visArea).getByText('1e+5')).toBeInTheDocument(); + expect(within(visArea).getByText('1e+5')).toBeVisible(); expect(editBtn).toHaveAttribute('aria-pressed', 'false'); // min and max no longer in edit mode }); test('clamp domain in symlog scale', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/nx_data'); - - const editBtn = await screen.findByRole('button', { name: 'Edit domain' }); - await user.click(editBtn); + const { user } = await renderApp('/nexus_entry/nx_process/nx_data'); + await user.click(screen.getByRole('button', { name: 'Edit domain' })); const minThumb = screen.getByRole('slider', { name: /min/ }); const maxThumb = screen.getByRole('slider', { name: /max/ }); const minInput = screen.getByLabelText('min'); const maxInput = screen.getByLabelText('max'); await user.clear(minInput); - await user.type(minInput, '-1e+1000{enter}'); + await user.type(minInput, '-1e+1000'); + await user.click(screen.getByRole('button', { name: 'Apply min' })); expect(minInput).toHaveValue('−8.98847e+307'); expect(minThumb).toHaveAttribute('aria-valuenow', '1'); await user.clear(maxInput); - await user.type(maxInput, '1e+1000{enter}'); + await user.type(maxInput, '1e+1000'); + await user.click(screen.getByRole('button', { name: 'Apply max' })); expect(maxInput).toHaveValue('8.98847e+307'); expect(maxThumb).toHaveAttribute('aria-valuenow', '100'); - maxThumb.focus(); - await user.keyboard('{ArrowLeft}'); + await user.type(maxThumb, '{ArrowLeft}'); expect(maxInput).toHaveValue('5.40006e+301'); expect(maxThumb).toHaveAttribute('aria-valuenow', '99'); // does not jump back to 81 - - // Remove focus now to avoid state update after unmount - maxThumb.blur(); -}); - -test('show min/max autoscale toggles in tooltip (pressed by default)', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/nx_data'); - - const editBtn = await screen.findByRole('button', { name: 'Edit domain' }); - await user.hover(editBtn); - - expect(screen.getByText('Autoscale')).toBeVisible(); - - const minBtn = screen.getByRole('button', { name: 'Min' }); - const maxBtn = screen.getByRole('button', { name: 'Max' }); - expect(minBtn).toHaveAttribute('aria-pressed', 'true'); - expect(maxBtn).toHaveAttribute('aria-pressed', 'true'); }); test('control min/max autoscale behaviour', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/nx_data'); + const { user } = await renderApp('/nexus_entry/nx_process/nx_data'); - const minThumb = await screen.findByRole('slider', { name: /min/ }); + const minThumb = screen.getByRole('slider', { name: /min/ }); await user.hover(minThumb); const minBtn = screen.getByRole('button', { name: 'Min' }); const maxBtn = screen.getByRole('button', { name: 'Max' }); const maxInput = screen.getByLabelText('max'); + // Autoscale is enabled for both min and max by default + expect(minBtn).toHaveAttribute('aria-pressed', 'true'); + expect(maxBtn).toHaveAttribute('aria-pressed', 'true'); + // Moving min thumb disables min autoscale - minThumb.focus(); - await user.keyboard('{ArrowRight}'); + await user.type(minThumb, '{ArrowRight}'); expect(minBtn).toHaveAttribute('aria-pressed', 'false'); expect(maxBtn).toHaveAttribute('aria-pressed', 'true'); // unaffected // Editing max disables max autoscale - await user.type(maxInput, '0{enter}'); + await user.type(maxInput, '0'); + await user.click(screen.getByRole('button', { name: 'Apply max' })); expect(maxInput).toHaveValue('4e+20'); expect(maxBtn).toHaveAttribute('aria-pressed', 'false'); @@ -222,16 +196,13 @@ test('control min/max autoscale behaviour', async () => { await user.click(maxBtn); expect(maxBtn).toHaveAttribute('aria-pressed', 'true'); expect(minBtn).toHaveAttribute('aria-pressed', 'false'); // unaffected - await waitFor(() => expect(maxInput).toHaveValue('4e+2')); // input needs time to be reset + expect(maxInput).toHaveValue('4e+2'); }); test('handle empty domain', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/nx_data'); - - const editBtn = await screen.findByRole('button', { name: 'Edit domain' }); - await user.click(editBtn); + const { user } = await renderApp('/nexus_entry/nx_process/nx_data'); + await user.click(screen.getByRole('button', { name: 'Edit domain' })); const minInput = screen.getByLabelText('min'); const maxInput = screen.getByLabelText('max'); const minThumb = screen.getByRole('slider', { name: /min/ }); @@ -239,87 +210,80 @@ test('handle empty domain', async () => { // Give min the same value as max await user.clear(minInput); - await user.type(minInput, '400{enter}'); + await user.type(minInput, '400'); + await user.click(screen.getByRole('button', { name: 'Apply min' })); expect(minThumb).toHaveAttribute('aria-valuenow', '58'); // not quite in the middle because of symlog expect(maxThumb).toHaveAttribute('aria-valuenow', '58'); - const visArea = await screen.findByRole('figure'); - expect(within(visArea).getByText('−∞')).toBeInTheDocument(); - expect(within(visArea).getByText('+∞')).toBeInTheDocument(); + const visArea = screen.getByRole('figure'); + expect(within(visArea).getByText('−∞')).toBeVisible(); + expect(within(visArea).getByText('+∞')).toBeVisible(); // Check that pearling works (i.e. that one thumb can push the other) - maxThumb.focus(); - await user.keyboard('{ArrowLeft}'); + await user.type(maxThumb, '{ArrowLeft}'); expect(minInput).toHaveValue('3.97453e+2'); expect(maxInput).toHaveValue('3.97453e+2'); expect(minThumb).toHaveAttribute('aria-valuenow', '58'); // thumbs stay in the middle expect(maxThumb).toHaveAttribute('aria-valuenow', '58'); // Ensure thumbs can be separated again - await user.keyboard('{ArrowRight}'); + await user.type(maxThumb, '{ArrowRight}'); expect(maxInput).toHaveValue('3.99891e+2'); expect(minThumb).toHaveAttribute('aria-valuenow', '20'); expect(maxThumb).toHaveAttribute('aria-valuenow', '81'); - - // Remove focus now to avoid state update after unmount - maxThumb.blur(); }); test('handle min > max', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/nx_data'); - - const editBtn = await screen.findByRole('button', { name: 'Edit domain' }); - await user.click(editBtn); + const { user } = await renderApp('/nexus_entry/nx_process/nx_data'); + await user.click(screen.getByRole('button', { name: 'Edit domain' })); const minInput = screen.getByLabelText('min'); const maxInput = screen.getByLabelText('max'); await user.clear(minInput); - await user.type(minInput, '500{enter}'); + await user.type(minInput, '500'); + await user.click(screen.getByRole('button', { name: 'Apply min' })); expect(minInput).toHaveValue('5e+2'); expect(maxInput).toHaveValue('4e+2'); expect(screen.getByText(/Min greater than max/)).toHaveTextContent( /falling back to data range/ ); - const visArea = await screen.findByRole('figure'); - expect(within(visArea).getByText('−9.5e+1')).toBeInTheDocument(); - expect(within(visArea).getByText('4e+2')).toBeInTheDocument(); + const visArea = screen.getByRole('figure'); + expect(within(visArea).getByText('−9.5e+1')).toBeVisible(); + expect(within(visArea).getByText('4e+2')).toBeVisible(); // Autoscaling min hides the error - const minBtn = screen.getByRole('button', { name: 'Min' }); - await user.click(minBtn); + await user.click(screen.getByRole('button', { name: 'Min' })); expect(screen.queryByText(/Min greater than max/)).not.toBeInTheDocument(); }); test('handle min or max <= 0 in log scale', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/image'); + const { user } = await renderApp('/nexus_entry/image'); - const editBtn = await screen.findByRole('button', { name: 'Edit domain' }); - await expect( - screen.findByRole('button', { name: 'Log' }) // wait for switch to log scale - ).resolves.toBeVisible(); + // Ensure the scale type is log + expect(screen.getByRole('button', { name: 'Log' })).toBeVisible(); + const editBtn = screen.getByRole('button', { name: 'Edit domain' }); await user.click(editBtn); - const minInput = screen.getByLabelText('min'); const maxInput = screen.getByLabelText('max'); await user.clear(minInput); - await user.type(minInput, '-5{enter}'); + await user.type(minInput, '-5'); + await user.click(screen.getByRole('button', { name: 'Apply min' })); expect(minInput).toHaveValue('−5e+0'); expect(screen.getByText(/Custom min invalid/)).toHaveTextContent( /falling back to data min/ ); - const visArea = await screen.findByRole('figure'); - expect(within(visArea).getByText('9.996e-1')).toBeInTheDocument(); // data max + const visArea = screen.getByRole('figure'); + expect(within(visArea).getByText('9.996e-1')).toBeVisible(); // data max // If min and max are negative and min > max, min > max error and fallback take over await user.clear(maxInput); - await user.type(maxInput, '-10{enter}'); + await user.type(maxInput, '-10'); + await user.click(screen.getByRole('button', { name: 'Apply max' })); expect(screen.queryByText(/Custom min invalid/)).not.toBeInTheDocument(); expect(screen.queryByText(/Custom max invalid/)).not.toBeInTheDocument(); expect(screen.getByText(/Min greater than max/)).toHaveTextContent( @@ -328,31 +292,29 @@ test('handle min or max <= 0 in log scale', async () => { }); test('handle min <= 0 with custom max fallback in log scale', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/image'); + const { user } = await renderApp('/nexus_entry/image'); - const editBtn = await screen.findByRole('button', { name: 'Edit domain' }); - await expect( - screen.findByRole('button', { name: 'Log' }) // wait for switch to log scale - ).resolves.toBeVisible(); - - await user.click(editBtn); + // Ensure the scale type is log + expect(screen.getByRole('button', { name: 'Log' })).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Edit domain' })); const minInput = screen.getByLabelText('min'); const maxInput = screen.getByLabelText('max'); await user.clear(minInput); - await user.type(minInput, '-5{enter}'); + await user.type(minInput, '-5'); + await user.click(screen.getByRole('button', { name: 'Apply min' })); await user.clear(maxInput); - await user.type(maxInput, '1e-4{enter}'); // lower than data min + await user.type(maxInput, '1e-4'); // lower than data min + await user.click(screen.getByRole('button', { name: 'Apply max' })); expect(screen.getByText(/Custom min invalid/)).toHaveTextContent( /falling back to custom max/ ); // Min fallback = custom max, so domain is empty - const visArea = await screen.findByRole('figure'); - expect(within(visArea).getByText('−∞')).toBeInTheDocument(); - expect(within(visArea).getByText('+∞')).toBeInTheDocument(); + const visArea = screen.getByRole('figure'); + expect(within(visArea).getByText('−∞')).toBeVisible(); + expect(within(visArea).getByText('+∞')).toBeVisible(); }); diff --git a/packages/app/src/__tests__/Explorer.test.tsx b/packages/app/src/__tests__/Explorer.test.tsx index c78141c81..33b6184ea 100644 --- a/packages/app/src/__tests__/Explorer.test.tsx +++ b/packages/app/src/__tests__/Explorer.test.tsx @@ -1,12 +1,13 @@ import { mockFilepath } from '@h5web/shared'; -import { screen, waitFor } from '@testing-library/react'; +import { screen } from '@testing-library/react'; +import { SLOW_TIMEOUT } from '../providers/mock/mock-api'; import { renderApp } from '../test-utils'; test('select root group by default', async () => { await renderApp(); - const title = await screen.findByRole('heading', { name: mockFilepath }); + const title = screen.getByRole('heading', { name: mockFilepath }); expect(title).toBeVisible(); const fileBtn = screen.getByRole('treeitem', { name: mockFilepath }); @@ -17,9 +18,7 @@ test('select root group by default', async () => { test('toggle explorer sidebar', async () => { const { user } = await renderApp(); - const fileBtn = await screen.findByRole('treeitem', { - name: mockFilepath, - }); + const fileBtn = screen.getByRole('treeitem', { name: mockFilepath }); const sidebarBtn = screen.getByRole('button', { name: 'Toggle explorer sidebar', }); @@ -37,48 +36,41 @@ test('toggle explorer sidebar', async () => { }); test('navigate groups in explorer', async () => { - const { user } = await renderApp(); + const { selectExplorerNode } = await renderApp(); - const groupBtn = await screen.findByRole('treeitem', { name: 'entities' }); + const groupBtn = screen.getByRole('treeitem', { name: 'entities' }); expect(groupBtn).toHaveAttribute('aria-selected', 'false'); expect(groupBtn).toHaveAttribute('aria-expanded', 'false'); // Expand `entities` group - await user.click(groupBtn); - + await selectExplorerNode('entities'); expect(groupBtn).toHaveAttribute('aria-selected', 'true'); expect(groupBtn).toHaveAttribute('aria-expanded', 'true'); - const childGroupBtn = await screen.findByRole('treeitem', { - name: 'empty_group', - }); + const childGroupBtn = screen.getByRole('treeitem', { name: 'empty_group' }); expect(childGroupBtn).toHaveAttribute('aria-selected', 'false'); expect(childGroupBtn).toHaveAttribute('aria-expanded', 'false'); // Expand `empty_group` child group - await user.click(childGroupBtn); - + await selectExplorerNode('empty_group'); expect(groupBtn).toHaveAttribute('aria-selected', 'false'); expect(groupBtn).toHaveAttribute('aria-expanded', 'true'); expect(childGroupBtn).toHaveAttribute('aria-selected', 'true'); expect(childGroupBtn).toHaveAttribute('aria-expanded', 'true'); // Collapse `empty_group` child group - await user.click(childGroupBtn); - + await selectExplorerNode('empty_group'); expect(childGroupBtn).toHaveAttribute('aria-selected', 'true'); expect(childGroupBtn).toHaveAttribute('aria-expanded', 'false'); // Select `entities` group - await user.click(groupBtn); - + await selectExplorerNode('entities'); expect(groupBtn).toHaveAttribute('aria-selected', 'true'); expect(groupBtn).toHaveAttribute('aria-expanded', 'true'); // remains expanded as it wasn't previously selected expect(childGroupBtn).toHaveAttribute('aria-selected', 'false'); // Collapse `entities` group - await user.click(groupBtn); - + await selectExplorerNode('entities'); expect( screen.queryByRole('treeitem', { name: 'empty_group' }) ).not.toBeInTheDocument(); @@ -87,20 +79,26 @@ test('navigate groups in explorer', async () => { }); test('show spinner when group metadata is slow to fetch', async () => { - jest.useFakeTimers('modern'); - const { selectExplorerNode } = await renderApp(); + await renderApp({ + initialPath: '/resilience/slow_metadata', + withFakeTimers: true, + }); - await selectExplorerNode('resilience/slow_metadata'); - await expect(screen.findByText(/Loading/)).resolves.toBeVisible(); - expect(screen.getByLabelText(/Loading group metadata/)).toBeVisible(); + // Wait for `slow_metadata` group to appear in explorer (i.e. for root and `resilience` groups to finish loading) + await expect( + screen.findByRole('treeitem', { name: 'slow_metadata' }) + ).resolves.toBeVisible(); - jest.runAllTimers(); // resolve slow fetch right away - await waitFor(() => { - expect( - screen.queryByLabelText(/Loading group metadata/) - ).not.toBeInTheDocument(); - }); + // Ensure explorer now shows loading spinner (i.e. for `slow_metadata` group) + expect(screen.getByLabelText(/Loading group/)).toBeVisible(); + + // Wait for fetch of group metadata to succeed + await expect( + screen.findByText(/No visualization available/, undefined, { + timeout: SLOW_TIMEOUT, + }) + ).resolves.toBeVisible(); - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + // Spinner has been removed + expect(screen.queryByLabelText(/Loading group/)).not.toBeInTheDocument(); }); diff --git a/packages/app/src/__tests__/MetadataViewer.test.tsx b/packages/app/src/__tests__/MetadataViewer.test.tsx index 28e0c63d2..b53760653 100644 --- a/packages/app/src/__tests__/MetadataViewer.test.tsx +++ b/packages/app/src/__tests__/MetadataViewer.test.tsx @@ -1,32 +1,24 @@ import { screen } from '@testing-library/react'; -import { findVisSelector, queryVisSelector, renderApp } from '../test-utils'; +import { renderApp } from '../test-utils'; test('switch between "display" and "inspect" modes', async () => { const { user } = await renderApp(); - const inspectBtn = await screen.findByRole('tab', { name: 'Inspect' }); - const displayBtn = screen.getByRole('tab', { name: 'Display' }); - // Switch to "inspect" mode - await user.click(inspectBtn); - - expect(queryVisSelector()).not.toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: 'Inspect' })); expect(screen.getByRole('row', { name: /^Path/ })).toBeVisible(); // Switch back to "display" mode - await user.click(displayBtn); - - await expect(findVisSelector()).resolves.toBeVisible(); + await user.click(screen.getByRole('tab', { name: 'Display' })); expect(screen.queryByRole('row', { name: /^Path/ })).not.toBeInTheDocument(); }); test('inspect group', async () => { - const { user, selectExplorerNode } = await renderApp(); - await user.click(await screen.findByRole('tab', { name: 'Inspect' })); - await selectExplorerNode('entities'); + const { user } = await renderApp('/entities'); + await user.click(screen.getByRole('tab', { name: 'Inspect' })); - const column = await screen.findByRole('columnheader', { name: /^Group/ }); + const column = screen.getByRole('columnheader', { name: /^Group/ }); const nameRow = screen.getByRole('row', { name: /^Name/ }); const pathRow = screen.getByRole('row', { name: /^Path/ }); @@ -36,13 +28,10 @@ test('inspect group', async () => { }); test('inspect scalar dataset', async () => { - const { user, selectExplorerNode } = await renderApp(); - await user.click(await screen.findByRole('tab', { name: 'Inspect' })); - await selectExplorerNode('entities/scalar_int'); + const { user } = await renderApp('/entities/scalar_int'); + await user.click(screen.getByRole('tab', { name: 'Inspect' })); - const column = await screen.findByRole('columnheader', { - name: /Dataset/, - }); + const column = screen.getByRole('columnheader', { name: /Dataset/ }); const nameRow = screen.getByRole('row', { name: /^Name/ }); const pathRow = screen.getByRole('row', { name: /^Path/ }); const shapeRow = screen.getByRole('row', { name: /^Shape/ }); @@ -56,34 +45,28 @@ test('inspect scalar dataset', async () => { }); test('inspect array dataset', async () => { - const { user, selectExplorerNode } = await renderApp(); - await user.click(await screen.findByRole('tab', { name: 'Inspect' })); - await selectExplorerNode('nD_datasets/threeD'); + const { user } = await renderApp('/nD_datasets/threeD'); + await user.click(screen.getByRole('tab', { name: 'Inspect' })); - const shapeRow = await screen.findByRole('row', { name: /^Shape/ }); + const shapeRow = screen.getByRole('row', { name: /^Shape/ }); expect(shapeRow).toHaveTextContent(/9 x 20 x 41 = 7380/); }); test('inspect empty dataset', async () => { - const { user, selectExplorerNode } = await renderApp(); - await user.click(await screen.findByRole('tab', { name: 'Inspect' })); - await selectExplorerNode('entities/empty_dataset'); + const { user } = await renderApp('/entities/empty_dataset'); + await user.click(screen.getByRole('tab', { name: 'Inspect' })); - const shapeRow = await screen.findByRole('row', { name: /^Shape/ }); + const shapeRow = screen.getByRole('row', { name: /^Shape/ }); const typeRow = screen.getByRole('row', { name: /^Type/ }); - expect(shapeRow).toHaveTextContent(/None/); expect(typeRow).toHaveTextContent(/Integer, 32-bit, little-endian/); }); test('inspect datatype', async () => { - const { user, selectExplorerNode } = await renderApp(); - await user.click(await screen.findByRole('tab', { name: 'Inspect' })); - await selectExplorerNode('entities/datatype'); + const { user } = await renderApp('/entities/datatype'); + await user.click(screen.getByRole('tab', { name: 'Inspect' })); - const column = await screen.findByRole('columnheader', { - name: /Datatype/, - }); + const column = screen.getByRole('columnheader', { name: /Datatype/ }); const nameRow = screen.getByRole('row', { name: /^Name/ }); const pathRow = screen.getByRole('row', { name: /^Path/ }); const typeRow = screen.getByRole('row', { name: /^Type/ }); @@ -95,11 +78,10 @@ test('inspect datatype', async () => { }); test('inspect unresolved soft link', async () => { - const { user, selectExplorerNode } = await renderApp(); - await user.click(await screen.findByRole('tab', { name: 'Inspect' })); - await selectExplorerNode('entities/unresolved_soft_link'); + const { user } = await renderApp('/entities/unresolved_soft_link'); + await user.click(screen.getByRole('tab', { name: 'Inspect' })); - const column = await screen.findByRole('columnheader', { name: /Entity/ }); + const column = screen.getByRole('columnheader', { name: /Entity/ }); const nameRow = screen.getByRole('row', { name: /^Name/ }); const pathRow = screen.getByRole('row', { name: /^Path/ }); const linkRow = screen.getByRole('row', { name: /^Soft link/ }); @@ -111,40 +93,37 @@ test('inspect unresolved soft link', async () => { }); test('inspect unresolved external link', async () => { - const { user, selectExplorerNode } = await renderApp(); - await user.click(await screen.findByRole('tab', { name: 'Inspect' })); - await selectExplorerNode('entities/unresolved_external_link'); + const { user } = await renderApp('/entities/unresolved_external_link'); + await user.click(screen.getByRole('tab', { name: 'Inspect' })); - const column = await screen.findByRole('columnheader', { name: /Entity/ }); + const column = screen.getByRole('columnheader', { name: /Entity/ }); const linkRow = screen.getByRole('row', { name: /^External link/ }); - expect(column).toBeVisible(); expect(linkRow).toHaveTextContent(/my_file.h5:entry_000\/dataset/); }); test('follow path attributes', async () => { const { user, selectExplorerNode } = await renderApp(); - await user.click(await screen.findByRole('tab', { name: 'Inspect' })); + await user.click(screen.getByRole('tab', { name: 'Inspect' })); - await user.click( - await screen.findByRole('button', { name: 'Inspect nexus_entry' }) - ); + // Follow relative `default` attribute + await user.click(screen.getByRole('button', { name: 'Inspect nexus_entry' })); - const nxEntry = await screen.findByRole('treeitem', { - name: /^nexus_entry /, - }); + const nxEntry = screen.getByRole('treeitem', { name: /^nexus_entry / }); expect(nxEntry).toHaveAttribute('aria-selected', 'true'); expect(nxEntry).toHaveAttribute('aria-expanded', 'true'); - await selectExplorerNode('nx_process/absolute_default_path'); + await selectExplorerNode('nx_process'); + await selectExplorerNode('absolute_default_path'); + // Follow absolute `default` attribute await user.click( await screen.findByRole('button', { name: 'Inspect /nexus_entry/nx_process/nx_data', }) ); - const nxData = await screen.findByRole('treeitem', { name: /nx_data/ }); + const nxData = screen.getByRole('treeitem', { name: /nx_data/ }); expect(nxData).toHaveAttribute('aria-selected', 'true'); expect(nxData).toHaveAttribute('aria-expanded', 'true'); }); diff --git a/packages/app/src/__tests__/NexusPack.test.tsx b/packages/app/src/__tests__/NexusPack.test.tsx index 8f718822a..fdbf21095 100644 --- a/packages/app/src/__tests__/NexusPack.test.tsx +++ b/packages/app/src/__tests__/NexusPack.test.tsx @@ -1,514 +1,320 @@ import { screen } from '@testing-library/react'; +import { SLOW_TIMEOUT } from '../providers/mock/mock-api'; import { - findVisSelectorTabs, + getSelectedVisTab, + getVisTabs, mockConsoleMethod, renderApp, } from '../test-utils'; import { NexusVis } from '../vis-packs/nexus/visualizations'; -test('visualize NXdata group with "spectrum" interpretation', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/spectrum'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(1); - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - - await expect( - screen.findByRole('figure', { name: 'twoD_spectrum (arb. units)' }) // signal name + `units` attribute - ).resolves.toBeVisible(); +test('visualize NXdata group with explicit signal interpretation', async () => { + // Signal with "spectrum" interpretation + const { selectExplorerNode } = await renderApp('/nexus_entry/spectrum'); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum]); + expect( + screen.getByRole('figure', { name: 'twoD_spectrum (arb. units)' }) // signal name + `units` attribute + ).toBeVisible(); + + // Signal with "image" interpretation + await selectExplorerNode('image'); + expect(getVisTabs()).toEqual([NexusVis.NxImage]); + expect( + screen.getByRole('figure', { name: 'Interference fringes' }) // `long_name` attribute + ).toBeVisible(); + + // 2D complex signal with "spectrum" interpretation + await selectExplorerNode('complex_spectrum'); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum]); + expect( + screen.getByRole('figure', { name: 'twoD_complex' }) // signal name (complex vis type is displayed as ordinate label) + ).toBeVisible(); + + // Signal with "rgb-image" interpretation + await selectExplorerNode('rgb-image'); + expect(getVisTabs()).toEqual([NexusVis.NxRGB]); + expect( + screen.getByRole('figure', { name: 'RGB CMY DGW' }) // `long_name` attribute + ).toBeVisible(); }); -test('visualize NXdata group with "image" interpretation', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/image'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(1); - expect(tabs[0]).toHaveTextContent(NexusVis.NxImage); - - await expect( - screen.findByRole('figure', { name: 'Interference fringes' }) // `long_name` attribute - ).resolves.toBeVisible(); +test('visualize NXdata group without explicit signal interpretation', async () => { + // 2D signal (no interpretation) + const { selectExplorerNode } = await renderApp( + '/nexus_entry/nx_process/nx_data' + ); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum, NexusVis.NxImage]); + expect(getSelectedVisTab()).toBe(NexusVis.NxImage); + expect(screen.getByRole('figure', { name: 'NeXus 2D' })).toBeVisible(); // `title` dataset + + // 1D signal (no interpretation) + await selectExplorerNode('log_spectrum'); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum]); + expect(screen.getByRole('figure', { name: 'oneD' })).toBeVisible(); // signal name + + // 2D complex signal (no interpretation) + await selectExplorerNode('complex'); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum, NexusVis.NxImage]); + expect(getSelectedVisTab()).toBe(NexusVis.NxImage); + expect( + screen.getByRole('figure', { name: 'twoD_complex (amplitude)' }) // signal name + complex visualization type + ).toBeVisible(); + + // 2D signal and two 1D axes of same length (implicit scatter interpretation) + await selectExplorerNode('scatter'); + expect(getVisTabs()).toEqual([NexusVis.NxScatter]); + expect(screen.getByRole('figure', { name: 'scatter_data' })).toBeVisible(); // signal name }); -test('visualize NXdata group with 2D signal', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/nx_data'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(2); - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - expect(tabs[1]).toHaveTextContent(NexusVis.NxImage); - - await expect( - screen.findByRole('figure', { name: 'NeXus 2D' }) // `title` dataset - ).resolves.toBeVisible(); +test('visualize NXdata group with old-style signal', async () => { + await renderApp('/nexus_entry/old-style'); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum, NexusVis.NxImage]); + expect( + screen.getByRole('figure', { name: 'twoD' }) // name of dataset with `signal` attribute + ).toBeVisible(); }); -test('visualize NXdata group with 1D signal and two 1D axes of same length', async () => { +test('visualize group with `default` attribute', async () => { + // NXroot with relative path to NXentry group with relative path to NXdata group with 2D signal const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/scatter'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(1); - expect(tabs[0]).toHaveTextContent(NexusVis.NxScatter); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum, NexusVis.NxImage]); + expect( + screen.getByRole('figure', { name: 'NeXus 2D' }) // `title` dataset + ).toBeVisible(); - await expect( - screen.findByRole('figure', { name: 'scatter_data' }) - ).resolves.toBeVisible(); -}); - -test('visualize NXentry group with relative path to 2D default signal', async () => { - const { selectExplorerNode } = await renderApp(); + // NXentry with relative path to NXdata group with 2D signal await selectExplorerNode('nexus_entry'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(2); - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - expect(tabs[1]).toHaveTextContent(NexusVis.NxImage); - - await expect( - screen.findByRole('figure', { name: 'NeXus 2D' }) // `title` dataset - ).resolves.toBeVisible(); -}); - -test('visualize NXentry group with absolute path to 2D default signal', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/nx_process/absolute_default_path'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(2); - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - expect(tabs[1]).toHaveTextContent(NexusVis.NxImage); - - await expect( - screen.findByRole('figure', { name: 'NeXus 2D' }) // `title` dataset - ).resolves.toBeVisible(); -}); - -test('visualize NXentry group with old-style signal', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/old-style'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(2); - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - expect(tabs[1]).toHaveTextContent(NexusVis.NxImage); - - await expect( - screen.findByRole('figure', { name: 'twoD' }) // name of dataset with `signal` attribute - ).resolves.toBeVisible(); -}); - -test('visualize NXroot group with 2D default signal', async () => { - await renderApp(); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(2); - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - expect(tabs[1]).toHaveTextContent(NexusVis.NxImage); - - await expect( - screen.findByRole('figure', { name: 'NeXus 2D' }) // `title` dataset - ).resolves.toBeVisible(); -}); - -test('visualize NXdata group with 2D complex signal', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/complex'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(2); - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - expect(tabs[1]).toHaveTextContent(NexusVis.NxImage); - - await expect( - screen.findByRole('figure', { name: 'twoD_complex (amplitude)' }) // signal name + complex visualization type - ).resolves.toBeVisible(); -}); - -test('visualize NXdata group with 2D complex signal and "spectrum" interpretation', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/complex_spectrum'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(1); - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - - await expect( - screen.findByRole('figure', { name: 'twoD_complex' }) // signal name (complex vis type is displayed as ordinate label) - ).resolves.toBeVisible(); -}); - -test('visualize NXdata group with "rgb-image" interpretation', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/rgb-image'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(1); - expect(tabs[0]).toHaveTextContent(NexusVis.NxRGB); - - await expect( - screen.findByRole('figure', { name: 'RGB CMY DGW' }) // `long_name` attribute - ).resolves.toBeVisible(); -}); - -test('follow SILX styles when visualizing NXdata group', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_entry/log_spectrum'); - - const logSelectors = await screen.findAllByRole('button', { name: 'Log' }); - expect(logSelectors).toHaveLength(2); // `log_spectrum` requests both axes to be in log scale + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum, NexusVis.NxImage]); + expect( + screen.getByRole('figure', { name: 'NeXus 2D' }) // `title` dataset + ).toBeVisible(); + + // NXentry with absolute path to NXdata group with 2D signal + await selectExplorerNode('nx_process'); + await selectExplorerNode('absolute_default_path'); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum, NexusVis.NxImage]); + expect( + screen.getByRole('figure', { name: 'NeXus 2D' }) // `title` dataset + ).toBeVisible(); }); test('visualize NXentry group with implicit default child NXdata group', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_no_default'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(1); - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - - await expect( - screen.findByRole('figure', { name: 'oneD' }) // signal name of NXdata group "spectrum" - ).resolves.toBeVisible(); + await renderApp('/nexus_no_default'); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum]); + expect( + screen.getByRole('figure', { name: 'oneD' }) // signal name of NXdata group "spectrum" + ).toBeVisible(); }); -test('show error when `default` entity is not found', async () => { - const { selectExplorerNode } = await renderApp(); - - const errorSpy = mockConsoleMethod('error'); - await selectExplorerNode('nexus_malformed/default_not_found'); - await expect( - screen.findByText('No entity found at /test') - ).resolves.toBeVisible(); - - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces -}); - -test('show error when `signal` entity is not found', async () => { - const { selectExplorerNode } = await renderApp(); - - const errorSpy = mockConsoleMethod('error'); - await selectExplorerNode('nexus_malformed/signal_not_found'); - await expect( - screen.findByText('Expected "unknown" signal entity to exist') - ).resolves.toBeVisible(); - - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces -}); - -test('show error when `signal` entity is not a dataset', async () => { - const { selectExplorerNode } = await renderApp(); - - const errorSpy = mockConsoleMethod('error'); - await selectExplorerNode('nexus_malformed/signal_not_dataset'); - await expect( - screen.findByText('Expected "some_group" signal to be a dataset') - ).resolves.toBeVisible(); - - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces -}); - -test('show error when old-style `signal` entity is not a dataset', async () => { - const { selectExplorerNode } = await renderApp(); - - const errorSpy = mockConsoleMethod('error'); - await selectExplorerNode('nexus_malformed/signal_old-style_not_dataset'); - await expect( - screen.findByText('Expected old-style "some_group" signal to be a dataset') - ).resolves.toBeVisible(); +test('follow SILX styles on NXdata group', async () => { + await renderApp('/nexus_entry/log_spectrum'); - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces + const logSelectors = screen.getAllByRole('button', { name: 'Log' }); + expect(logSelectors).toHaveLength(2); // log for both axes }); -test('show error when `signal` dataset is not array', async () => { - const { selectExplorerNode } = await renderApp(); +test('handle unknown/incompatible interpretation gracefully', async () => { + const { selectExplorerNode } = await renderApp('/nexus_malformed'); - const errorSpy = mockConsoleMethod('error'); - await selectExplorerNode('nexus_malformed/signal_not_array'); - await expect( - screen.findByText('Expected dataset to have array shape') - ).resolves.toBeVisible(); + // Signal with unknown interpretation + await selectExplorerNode('interpretation_unknown'); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum, NexusVis.NxImage]); // fallback based on number of dimensions + expect(screen.getByRole('figure', { name: 'fourD' })).toBeVisible(); // signal name - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces + // Signal with too few dimensions for "rgb-image" interpretation + await selectExplorerNode('rgb-image_incompatible'); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum]); // fallback based on number of dimensions + expect(screen.getByRole('figure', { name: 'oneD' })).toBeVisible(); // signal name }); -test('show error when `signal` dataset is not numeric', async () => { - const { selectExplorerNode } = await renderApp(); - +test('show error/fallback for malformed NeXus entity', async () => { const errorSpy = mockConsoleMethod('error'); - await selectExplorerNode('nexus_malformed/signal_not_numeric'); - await expect( - screen.findByText('Expected dataset to have numeric or complex type') - ).resolves.toBeVisible(); - - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces -}); - -test('show fallback message when NXdata group has no `signal` attribute', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_malformed/no_signal'); - - await expect( - screen.findByText('No visualization available for this entity.') - ).resolves.toBeInTheDocument(); -}); - -test('visualize NXdata group with unknown interpretation', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_malformed/interpretation_unknown'); - - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(2); // support check falls back to signal dataset dimensions (4D supports both Image and Spectrum) - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - expect(tabs[1]).toHaveTextContent(NexusVis.NxImage); - - await expect( - screen.findByRole('figure', { name: 'fourD' }) // signal name - ).resolves.toBeVisible(); -}); - -test('visualize NXdata group with "rgb-image" interpretation but incompatible signal', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_malformed/rgb-image_incompatible'); + const { selectExplorerNode } = await renderApp('/nexus_malformed'); + + // `default` attribute points to non-existant entity + await selectExplorerNode('default_not_found'); + expect(screen.getByText('No entity found at /test')).toBeVisible(); + errorSpy.mockClear(); + + // No `signal` attribute + await selectExplorerNode('no_signal'); + expect( + screen.getByText('No visualization available for this entity.') + ).toBeInTheDocument(); + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockClear(); + + // `signal` attribute points to non-existant dataset + await selectExplorerNode('signal_not_found'); + expect( + screen.getByText('Expected "unknown" signal entity to exist') + ).toBeVisible(); + errorSpy.mockClear(); + + // Signal entity is not a dataset + await selectExplorerNode('signal_not_dataset'); + expect( + screen.getByText('Expected "some_group" signal to be a dataset') + ).toBeVisible(); + errorSpy.mockClear(); + + // Old-style signal entity is not a dataset + await selectExplorerNode('signal_old-style_not_dataset'); + expect( + screen.getByText('Expected old-style "some_group" signal to be a dataset') + ).toBeVisible(); + errorSpy.mockClear(); + + // Shape of signal dataset is not array + await selectExplorerNode('signal_not_array'); + expect( + screen.getByText('Expected dataset to have array shape') + ).toBeVisible(); + errorSpy.mockClear(); + + // Type of signal dataset is not numeric + await selectExplorerNode('signal_not_numeric'); + expect( + screen.getByText('Expected dataset to have numeric or complex type') + ).toBeVisible(); + errorSpy.mockClear(); - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(1); // support check falls back to signal dataset dimensions (1D supports only Spectrum) - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - - await expect( - screen.findByRole('figure', { name: 'oneD' }) // signal name - ).resolves.toBeVisible(); + errorSpy.mockRestore(); }); -test('ignore unknown `SILX_style` options and invalid values', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('nexus_malformed/silx_style_unknown'); - +test('ignore malformed `SILX_style` attribute', async () => { const errorSpy = mockConsoleMethod('error'); + const warningSpy = mockConsoleMethod('warn'); - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(1); - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); - - const scaleSelectors = await screen.findAllByRole('button', { - name: 'Linear', // the scales of both axes remain unchanged - }); - expect(scaleSelectors).toHaveLength(2); - - expect(errorSpy).not.toHaveBeenCalled(); // no error - errorSpy.mockRestore(); -}); - -test('warn in console when `SILX_style` attribute is not valid JSON', async () => { - const { selectExplorerNode } = await renderApp(); + // Unknown keys, invalid values + const { selectExplorerNode } = await renderApp( + '/nexus_malformed/silx_style_unknown' + ); - const warningSpy = mockConsoleMethod('warn'); - await selectExplorerNode('nexus_malformed/silx_style_malformed'); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum]); + const scaleSelectors = screen.getAllByRole('button', { name: 'Linear' }); + expect(scaleSelectors).toHaveLength(2); // scales remain unchanged - const tabs = await findVisSelectorTabs(); - expect(tabs).toHaveLength(1); - expect(tabs[0]).toHaveTextContent(NexusVis.NxSpectrum); + // Invalid JSON + await selectExplorerNode('silx_style_malformed'); + expect(getVisTabs()).toEqual([NexusVis.NxSpectrum]); expect(warningSpy).toHaveBeenCalledWith( - "Malformed 'SILX_style' attribute: {" + "Malformed 'SILX_style' attribute: {" // warn in console ); + + expect(errorSpy).not.toHaveBeenCalled(); // no error warningSpy.mockRestore(); + errorSpy.mockRestore(); }); test('cancel and retry slow fetch of NxSpectrum', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); + const { user } = await renderApp({ + initialPath: '/resilience/slow_nx_spectrum', + withFakeTimers: true, + }); - // Select NXdata group with spectrum interpretation and start fetching dataset values - await selectExplorerNode('resilience/slow_nx_spectrum'); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Cancel all fetches at once const errorSpy = mockConsoleMethod('error'); - await user.click(await screen.findByRole('button', { name: /Cancel/ })); - + await user.click(screen.getByRole('button', { name: /Cancel/ })); await expect(screen.findByText('Request cancelled')).resolves.toBeVisible(); - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces errorSpy.mockRestore(); // Retry all fetches at once - await user.click(await screen.findByRole('button', { name: /Retry/ })); + await user.click(screen.getByRole('button', { name: /Retry/ })); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Let fetches succeed - jest.runAllTimers(); - - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); }); test('cancel and retry slow fetch of NxImage', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); + const { user } = await renderApp({ + initialPath: '/resilience/slow_nx_image', + withFakeTimers: true, + }); - // Select NXdata group with image interpretation and start fetching dataset values - await selectExplorerNode('resilience/slow_nx_image'); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Cancel all fetches at once const errorSpy = mockConsoleMethod('error'); - await user.click(await screen.findByRole('button', { name: /Cancel/ })); + await user.click(screen.getByRole('button', { name: /Cancel/ })); await expect(screen.findByText('Request cancelled')).resolves.toBeVisible(); - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces errorSpy.mockRestore(); // Retry all fetches at once - await user.click(await screen.findByRole('button', { name: /Retry/ })); + await user.click(screen.getByRole('button', { name: /Retry/ })); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Let fetches succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); }); test('retry fetching automatically when re-selecting NxSpectrum', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); + const { user, selectExplorerNode } = await renderApp({ + initialPath: '/resilience/slow_nx_spectrum', + withFakeTimers: true, + }); - // Select NXdata group with spectrum interpretation and start fetching dataset values - await selectExplorerNode('resilience/slow_nx_spectrum'); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Cancel all fetches at once const errorSpy = mockConsoleMethod('error'); - await user.click(await screen.findByRole('button', { name: /Cancel/ })); + await user.click(screen.getByRole('button', { name: /Cancel/ })); await expect(screen.findByText('Request cancelled')).resolves.toBeVisible(); - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces errorSpy.mockRestore(); // Switch to other entity with no visualization await selectExplorerNode('entities'); await expect(screen.findByText(/No visualization/)).resolves.toBeVisible(); - // Select dataset again + // Select NXdata group again await selectExplorerNode('slow_nx_spectrum'); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Let fetches succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); -}); - -test('retry fetching automatically when re-selecting NxImage', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); - - // Select NXdata group with image interpretation and start fetching dataset values - await selectExplorerNode('resilience/slow_nx_image'); - await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); - - // Cancel all fetches at once - const errorSpy = mockConsoleMethod('error'); - await user.click(await screen.findByRole('button', { name: /Cancel/ })); - await expect(screen.findByText('Request cancelled')).resolves.toBeVisible(); - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces - errorSpy.mockRestore(); - - // Switch to other entity with no visualization - await selectExplorerNode('entities'); - await expect(screen.findByText(/No visualization/)).resolves.toBeVisible(); - - // Select dataset again - await selectExplorerNode('slow_nx_image'); - await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); - - // Let fetches succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); }); -test('retry fetching automatically when selecting other NxSpectrum slice', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); +test('retry fetching automatically when selecting other NxImage slice', async () => { + const { user } = await renderApp({ + initialPath: '/resilience/slow_nx_image', + withFakeTimers: true, + }); - // Select NXdata group with spectrum interpretation and start fetching dataset values - await selectExplorerNode('resilience/slow_nx_spectrum'); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Cancel all fetches at once const errorSpy = mockConsoleMethod('error'); - await user.click(await screen.findByRole('button', { name: /Cancel/ })); + await user.click(screen.getByRole('button', { name: /Cancel/ })); await expect(screen.findByText('Request cancelled')).resolves.toBeVisible(); - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces errorSpy.mockRestore(); // Move to other slice to retry fetching automatically - const d0Slider = screen.getByRole('slider', { name: 'Dimension slider' }); - d0Slider.focus(); - await user.keyboard('{PageUp}'); + const d0Slider = screen.getByRole('slider', { name: 'D0' }); + await user.type(d0Slider, '{PageUp}'); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Let fetches succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - - // Move back to first slice to retry fetching it automatically - await user.keyboard('{PageDown}'); - await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); - - // Let fetch of first slice succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - d0Slider.blur(); // remove focus to avoid state update after unmount - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); -}); - -test('retry fetching supporting datasets automatically when selecting other NxImage slice', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); - - // Select NXdata group with image interpretation and start fetching dataset values - await selectExplorerNode('resilience/slow_nx_image'); - await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); - - // Cancel all fetches at once - const errorSpy = mockConsoleMethod('error'); - await user.click(await screen.findByRole('button', { name: /Cancel/ })); - await expect(screen.findByText('Request cancelled')).resolves.toBeVisible(); - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces - errorSpy.mockRestore(); - - // Move to other slice to fetch new slice and retry fetching supporting datasets automatically - const d0Slider = screen.getByRole('slider', { name: 'Dimension slider' }); - d0Slider.focus(); - await user.keyboard('{PageUp}'); - await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); - - // Let fetches succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); // Move back to first slice to retry fetching it automatically - await user.keyboard('{PageDown}'); + await user.type(d0Slider, '{PageDown}'); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Let fetch of first slice succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - d0Slider.blur(); // remove focus to avoid state update after unmount - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); }); diff --git a/packages/app/src/__tests__/VisSelector.test.tsx b/packages/app/src/__tests__/VisSelector.test.tsx index a4d15db3a..36da2fc11 100644 --- a/packages/app/src/__tests__/VisSelector.test.tsx +++ b/packages/app/src/__tests__/VisSelector.test.tsx @@ -1,12 +1,12 @@ import { screen } from '@testing-library/react'; -import { renderApp } from '../test-utils'; +import { getSelectedVisTab, renderApp } from '../test-utils'; +import { Vis } from '../vis-packs/core/visualizations'; test('switch between visualizations', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nD_datasets/oneD'); + const { user } = await renderApp('/nD_datasets/oneD'); - const lineTab = await screen.findByRole('tab', { name: 'Line' }); + const lineTab = screen.getByRole('tab', { name: 'Line' }); expect(lineTab).toBeVisible(); expect(lineTab).toHaveAttribute('aria-selected', 'true'); @@ -16,82 +16,62 @@ test('switch between visualizations', async () => { // Switch to Matrix visualization await user.click(matrixTab); - - expect(screen.getByRole('tab', { name: 'Matrix' })).toHaveAttribute( - 'aria-selected', - 'true' - ); - expect(screen.getByRole('tab', { name: 'Line' })).toHaveAttribute( - 'aria-selected', - 'false' - ); + expect(matrixTab).toHaveAttribute('aria-selected', 'true'); + expect(lineTab).toHaveAttribute('aria-selected', 'false'); }); test('restore active visualization when switching to inspect mode and back', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nD_datasets/twoD'); + const { user, selectVisTab } = await renderApp('/nD_datasets/twoD'); // Switch to Line visualization - await user.click(await screen.findByRole('tab', { name: 'Line' })); + await selectVisTab(Vis.Line); // Switch to inspect mode and back - await user.click(await screen.findByRole('tab', { name: 'Inspect' })); - await user.click(await screen.findByRole('tab', { name: 'Display' })); + await user.click(screen.getByRole('tab', { name: 'Inspect' })); + await user.click(screen.getByRole('tab', { name: 'Display' })); - await expect( - screen.findByRole('tab', { name: 'Line' }) - ).resolves.toHaveAttribute('aria-selected', 'true'); + // Ensure Line visualization is active + expect(getSelectedVisTab()).toBe('Line'); }); test('choose most advanced visualization when switching between datasets', async () => { - const { selectExplorerNode } = await renderApp(); + const { selectExplorerNode } = await renderApp('/nD_datasets/oneD'); - await selectExplorerNode('nD_datasets/oneD'); - await expect( - screen.findByRole('tab', { name: 'Line' }) - ).resolves.toHaveAttribute('aria-selected', 'true'); + expect(getSelectedVisTab()).toBe('Line'); await selectExplorerNode('twoD'); - await expect( - screen.findByRole('tab', { name: 'Heatmap' }) - ).resolves.toHaveAttribute('aria-selected', 'true'); + expect(getSelectedVisTab()).toBe(Vis.Heatmap); await selectExplorerNode('threeD_rgb'); - await expect( - screen.findByRole('tab', { name: 'RGB' }) - ).resolves.toHaveAttribute('aria-selected', 'true'); + expect(getSelectedVisTab()).toBe(Vis.RGB); await selectExplorerNode('threeD_bool'); - await expect( - screen.findByRole('tab', { name: 'Matrix' }) - ).resolves.toHaveAttribute('aria-selected', 'true'); + expect(getSelectedVisTab()).toBe(Vis.Matrix); }); test('remember preferred visualization when switching between datasets', async () => { - const { user, selectExplorerNode } = await renderApp(); - await selectExplorerNode('nD_datasets/twoD'); + const { user, selectExplorerNode, selectVisTab } = await renderApp( + '/nD_datasets/twoD' + ); - /* Switch to Matrix vis. Since this is not the most advanced visualization + /* Switch to Matrix vis. Since this is _not_ the most advanced visualization * for `twoD`, it becomes the preferred visualization. */ - await user.click(await screen.findByRole('tab', { name: 'Matrix' })); + await selectVisTab(Vis.Matrix); + expect(getSelectedVisTab()).toBe('Matrix'); // Select another dataset for which the Matrix vis is not the most advanced visualization await selectExplorerNode('oneD'); // Check that the preferred visualization is restored - await expect( - screen.findByRole('tab', { name: 'Matrix' }) - ).resolves.toHaveAttribute('aria-selected', 'true'); + expect(getSelectedVisTab()).toBe(Vis.Matrix); /* Switch to Line vis. Since this _is_ the most advanced visualization for * `oneD`, the preferred visualization is cleared. */ - await user.click(await screen.findByRole('tab', { name: 'Line' })); // becomes preferred vis + await user.click(screen.getByRole('tab', { name: 'Line' })); // becomes preferred vis // Select another dataset with a more advanced visualization than Line await selectExplorerNode('threeD_rgb'); // Check that the most advanced visualization is selected - await expect( - screen.findByRole('tab', { name: 'RGB' }) - ).resolves.toHaveAttribute('aria-selected', 'true'); + expect(getSelectedVisTab()).toBe(Vis.RGB); }); diff --git a/packages/app/src/__tests__/Visualizer.test.tsx b/packages/app/src/__tests__/Visualizer.test.tsx index 49bc342da..ffdffed1b 100644 --- a/packages/app/src/__tests__/Visualizer.test.tsx +++ b/packages/app/src/__tests__/Visualizer.test.tsx @@ -1,117 +1,102 @@ import { screen } from '@testing-library/react'; -import { mockConsoleMethod, queryVisSelector, renderApp } from '../test-utils'; +import { SLOW_TIMEOUT } from '../providers/mock/mock-api'; +import { mockConsoleMethod, renderApp } from '../test-utils'; +import { Vis } from '../vis-packs/core/visualizations'; test('show fallback message when no visualization is supported', async () => { - const { selectExplorerNode } = await renderApp(); - await selectExplorerNode('entities'); // simple group - - await expect( - screen.findByText('No visualization available for this entity.') - ).resolves.toBeInTheDocument(); - expect(queryVisSelector()).not.toBeInTheDocument(); + await renderApp('/entities'); // simple group + expect(screen.getByText(/No visualization available/)).toBeVisible(); }); test('show loader while fetching dataset value', async () => { - jest.useFakeTimers('modern'); - const { selectExplorerNode } = await renderApp(); + await renderApp({ + initialPath: '/resilience/slow_value', + withFakeTimers: true, + }); - await selectExplorerNode('resilience/slow_value'); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); - - jest.runAllTimers(); // resolve slow fetch right away - await expect(screen.findByText(/42/)).resolves.toBeVisible(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + await expect( + screen.findByText(/42/, undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); }); test("show error when dataset value can't be fetched", async () => { - const { selectExplorerNode } = await renderApp(); - const errorSpy = mockConsoleMethod('error'); - await selectExplorerNode('resilience/error_value'); + const { selectExplorerNode } = await renderApp('/resilience/error_value'); - await expect(screen.findByText('error')).resolves.toBeVisible(); - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces + expect(screen.getByText('error')).toBeVisible(); errorSpy.mockRestore(); // Make sure error boundary resets when selecting another entity await selectExplorerNode('entities'); - await expect(screen.findByText(/No visualization/)).resolves.toBeVisible(); + expect(screen.getByText(/No visualization/)).toBeVisible(); }); test('cancel and retry slow fetch of dataset value', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); + const { user } = await renderApp({ + initialPath: '/resilience/slow_value', + withFakeTimers: true, + }); - // Select dataset and start fetching value - await selectExplorerNode('resilience/slow_value'); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Cancel fetch const errorSpy = mockConsoleMethod('error'); - await user.click(await screen.findByRole('button', { name: /Cancel/ })); - + await user.click(screen.getByRole('button', { name: /Cancel/ })); await expect(screen.findByText('Request cancelled')).resolves.toBeVisible(); - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces + // expect(errorSpy).toHaveBeenCalledTimes(3); // React logs two stack traces + one extra for some reason errorSpy.mockRestore(); // Retry fetch - await user.click(await screen.findByRole('button', { name: /Retry/ })); + await user.click(screen.getByRole('button', { name: /Retry/ })); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Let fetch succeed - jest.runAllTimers(); - await expect(screen.findByText(/42/)).resolves.toBeVisible(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + await expect( + screen.findByText(/42/, undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); }); test('cancel and retry slow fetch of dataset slice', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); + const { user } = await renderApp({ + initialPath: '/resilience/slow_slicing', + withFakeTimers: true, + }); - // Select dataset and start fetching first slice - await selectExplorerNode('resilience/slow_slicing'); await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); // Cancel fetch of first slice const errorSpy = mockConsoleMethod('error'); - await user.click(await screen.findByRole('button', { name: /Cancel/ })); - + await user.click(screen.getByRole('button', { name: /Cancel/ })); await expect(screen.findByText('Request cancelled')).resolves.toBeVisible(); - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces errorSpy.mockRestore(); // Retry fetch of first slice - await user.click(await screen.findByRole('button', { name: /Retry/ })); + await user.click(screen.getByRole('button', { name: /Retry/ })); await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); // Let fetch of first slice succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); }); test('retry fetching automatically when re-selecting dataset', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); + const { user, selectExplorerNode } = await renderApp({ + initialPath: '/resilience/slow_value', + withFakeTimers: true, + }); - // Select dataset and start fetching - await selectExplorerNode('resilience/slow_value'); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Cancel fetch const errorSpy = mockConsoleMethod('error'); - await user.click(await screen.findByRole('button', { name: /Cancel/ })); + await user.click(screen.getByRole('button', { name: /Cancel/ })); await expect(screen.findByText('Request cancelled')).resolves.toBeVisible(); errorSpy.mockRestore(); @@ -124,120 +109,121 @@ test('retry fetching automatically when re-selecting dataset', async () => { await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); // Let fetch succeed - jest.runAllTimers(); - await expect(screen.findByText(/42/)).resolves.toBeVisible(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + await expect( + screen.findByText(/42/, undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); }); test('retry fetching dataset slice automatically when re-selecting slice', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); + const { user } = await renderApp({ + initialPath: '/resilience/slow_slicing', + withFakeTimers: true, + }); - // Select dataset and start fetching first slice - await selectExplorerNode('resilience/slow_slicing'); await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); // Cancel fetch of first slice const errorSpy = mockConsoleMethod('error'); - await user.click(await screen.findByRole('button', { name: /Cancel/ })); + await user.click(screen.getByRole('button', { name: /Cancel/ })); await expect(screen.findByText('Request cancelled')).resolves.toBeVisible(); - expect(errorSpy).toHaveBeenCalledTimes(2); // React logs two stack traces errorSpy.mockRestore(); // Move to other slice and start fetching - const d0Slider = screen.getByRole('slider', { name: 'Dimension slider' }); - d0Slider.focus(); - await user.keyboard('{ArrowUp}'); + const d0Slider = screen.getByRole('slider', { name: 'D0' }); + await user.type(d0Slider, '{ArrowUp}'); + await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); // Let fetch of other slice succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); // Move back to first slice to retry fetching it automatically - await user.keyboard('{ArrowDown}'); + await user.type(d0Slider, '{ArrowDown}'); + await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); - d0Slider.blur(); // remove focus to avoid state update after unmount // Let fetch of first slice succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); }); test('cancel fetching dataset slice when changing entity', async () => { - jest.useFakeTimers('modern'); - const { selectExplorerNode } = await renderApp(); + const { selectExplorerNode } = await renderApp({ + initialPath: '/resilience/slow_slicing', + withFakeTimers: true, + }); - // Select dataset and start fetching first slice - await selectExplorerNode('resilience/slow_slicing'); await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); // Switch to another entity to cancel the fetch - await selectExplorerNode('resilience/slow_value'); + const errorSpy = mockConsoleMethod('error'); // `act` warning due to previous slice getting cancelled + await selectExplorerNode('slow_value'); await expect(screen.findByText(/Loading data/)).resolves.toBeVisible(); + errorSpy.mockRestore(); - // Let pending requests succeed - jest.runAllTimers(); + // Let fetch succeed + await expect( + screen.findByText(/42/, undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); + + // Reselect initial dataset + await selectExplorerNode('slow_slicing'); - // Reselect dataset and check that it refetches the first slice - await selectExplorerNode('resilience/slow_slicing'); - // The slice request was cancelled so it should be pending once again + // Ensure that fetching restarts (since it was cancelled) await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); // Let fetch of first slice succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); }); test('cancel fetching dataset slice when changing vis', async () => { - jest.useFakeTimers('modern'); - const { user, selectExplorerNode } = await renderApp(); + const { selectVisTab } = await renderApp({ + initialPath: '/resilience/slow_slicing', + withFakeTimers: true, + }); - // Select dataset and start fetching the slice - await selectExplorerNode('resilience/slow_slicing'); await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); - // Switch to the Line vis to cancel the fetch - await user.click(screen.getByRole('tab', { name: 'Line' })); + // Switch to Line visualization to cancel fetch + const errorSpy = mockConsoleMethod('error'); // `act` warning due to previous slice getting cancelled + await selectVisTab(Vis.Line); await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); + errorSpy.mockRestore(); // Let pending requests succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); + + // Switch back to Heatmap visualization + await selectVisTab(Vis.Heatmap); - // Switch back to Heatmap and check that it refetches the slice - await user.click(screen.getByRole('tab', { name: 'Heatmap' })); - // The slice request was cancelled so it should be pending once again + // Ensure that fetching restarts (since it was cancelled) await expect( screen.findByText(/Loading current slice/) ).resolves.toBeVisible(); - // Let fetch of the slice succeed - jest.runAllTimers(); - await expect(screen.findByRole('figure')).resolves.toBeVisible(); - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + // Let fetch of slice succeed + await expect( + screen.findByRole('figure', undefined, { timeout: SLOW_TIMEOUT }) + ).resolves.toBeVisible(); }); diff --git a/packages/app/src/dimension-mapper/SlicingSlider.tsx b/packages/app/src/dimension-mapper/SlicingSlider.tsx index 913d2fb35..97a854cde 100644 --- a/packages/app/src/dimension-mapper/SlicingSlider.tsx +++ b/packages/app/src/dimension-mapper/SlicingSlider.tsx @@ -4,7 +4,9 @@ import ReactSlider from 'react-slider'; import styles from './SlicingSlider.module.css'; +const ID = 'h5w-slider'; const MIN_HEIGHT_PER_MARK = 25; +export const SLICING_DEBOUNCE_DELAY = 250; interface Props { dimension: number; @@ -17,18 +19,25 @@ function SlicingSlider(props: Props) { const { dimension, maxIndex, initialValue, onChange } = props; const [value, setValue] = useState(initialValue); - const onDebouncedChange = useDebouncedCallback(onChange, [onChange], 250); + const onDebouncedChange = useDebouncedCallback( + onChange, + [onChange], + SLICING_DEBOUNCE_DELAY + ); const [containerSize, containerRef] = useMeasure(); + const sliderLabelId = `${ID}-${dimension}-label`; return (
- D{dimension} + + D{dimension} + 0:{maxIndex} {maxIndex > 0 ? ( {entity.name} }> - + }> @@ -72,6 +72,7 @@ function EntityItem(props: Props) { } > diff --git a/packages/app/src/explorer/Explorer.tsx b/packages/app/src/explorer/Explorer.tsx index 3eed01f00..8aeac5311 100644 --- a/packages/app/src/explorer/Explorer.tsx +++ b/packages/app/src/explorer/Explorer.tsx @@ -32,6 +32,7 @@ function Explorer(props: Props) { } > diff --git a/packages/app/src/metadata-viewer/AttrValueLoader.tsx b/packages/app/src/metadata-viewer/AttrValueLoader.tsx index 6a53ecbc8..462627ab4 100644 --- a/packages/app/src/metadata-viewer/AttrValueLoader.tsx +++ b/packages/app/src/metadata-viewer/AttrValueLoader.tsx @@ -2,7 +2,7 @@ import styles from './MetadataViewer.module.css'; function AttrValueLoader() { return ( - + Loading... ); diff --git a/packages/app/src/providers/mock/mock-api.ts b/packages/app/src/providers/mock/mock-api.ts index f6faa97c8..8d57a882a 100644 --- a/packages/app/src/providers/mock/mock-api.ts +++ b/packages/app/src/providers/mock/mock-api.ts @@ -25,7 +25,7 @@ import { applyMapping } from '../../vis-packs/core/utils'; import { DataProviderApi } from '../api'; import type { ValuesStoreParams } from '../models'; -const SLOW_TIMEOUT = 3000; +export const SLOW_TIMEOUT = 3000; export class MockApi extends DataProviderApi { public constructor() { diff --git a/packages/app/src/setupTests.ts b/packages/app/src/setupTests.ts index befef8e62..275c707aa 100644 --- a/packages/app/src/setupTests.ts +++ b/packages/app/src/setupTests.ts @@ -65,4 +65,10 @@ const errorSpy = mockConsoleMethod('error'); errorSpy.mockRestore(); // optional if end of test `); } + + // Restore real timers if applicable (if fake modern timers were used) + // eslint-disable-next-line prefer-object-has-own + if (Object.prototype.hasOwnProperty.call(setTimeout, 'clock')) { + jest.useRealTimers(); + } }); diff --git a/packages/app/src/test-utils.tsx b/packages/app/src/test-utils.tsx index 9de8831b8..7d94a54a3 100644 --- a/packages/app/src/test-utils.tsx +++ b/packages/app/src/test-utils.tsx @@ -1,5 +1,6 @@ +import { assertDefined, assertNonNull } from '@h5web/shared'; import type { RenderResult } from '@testing-library/react'; -import { render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import App from './App'; @@ -8,32 +9,64 @@ import type { Vis } from './vis-packs/core/visualizations'; interface RenderAppResult extends RenderResult { user: ReturnType; - selectExplorerNode: (path: string) => Promise; + selectExplorerNode: (name: string) => Promise; selectVisTab: (name: Vis) => Promise; } -export async function renderApp(): Promise { - const user = userEvent.setup({ delay: null }); // https://github.com/testing-library/user-event/issues/833 +type InitialPath = `/${string}`; +interface RenderAppOpts { + initialPath?: InitialPath; + preferredVis?: Vis | undefined; + withFakeTimers?: boolean; +} + +export async function renderApp( + opts: InitialPath | RenderAppOpts = '/' +): Promise { + const optsObj = typeof opts === 'string' ? { initialPath: opts } : opts; + const { initialPath, preferredVis, withFakeTimers }: RenderAppOpts = { + initialPath: '/', + ...optsObj, + }; + + if (preferredVis) { + window.localStorage.setItem( + 'h5web:preferredVis', + JSON.stringify(preferredVis) + ); + } + + if (withFakeTimers) { + jest.useFakeTimers('modern'); + } + + const user = userEvent.setup( + withFakeTimers ? { advanceTimers: jest.advanceTimersByTime } : undefined + ); + const renderResult = render( - + ); - await screen.findByLabelText('Loading root metadata...'); - await screen.findByRole('treeitem', { name: 'entities' }); + if (!withFakeTimers) { + await waitForAllLoaders(); + } return { user, ...renderResult, - selectExplorerNode: async (path) => { - for await (const segment of path.split('/')) { - await user.click( - await screen.findByRole('treeitem', { - name: new RegExp(`^${segment}(?: \\(NeXus group\\))?$`, 'u'), // account for potential NeXus badge - }) - ); + selectExplorerNode: async (name: string) => { + const item = await screen.findByRole('treeitem', { + name: new RegExp(`^${name}(?: \\(NeXus group\\))?$`, 'u'), // account for potential NeXus badge + }); + + await user.click(item); + + if (!withFakeTimers) { + await waitForAllLoaders(); } }, @@ -43,16 +76,33 @@ export async function renderApp(): Promise { }; } -export function queryVisSelector(): HTMLElement | null { - return screen.queryByRole('tablist', { name: 'Visualization' }); +export async function waitForAllLoaders(): Promise { + await waitFor(() => { + expect(screen.queryAllByTestId(/^Loading/u)).toHaveLength(0); + }); } -export async function findVisSelector(): Promise { - return screen.findByRole('tablist', { name: 'Visualization' }); +function getVisSelector(): HTMLElement { + return screen.getByRole('tablist', { name: 'Visualization' }); } -export async function findVisSelectorTabs(): Promise { - return within(await findVisSelector()).getAllByRole('tab'); +export function getVisTabs(): string[] { + return within(getVisSelector()) + .getAllByRole('tab') + .map((tab) => { + assertNonNull(tab.textContent); + return tab.textContent; + }); +} + +export function getSelectedVisTab(): string { + const selectedTab = within(getVisSelector()) + .getAllByRole('tab') + .find((tab) => tab.getAttribute('aria-selected') === 'true'); + + assertDefined(selectedTab); + assertNonNull(selectedTab.textContent); + return selectedTab.textContent; } /** @@ -60,8 +110,15 @@ export async function findVisSelectorTabs(): Promise { * Mocks are automatically restored after every test, but to restore * the original console method earlier, call `spy.mockRestore()`. */ -export function mockConsoleMethod(method: 'log' | 'warn' | 'error') { +export function mockConsoleMethod( + method: 'log' | 'warn' | 'error', + debug?: boolean +) { const spy = jest.spyOn(console, method); - spy.mockImplementation(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function + spy.mockImplementation((...args) => { + if (debug) { + console.debug(...args); // eslint-disable-line no-console + } + }); return spy; } diff --git a/packages/app/src/vis-packs/ValueLoader.tsx b/packages/app/src/vis-packs/ValueLoader.tsx index bef094368..0c1f859ce 100644 --- a/packages/app/src/vis-packs/ValueLoader.tsx +++ b/packages/app/src/vis-packs/ValueLoader.tsx @@ -5,6 +5,7 @@ import { useDataContext } from '../providers/DataProvider'; import styles from './ValueLoader.module.css'; const MAX_PROGRESS_BARS = 3; +const LOADER_DELAY = 100; interface Props { message?: string; @@ -23,42 +24,42 @@ function ValueLoader(props: Props) { }, [addProgressListener, removeProgressListener, setProgress]); // Wait a bit before showing loader to avoid flash - const [isReady] = useTimeout(100); - - if (!isReady()) { - return null; - } + const [isReady] = useTimeout(LOADER_DELAY); return ( -
-
-
-
-
-
-
-
-
-
-
-
- {progress && ( -
- {progress.slice(0, MAX_PROGRESS_BARS).map((val, index) => ( - // eslint-disable-line react/no-array-index-key - ))} -
+
+ {isReady() && ( + <> +
+
+
+
+
+
+
+
+
+
+
+ {progress && ( +
+ {progress.slice(0, MAX_PROGRESS_BARS).map((val, index) => ( + // eslint-disable-line react/no-array-index-key + ))} +
+ )} +

{message}...

+

+ +

+ )} -

{message}...

-

- -

); } diff --git a/packages/app/src/vis-packs/core/line/MappedLineVis.tsx b/packages/app/src/vis-packs/core/line/MappedLineVis.tsx index 6dc6eab74..315552adb 100644 --- a/packages/app/src/vis-packs/core/line/MappedLineVis.tsx +++ b/packages/app/src/vis-packs/core/line/MappedLineVis.tsx @@ -114,6 +114,7 @@ function MappedLineVis(props: Props) { label, array: auxArrays[i], }))} + testid={dimMapping.toString()} /> ); diff --git a/packages/lib/src/toolbar/controls/DomainSlider/BoundEditor.tsx b/packages/lib/src/toolbar/controls/DomainSlider/BoundEditor.tsx index e5026c1a3..03efdbe36 100644 --- a/packages/lib/src/toolbar/controls/DomainSlider/BoundEditor.tsx +++ b/packages/lib/src/toolbar/controls/DomainSlider/BoundEditor.tsx @@ -120,5 +120,7 @@ const BoundEditor = forwardRef((props, ref) => { ); }); +BoundEditor.displayName = 'BoundEditor'; + export type { Handle as BoundEditorHandle }; export default BoundEditor; diff --git a/packages/lib/src/toolbar/controls/DomainSlider/DomainSlider.tsx b/packages/lib/src/toolbar/controls/DomainSlider/DomainSlider.tsx index f7453e48d..aef170875 100644 --- a/packages/lib/src/toolbar/controls/DomainSlider/DomainSlider.tsx +++ b/packages/lib/src/toolbar/controls/DomainSlider/DomainSlider.tsx @@ -32,7 +32,7 @@ function DomainSlider(props: Props) { const [sliderDomain, setSliderDomain] = useState(visDomain); useEffect(() => { setSliderDomain(visDomain); - }, [visDomain, setSliderDomain]); + }, [visDomain]); const isAutoMin = customDomain[0] === null; const isAutoMax = customDomain[1] === null; diff --git a/packages/lib/src/toolbar/controls/DomainSlider/DomainTooltip.tsx b/packages/lib/src/toolbar/controls/DomainSlider/DomainTooltip.tsx index 35242965a..df0f83ee4 100644 --- a/packages/lib/src/toolbar/controls/DomainSlider/DomainTooltip.tsx +++ b/packages/lib/src/toolbar/controls/DomainSlider/DomainTooltip.tsx @@ -137,5 +137,7 @@ const DomainTooltip = forwardRef((props, ref) => { ); }); +DomainTooltip.displayName = 'DomainTooltip'; + export type { Handle as DomainTooltipHandle }; export default DomainTooltip; diff --git a/packages/lib/src/toolbar/controls/DomainSlider/Thumb.tsx b/packages/lib/src/toolbar/controls/DomainSlider/Thumb.tsx index 8ea9f3815..a84ccc784 100644 --- a/packages/lib/src/toolbar/controls/DomainSlider/Thumb.tsx +++ b/packages/lib/src/toolbar/controls/DomainSlider/Thumb.tsx @@ -35,4 +35,5 @@ const Thumb = forwardRef((props, ref) => { ); }); +Thumb.displayName = 'Thumb'; export default Thumb; diff --git a/packages/lib/src/vis/line/LineVis.tsx b/packages/lib/src/vis/line/LineVis.tsx index 9955ddb1e..1c3f9d8ac 100644 --- a/packages/lib/src/vis/line/LineVis.tsx +++ b/packages/lib/src/vis/line/LineVis.tsx @@ -58,6 +58,7 @@ interface Props { renderTooltip?: (data: TooltipData) => ReactElement; children?: ReactNode; interactions?: Interactions; + testid?: string; } function LineVis(props: Props) { @@ -77,6 +78,7 @@ function LineVis(props: Props) { renderTooltip, children, interactions, + testid, } = props; const { @@ -112,6 +114,7 @@ function LineVis(props: Props) { className={styles.root} aria-label={title} data-keep-canvas-colors + data-testid={testid} > ((props, ref) => { ); }); +StickyGrid.displayName = 'StickyGrid'; export default StickyGrid; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index beddbc11a..c11e50440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4139,24 +4139,10 @@ packages: cypress: '*' dependencies: '@babel/runtime': 7.16.3 - '@testing-library/dom': 8.11.1 + '@testing-library/dom': 8.13.0 cypress: 9.5.0 dev: true - /@testing-library/dom/8.11.1: - resolution: {integrity: sha512-3KQDyx9r0RKYailW2MiYrSSKEfH0GTkI51UGEvJenvcoDoeRYs0PZpi2SXqtnMClQvCqdtTTpOfFETDTVADpAg==} - engines: {node: '>=12'} - dependencies: - '@babel/code-frame': 7.16.7 - '@babel/runtime': 7.16.3 - '@types/aria-query': 4.2.2 - aria-query: 5.0.0 - chalk: 4.1.2 - dom-accessibility-api: 0.5.10 - lz-string: 1.4.4 - pretty-format: 27.5.1 - dev: true - /@testing-library/dom/8.11.3: resolution: {integrity: sha512-9LId28I+lx70wUiZjLvi1DB/WT2zGOxUh46glrSNMaWVx849kKAluezVzZrXJfTKKoQTmEOutLes/bHg4Bj3aA==} engines: {node: '>=12'} @@ -7567,10 +7553,6 @@ packages: esutils: 2.0.3 dev: true - /dom-accessibility-api/0.5.10: - resolution: {integrity: sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g==} - dev: true - /dom-accessibility-api/0.5.12: resolution: {integrity: sha512-gQ2mON6fLWZeM8ubjzL7RtMeHS/g8hb82j4MjHmcQECD7pevWsMlhqwp9BjIRrQvmyJMMyv/XiO1cXzeFlUw4g==} dev: true