From 3089666e5c8dd6bed83cd3d55d7b318f74f9b149 Mon Sep 17 00:00:00 2001 From: Ronja Date: Tue, 10 Nov 2026 20:34:59 +0100 Subject: [PATCH] refactor(tests): split the ElementsPage test file by topic The 1375-line file becomes eight files of at most 260 lines (the page, the filters, the filters and the address, the sort, the combined cases, the results, the search and Load more), with the shared set-up in src/tests/elementsPage.tsx. No test is changed: 64 tests before and after. Closes #181 --- README.md | 4 +- src/tests/elementsPage.tsx | 220 +++ .../pages/ElementsPage.combined.test.tsx | 104 ++ src/tests/pages/ElementsPage.filters.test.tsx | 258 ++++ .../ElementsPage.filtersAddress.test.tsx | 238 +++ .../pages/ElementsPage.loadMore.test.tsx | 146 ++ src/tests/pages/ElementsPage.results.test.tsx | 129 ++ src/tests/pages/ElementsPage.search.test.tsx | 118 ++ src/tests/pages/ElementsPage.sort.test.tsx | 216 +++ src/tests/pages/ElementsPage.test.tsx | 1305 +---------------- 10 files changed, 1438 insertions(+), 1300 deletions(-) create mode 100644 src/tests/elementsPage.tsx create mode 100644 src/tests/pages/ElementsPage.combined.test.tsx create mode 100644 src/tests/pages/ElementsPage.filters.test.tsx create mode 100644 src/tests/pages/ElementsPage.filtersAddress.test.tsx create mode 100644 src/tests/pages/ElementsPage.loadMore.test.tsx create mode 100644 src/tests/pages/ElementsPage.results.test.tsx create mode 100644 src/tests/pages/ElementsPage.search.test.tsx create mode 100644 src/tests/pages/ElementsPage.sort.test.tsx diff --git a/README.md b/README.md index 417aa2f..75af190 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The calculator and About pages are placeholders. Adding items from the catalog t | Review | What to look at | | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | | [Running application](http://it2810-29.idi.ntnu.no/project2) | Search, load more, element details, and the Your gymnasts test login | -| [ElementsPage tests](src/tests/pages/ElementsPage.test.tsx) | Search, result paging, URL state, loading, and error behavior | +| [ElementsPage tests](src/tests/pages/) | Search, result paging, URL state, loading, and error behavior | | [Element detail tests](src/tests/pages/ElementDetailPage.test.tsx) | Catalog detail rendering and error/loading states | | [Gymnast page tests](src/tests/pages/YourGymnastsPage.test.tsx) | Test login, loading, profile creation, and error handling | | [Backend catalog tests](backend/src/catalog/search.test.ts) | Search, filters, sorting, pagination, and count limits | @@ -254,7 +254,7 @@ The Elements page (`/elements`, `src/pages/ElementsPage.tsx`) is where a coach s ### Testing -- `src/tests/pages/ElementsPage.test.tsx` runs the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it. +- `src/tests/pages/ElementsPage*.test.tsx` (one file for each topic, with the shared set-up in `src/tests/elementsPage.tsx`) run the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it. - The backend tests (`backend/src/catalog/search.test.ts`, `backend/src/server/server.test.ts`) cover the vault filter, the range of the values and `Apparatus.difficultyValues`, and need the seeded database like the rest. - Not everything can be tested in jsdom (for example the tooltip, which Base UI opens from real pointer events). Those parts were checked in a real browser against the real backend. diff --git a/src/tests/elementsPage.tsx b/src/tests/elementsPage.tsx new file mode 100644 index 0000000..75bcd99 --- /dev/null +++ b/src/tests/elementsPage.tsx @@ -0,0 +1,220 @@ +/* eslint-disable react-refresh/only-export-components -- test helpers, not part of the app, so there is no fast refresh to protect */ +// Shared by the tests of the Elements page (src/tests/pages/ElementsPage*.test.tsx). +// The page runs on the real Apollo Client, with a stub for `fetch` that answers like +// the server (see src/tests/apollo.ts and "Search, filter, sort and pagination" in +// docs/API.md). + +import { ApolloProvider } from '@apollo/client/react' +import { fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom' +import { type ElementListRow } from '@/lib/elementList' +import { + createTestClient, + ok, + type Answer, + type Handler, + type Operation, +} from '@/tests/apollo' +import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows' +import ElementsPage from '@/pages/ElementsPage' + +/** What the server says for one row (an Element has `value`, a Vault has `dValue`). */ +export const nodeOf = (row: ElementListRow) => ({ + __typename: row.isVault ? 'Vault' : 'Element', + id: row.id, + code: row.code, + name: row.name, + description: row.description, + imageUrl: row.imageUrl, + elementGroup: { + __typename: 'ElementGroup', + name: row.elementGroupName, + number: row.elementGroupNumber, + apparatus: { __typename: 'Apparatus', name: row.apparatusName }, + }, + ...(row.isVault ? { dValue: row.value } : { value: row.value }), + variants: row.hasVariants + ? [{ __typename: 'Element', id: `${row.id}-x` }] + : [], +}) + +/** What the server says to `SearchCatalog`. */ +export const searchAnswer = ( + rows: ElementListRow[], + totalCount = rows.length, + hasNextPage = false, +): Answer => + ok({ + searchCatalog: { + __typename: 'CatalogConnection', + totalCount, + pageInfo: { + __typename: 'PageInfo', + hasNextPage, + endCursor: rows.length ? rows[rows.length - 1].id : null, + }, + edges: rows.map((row) => ({ + __typename: 'CatalogEdge', + cursor: row.id, + node: nodeOf(row), + })), + }, + }) + +/** The server has seven rows, and finds only those with the search text in them. */ +export const serverWithRows: Handler = ({ variables }) => { + const text = String(variables['search'] ?? '').toLowerCase() + const found = exampleElementRows.filter((row) => + `${row.code} ${row.name ?? ''} ${row.description}` + .toLowerCase() + .includes(text), + ) + return searchAnswer(found, text ? found.length : exampleTotalCount) +} + +/** + * A server that has the seven rows in pages of `size`: the first page, and then the + * page after the row that `after` is the cursor of (the id of a row). + */ +export const pagedServer = + (size: number): Handler => + ({ variables }) => { + const after = variables['after'] + const start = after + ? exampleElementRows.findIndex((row) => row.id === after) + 1 + : 0 + const page = exampleElementRows.slice(start, start + size) + return searchAnswer( + page, + exampleElementRows.length, + start + size < exampleElementRows.length, + ) + } + +/** Shows the address, so a test can see that the search is in it. */ +export function Address() { + const location = useLocation() + return ( + {location.pathname + location.search} + ) +} + +/** A button that goes to another address, like the back button does. */ +export function GoTo({ to }: { to: string }) { + const navigate = useNavigate() + return ( + + ) +} + +/** What the server says to `CatalogStructure`: the apparatus with their groups. */ +export const structure = [ + { + __typename: 'Apparatus', + id: 'VT', + name: 'Vault', + difficultyValues: [1.6, 2, 2.4, 3.2], + groups: [ + { + __typename: 'ElementGroup', + id: 'VT-1', + number: 1, + name: 'Long vault title', + }, + { + __typename: 'ElementGroup', + id: 'VT-2', + number: 2, + name: 'Another long title', + }, + ], + }, + { + __typename: 'Apparatus', + id: 'UB', + name: 'Uneven bars', + difficultyValues: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1], + groups: [ + { __typename: 'ElementGroup', id: 'UB-1', number: 1, name: 'Mounts' }, + { __typename: 'ElementGroup', id: 'UB-2', number: 2, name: 'Dismounts' }, + ], + }, +] + +/** + * Starts the page. `calls` are the searches that were sent: the structure of the + * filters is answered here and is counted in `structureCalls`, so each test can look at + * the searches alone. + */ +export function renderPage( + handler: Handler, + address = '/elements', + extra?: string, + // What the server says to the structure, by the number of the call (the first is 0). + structureAnswer: (call: number) => Answer = () => + ok({ apparatus: structure }), +) { + const calls: Operation[] = [] + const structureCalls: Operation[] = [] + const { client } = createTestClient((operation) => { + if (operation.operationName === 'CatalogStructure') { + structureCalls.push(operation) + return structureAnswer(structureCalls.length - 1) + } + calls.push(operation) + return handler(operation) + }) + render( + + + +
+ {extra && } + + , + ) + return { calls, structureCalls } +} + +export const field = () => screen.getByRole('searchbox') +export const address = () => screen.getByTestId('address').textContent +export const wait = { timeout: 3000 } + +/** Opens a menu like a click does: the pointer goes down and up on the button. */ +export async function openMenu(name: string | RegExp) { + const button = screen.getByRole('button', { name }) + fireEvent.pointerDown(button, { button: 0, pointerType: 'mouse' }) + fireEvent.mouseDown(button, { button: 0 }) + fireEvent.pointerUp(button, { button: 0, pointerType: 'mouse' }) + fireEvent.mouseUp(button, { button: 0 }) + fireEvent.click(button, { button: 0 }) + return screen.findByRole('menu') +} + +export const choose = ( + role: 'menuitemradio' | 'menuitemcheckbox', + name: string, +) => fireEvent.click(screen.getByRole(role, { name })) + +export const lastFilter = (calls: Operation[]) => + calls.at(-1)!.variables['filter'] + +export const chooseSort = (name: string) => + fireEvent.click(screen.getByRole('menuitemradio', { name })) + +export const sortButton = (name = /Sort by Code: low to high/) => + screen.getByRole('button', { name }) + +/** A server that pages the seven rows, and also finds rows by their text. */ +export function serverWithRowsPaged(operation: Parameters[0]) { + const text = String(operation.variables['search'] ?? '').toLowerCase() + if (!text) return pagedServer(4)(operation) + const found = exampleElementRows.filter((row) => + `${row.code} ${row.name ?? ''} ${row.description}` + .toLowerCase() + .includes(text), + ) + return searchAnswer(found, found.length) +} diff --git a/src/tests/pages/ElementsPage.combined.test.tsx b/src/tests/pages/ElementsPage.combined.test.tsx new file mode 100644 index 0000000..d10df55 --- /dev/null +++ b/src/tests/pages/ElementsPage.combined.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { + serverWithRows, + renderPage, + address, + openMenu, + choose, + chooseSort, + sortButton, +} from '@/tests/elementsPage' + +afterEach(cleanup) + +describe('ElementsPage: the search, the filters and the sort together', () => { + it('sends all three in one request, and keeps all three in the address', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?search=salto&apparatus=UB&group=UB-2&difficulty=A,B&sort=difficulty&dir=desc', + ) + await screen.findByText(/Showing/) + + expect(calls).toHaveLength(1) + expect(calls[0].variables).toMatchObject({ + search: 'salto', + filter: { + apparatusId: 'UB', + elementGroupId: 'UB-2', + difficultyValues: [0.1, 0.2], + }, + sort: { field: 'DIFFICULTY', direction: 'DESC' }, + }) + expect(sortButton(/Sort by Difficulty: high to low/)).toBeTruthy() + + // A new filter keeps the search and the sort. + await openMenu(/^Difficulty: A, B/) + choose('menuitemcheckbox', 'C') + await waitFor(() => + expect(address()).toBe( + '/elements?search=salto&apparatus=UB&group=UB-2&difficulty=A%2CB%2CC&sort=difficulty&dir=desc', + ), + ) + }) + + it('can sort vault by difficulty, because the apparatus filter is set', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu('Apparatus') + choose('menuitemradio', 'Vault') + await waitFor(() => expect(address()).toBe('/elements?apparatus=VT')) + + await openMenu(/Sort by/) + chooseSort('Difficulty: low to high') + await waitFor(() => + expect(calls.at(-1)!.variables).toMatchObject({ + filter: { apparatusId: 'VT' }, + sort: { field: 'DIFFICULTY', direction: 'ASC' }, + }), + ) + }) + + it('falls back to sorting by code when the apparatus is cleared, so the API gets no BAD_USER_INPUT', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?apparatus=UB&sort=difficulty', + ) + await screen.findByText(/Showing/) + expect(sortButton(/Sort by Difficulty/)).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })) + await waitFor(() => + expect(sortButton(/Sort by Code: low to high/)).toBeTruthy(), + ) + await waitFor(() => + expect(calls.at(-1)!.variables).not.toHaveProperty('sort'), + ) + expect(calls.at(-1)!.variables['filter']).toBeNull() + }) + + it('offers Difficulty in the sort menu once an apparatus is chosen in the filter', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu(/Sort by/) + expect( + screen + .getByRole('menuitemradio', { name: 'Difficulty: low to high' }) + .getAttribute('aria-disabled'), + ).toBe('true') + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()) + + await openMenu('Apparatus') + choose('menuitemradio', 'Uneven bars') + await waitFor(() => expect(address()).toBe('/elements?apparatus=UB')) + await openMenu(/Sort by/) + expect( + screen + .getByRole('menuitemradio', { name: 'Difficulty: low to high' }) + .getAttribute('aria-disabled'), + ).not.toBe('true') + }) +}) diff --git a/src/tests/pages/ElementsPage.filters.test.tsx b/src/tests/pages/ElementsPage.filters.test.tsx new file mode 100644 index 0000000..ed7691f --- /dev/null +++ b/src/tests/pages/ElementsPage.filters.test.tsx @@ -0,0 +1,258 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { + serverWithRows, + renderPage, + address, + openMenu, + choose, + lastFilter, +} from '@/tests/elementsPage' + +afterEach(cleanup) + +describe('ElementsPage: the filters', () => { + it('sends no filter when none is chosen, and keeps the address clean', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + + expect(calls[0].variables['filter']).toBeNull() + expect(address()).toBe('/elements') + expect(screen.queryByRole('button', { name: 'Clear filters' })).toBeNull() + }) + + it('asks for the structure of the filters once, however many filters change', async () => { + const { structureCalls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + + await openMenu('Apparatus') + choose('menuitemradio', 'Uneven bars') + await waitFor(() => expect(address()).toContain('apparatus=UB')) + await screen.findByText(/Showing/) + + expect(structureCalls).toHaveLength(1) + }) + + it('filters by apparatus: sends it, puts it in the address, and says it on the button', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu('Apparatus') + choose('menuitemradio', 'Uneven bars') + + await waitFor(() => expect(address()).toBe('/elements?apparatus=UB')) + // One choice is made, so the menu closes. + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()) + await waitFor(() => + expect(lastFilter(calls)).toEqual({ apparatusId: 'UB' }), + ) + expect( + screen.getByRole('button', { name: 'Apparatus: Uneven bars' }), + ).toBeTruthy() + }) + + it('has the apparatus as radio items, and the chosen one is checked', async () => { + renderPage(serverWithRows, '/elements?apparatus=UB') + await screen.findByText(/Showing/) + await openMenu(/Apparatus/) + + const checked = (name: string) => + screen.getByRole('menuitemradio', { name }).getAttribute('aria-checked') + expect(checked('Uneven bars')).toBe('true') + expect(checked('Vault')).toBe('false') + expect(checked('All apparatus')).toBe('false') + }) + + it('disables the group filter before an apparatus is chosen, still reachable by the keyboard, and says why', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + + const button = screen.getByRole('button', { name: 'Element group' }) + const reason = 'Choose an apparatus to choose a group.' + // aria-disabled and not disabled: the keyboard can reach it, so the reason can be found. + expect(button.getAttribute('aria-disabled')).toBe('true') + expect(button.hasAttribute('disabled')).toBe(false) + expect(button.getAttribute('tabindex')).not.toBe('-1') + // A screen reader says the reason with the button. + expect(button.getAttribute('aria-describedby')).toBeTruthy() + expect( + document.getElementById(button.getAttribute('aria-describedby')!) + ?.textContent, + ).toBe(reason) + + // It opens no menu and changes nothing when it is pressed, but the reason is shown: a + // tooltip does not open on a tap, so a touch user would not see it otherwise. + expect(screen.getAllByText(reason)).toHaveLength(1) + fireEvent.click(button) + expect(screen.queryByRole('menu')).toBeNull() + expect(address()).toBe('/elements') + await waitFor(() => expect(screen.getAllByText(reason)).toHaveLength(2)) + + // The tooltip (hover and focus) repeats the reason for sighted users. Base UI opens it + // from real pointer and focus events, which jsdom does not give, so it is checked in a browser. + }) + + it('enables the group filter when an apparatus is chosen', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu('Apparatus') + choose('menuitemradio', 'Uneven bars') + + await waitFor(() => + expect( + screen + .getByRole('button', { name: 'Element group' }) + .getAttribute('aria-disabled'), + ).toBeNull(), + ) + await openMenu('Element group') + expect(screen.getByRole('menuitemradio', { name: 'Mounts' })).toBeTruthy() + }) + + it('filters by group of the chosen apparatus, and shows a vault group as Group N', async () => { + const { calls } = renderPage(serverWithRows, '/elements?apparatus=UB') + await screen.findByText(/Showing/) + await openMenu('Element group') + expect(screen.getByRole('menuitemradio', { name: 'Mounts' })).toBeTruthy() + expect(screen.queryByRole('menuitemradio', { name: 'Group 1' })).toBeNull() + choose('menuitemradio', 'Dismounts') + + await waitFor(() => + expect(address()).toBe('/elements?apparatus=UB&group=UB-2'), + ) + await waitFor(() => + expect(lastFilter(calls)).toEqual({ + apparatusId: 'UB', + elementGroupId: 'UB-2', + }), + ) + cleanup() + + renderPage(serverWithRows, '/elements?apparatus=VT') + await screen.findByText(/Showing/) + await openMenu('Element group') + expect(screen.getByRole('menuitemradio', { name: 'Group 1' })).toBeTruthy() + expect(screen.queryByText('Long vault title')).toBeNull() + }) + + it('clears the group when the apparatus changes', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?apparatus=UB&group=UB-2', + ) + await screen.findByText(/Showing/) + await openMenu(/Apparatus/) + choose('menuitemradio', 'Vault') + + await waitFor(() => expect(address()).toBe('/elements?apparatus=VT')) + await waitFor(() => + expect(lastFilter(calls)).toEqual({ apparatusId: 'VT' }), + ) + }) + + it('filters by one or more difficulty letters, sends the values, and keeps the menu open', async () => { + const { calls } = renderPage(serverWithRows, '/elements?apparatus=UB') + await screen.findByText(/Showing/) + await openMenu('Difficulty') + choose('menuitemcheckbox', 'B') + choose('menuitemcheckbox', 'A') + + expect(screen.getByRole('menu')).toBeTruthy() + await waitFor(() => + expect(address()).toBe('/elements?apparatus=UB&difficulty=A%2CB'), + ) + await waitFor(() => + expect(lastFilter(calls)).toEqual({ + apparatusId: 'UB', + difficultyValues: [0.1, 0.2], + }), + ) + expect( + screen + .getByRole('menuitemcheckbox', { name: 'A' }) + .getAttribute('aria-checked'), + ).toBe('true') + expect( + screen.getByRole('button', { name: 'Difficulty: A, B' }), + ).toBeTruthy() + + choose('menuitemcheckbox', 'A') + await waitFor(() => + expect(address()).toBe('/elements?apparatus=UB&difficulty=B'), + ) + }) + + it('filters vault by bands of its D-score, in ascending order, sent as the values inside', async () => { + const { calls } = renderPage(serverWithRows, '/elements?apparatus=VT') + await screen.findByText(/Showing/) + const button = screen.getByRole('button', { name: 'Difficulty' }) + expect(button.getAttribute('aria-disabled')).toBeNull() + await openMenu('Difficulty') + + // The bands that have a D-value (1.6, 2, 2.4, 3.2), and not 23 single values. + expect( + screen.getAllByRole('menuitemcheckbox').map((item) => item.textContent), + ).toEqual(['1.0–1.9', '2.0–2.9', '3.0–3.9']) + choose('menuitemcheckbox', '3.0–3.9') + choose('menuitemcheckbox', '2.0–2.9') + + await waitFor(() => + expect(address()).toBe('/elements?apparatus=VT&difficulty=2%2C3'), + ) + // Each band is the D-values that exist inside it, so the API needs no ranges. + await waitFor(() => + expect(lastFilter(calls)).toEqual({ + apparatusId: 'VT', + difficultyValues: [2, 2.4, 3.2], + }), + ) + expect( + screen.getByRole('button', { name: 'Difficulty: 2.0–2.9, 3.0–3.9' }), + ).toBeTruthy() + }) + + it('starts from the bands in the address, and leaves out letters, values and bands that do not exist', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?apparatus=VT&difficulty=3,A,9,2.4', + ) + await screen.findByText(/Showing/) + expect(calls[0].variables['filter']).toEqual({ + apparatusId: 'VT', + difficultyValues: [3.2], + }) + }) + + it('drops the chosen difficulty when the apparatus changes between vault and the others, but not between two others', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?apparatus=UB&difficulty=A', + ) + await screen.findByText(/Showing/) + await openMenu(/Apparatus/) + choose('menuitemradio', 'Vault') + await waitFor(() => expect(address()).toBe('/elements?apparatus=VT')) + await waitFor(() => + expect(lastFilter(calls)).toEqual({ apparatusId: 'VT' }), + ) + + await openMenu('Difficulty') + choose('menuitemcheckbox', '2.0–2.9') + await waitFor(() => expect(address()).toContain('difficulty=2')) + await openMenu(/Apparatus/) + choose('menuitemradio', 'Uneven bars') + await waitFor(() => expect(address()).toBe('/elements?apparatus=UB')) + }) + + it('can filter by difficulty without an apparatus', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu('Difficulty') + choose('menuitemcheckbox', 'C') + + await waitFor(() => + expect(lastFilter(calls)).toEqual({ difficultyValues: [0.3] }), + ) + }) +}) diff --git a/src/tests/pages/ElementsPage.filtersAddress.test.tsx b/src/tests/pages/ElementsPage.filtersAddress.test.tsx new file mode 100644 index 0000000..fff6270 --- /dev/null +++ b/src/tests/pages/ElementsPage.filtersAddress.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment jsdom + +import { ApolloProvider } from '@apollo/client/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import { + createTestClient, + deferred, + failure, + ok, + type Answer, + type Operation, +} from '@/tests/apollo' +import { exampleElementRows } from '@/tests/elementRows' +import ElementsPage from '@/pages/ElementsPage' +import { + searchAnswer, + serverWithRows, + pagedServer, + structure, + renderPage, + field, + address, + wait, + openMenu, + choose, + lastFilter, +} from '@/tests/elementsPage' + +afterEach(cleanup) + +describe('ElementsPage: the filters, the address and the structure', () => { + it('starts from the filters in the address, together with the search', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?search=amanar&apparatus=UB&group=UB-1&difficulty=D,E', + ) + await screen.findByText(/Showing/) + + expect(calls).toHaveLength(1) + expect(calls[0].variables).toMatchObject({ + search: 'amanar', + filter: { + apparatusId: 'UB', + elementGroupId: 'UB-1', + difficultyValues: [0.4, 0.5], + }, + }) + expect( + screen.getByRole('button', { name: 'Apparatus: Uneven bars' }), + ).toBeTruthy() + expect( + screen.getByRole('button', { name: 'Element group: Mounts' }), + ).toBeTruthy() + expect( + screen.getByRole('button', { name: 'Difficulty: D, E' }), + ).toBeTruthy() + expect(field().value).toBe('amanar') + }) + + it('keeps the filters when the search changes, and the search when a filter changes', async () => { + renderPage(serverWithRows, '/elements?apparatus=UB') + await screen.findByText(/Showing/) + + fireEvent.change(field(), { target: { value: 'biles' } }) + await waitFor( + () => expect(address()).toBe('/elements?apparatus=UB&search=biles'), + wait, + ) + + await openMenu('Difficulty') + choose('menuitemcheckbox', 'A') + await waitFor(() => + expect(address()).toBe( + '/elements?apparatus=UB&search=biles&difficulty=A', + ), + ) + }) + + it('leaves out what the address says that does not exist or does not fit, so the API gets no BAD_USER_INPUT', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?apparatus=XX&group=VT-1&difficulty=A,Z', + ) + await screen.findByText(/Showing/) + // The group is of vault, so it gives the apparatus, and vault has no difficulty. + expect(calls[0].variables['filter']).toEqual({ + apparatusId: 'VT', + elementGroupId: 'VT-1', + }) + cleanup() + + const other = renderPage( + serverWithRows, + '/elements?apparatus=UB&group=VT-1&difficulty=a,zz', + ) + await screen.findByText(/Showing/) + expect(other.calls[0].variables['filter']).toEqual({ + apparatusId: 'UB', + difficultyValues: [0.1], + }) + }) + + it('does not search before the structure is known when the address has a filter, so a wrong value is never sent', async () => { + const wait1 = deferred() + const calls: Operation[] = [] + const { client } = createTestClient((operation) => { + calls.push(operation) + return operation.operationName === 'CatalogStructure' + ? wait1.promise + : searchAnswer(exampleElementRows) + }) + render( + + + + + , + ) + + expect(await screen.findByText('Loading elements…')).toBeTruthy() + expect(calls.map((c) => c.operationName)).toEqual(['CatalogStructure']) + + wait1.resolve(ok({ apparatus: structure })) + await screen.findByText(/Showing/) + expect(calls.map((c) => c.operationName)).toEqual([ + 'CatalogStructure', + 'SearchCatalog', + ]) + }) + + it('says what went wrong and does not search when the structure fails and the address has filters', async () => { + const { calls, structureCalls } = renderPage( + serverWithRows, + '/elements?apparatus=UB&difficulty=A', + undefined, + (call) => + call === 0 + ? failure('INTERNAL_SERVER_ERROR', 'Something broke') + : ok({ apparatus: structure }), + ) + + // The error, and no results under an address that says UB: the filters are not dropped. + expect( + await screen.findByText('Something went wrong on our side. Try again.'), + ).toBeTruthy() + expect(calls).toHaveLength(0) + expect(screen.queryByText(/Showing/)).toBeNull() + expect(address()).toBe('/elements?apparatus=UB&difficulty=A') + + // Trying again loads the structure, and then searches with the filters of the address. + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + await screen.findByText(/Showing/) + expect(structureCalls).toHaveLength(2) + expect(calls).toHaveLength(1) + expect(calls[0].variables['filter']).toEqual({ + apparatusId: 'UB', + difficultyValues: [0.1], + }) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('still searches when the structure fails and the address has no filter', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?search=amanar', + undefined, + () => failure('INTERNAL_SERVER_ERROR', 'Something broke'), + ) + await screen.findByText(/Showing/) + expect(calls).toHaveLength(1) + expect(calls[0].variables).toMatchObject({ search: 'amanar', filter: null }) + }) + + it('clears every filter at once, and keeps the search', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?search=amanar&apparatus=UB&group=UB-1&difficulty=D', + ) + await screen.findByText(/Showing/) + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })) + + await waitFor(() => expect(address()).toBe('/elements?search=amanar')) + await waitFor(() => expect(lastFilter(calls)).toBeNull()) + expect(screen.queryByRole('button', { name: 'Clear filters' })).toBeNull() + // The button is gone, so the focus goes to the first filter and not to the page. + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'Apparatus' }), + ) + }) + + it('follows the address when the user goes to another one, like the back button does', async () => { + renderPage(serverWithRows, '/elements', '/elements?apparatus=UB') + await screen.findByText(/Showing/) + fireEvent.click( + screen.getByRole('button', { name: 'go to /elements?apparatus=UB' }), + ) + + await waitFor(() => expect(address()).toBe('/elements?apparatus=UB')) + await screen.findByRole('button', { name: 'Clear filters' }) + }) + + it('starts a changed filter from the first page, and loads more with the same filter', async () => { + const { calls } = renderPage(pagedServer(4)) + await screen.findByText(/Showing 4 of/) + fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) + await screen.findByText(/Showing 7 of/) + + await openMenu('Apparatus') + choose('menuitemradio', 'Uneven bars') + await screen.findByText(/Showing 4 of/) + const last = calls.at(-1)! + expect(last.operationName).toBe('SearchCatalog') + expect(last.variables).not.toHaveProperty('after') + expect(last.variables['filter']).toEqual({ apparatusId: 'UB' }) + + fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) + await screen.findByText(/Showing 7 of/) + expect(calls.at(-1)!.variables).toMatchObject({ + filter: { apparatusId: 'UB' }, + after: expect.any(String), + }) + }) + + it('says that nothing matches the filters', async () => { + renderPage(() => searchAnswer([]), '/elements?apparatus=UB') + expect( + await screen.findByText('No elements match these filters.'), + ).toBeTruthy() + }) +}) diff --git a/src/tests/pages/ElementsPage.loadMore.test.tsx b/src/tests/pages/ElementsPage.loadMore.test.tsx new file mode 100644 index 0000000..a780a52 --- /dev/null +++ b/src/tests/pages/ElementsPage.loadMore.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' +import { print } from 'graphql' +import { afterEach, describe, expect, it } from 'vitest' +import { + PAGE_SIZE, + SEARCH_CATALOG_MORE_QUERY, + SEARCH_CATALOG_QUERY, +} from '@/lib/searchOperations' +import { deferred, failure, type Answer } from '@/tests/apollo' +import { exampleElementRows } from '@/tests/elementRows' +import { + searchAnswer, + pagedServer, + renderPage, + field, + wait, + serverWithRowsPaged, +} from '@/tests/elementsPage' + +afterEach(cleanup) + +describe('ElementsPage: Load more elements', () => { + const more = () => + screen.getByRole('button', { + name: /Load more|Loading more/, + }) + const tableRows = () => document.querySelectorAll('tbody tr') + + it('fetches the next page with the cursor of the last row and adds it below, and the count follows', async () => { + const { calls } = renderPage(pagedServer(4)) + await screen.findByText('Showing 4 of 7') + expect(tableRows()).toHaveLength(4) + expect(more().disabled).toBe(false) + + fireEvent.click(more()) + + expect(await screen.findByText('Showing 7 of 7')).toBeTruthy() + expect(tableRows()).toHaveLength(7) + // The next page comes in the order of the server, after the first one. + const codes = [...tableRows()].map( + (row) => row.querySelector('td')?.textContent, + ) + expect(codes).toEqual(exampleElementRows.map((row) => row.code)) + expect(calls).toHaveLength(2) + expect(calls[1]).toMatchObject({ + operationName: 'SearchCatalogMore', + variables: { + search: null, + first: PAGE_SIZE, + after: exampleElementRows[3].id, + }, + }) + }) + + it('does not ask for the count again with the next page', () => { + expect(print(SEARCH_CATALOG_QUERY)).toContain('totalCount') + expect(print(SEARCH_CATALOG_MORE_QUERY)).not.toContain('totalCount') + }) + + it('is disabled and says so while the next page is on its way, so a double click fetches it once', async () => { + const next = deferred() + const { calls } = renderPage((operation) => + operation.variables['after'] ? next.promise : pagedServer(4)(operation), + ) + await screen.findByText('Showing 4 of 7') + + fireEvent.click(more()) + await waitFor(() => expect(more().disabled).toBe(true)) + expect(more().textContent).toBe('Loading more…') + expect(more().getAttribute('aria-busy')).toBe('true') + fireEvent.click(more()) + + next.resolve(searchAnswer(exampleElementRows.slice(4), 7)) + expect(await screen.findByText('Showing 7 of 7')).toBeTruthy() + expect(calls).toHaveLength(2) + }) + + it('is disabled when everything is shown (N of N), and the count is what describes it', async () => { + renderPage(pagedServer(10)) + const count = await screen.findByText('Showing 7 of 7') + + expect(more().disabled).toBe(true) + expect(more().getAttribute('aria-describedby')).toBe(count.id) + expect(count.id).not.toBe('') + }) + + it('becomes disabled when the last page has come', async () => { + renderPage(pagedServer(4)) + await screen.findByText('Showing 4 of 7') + + fireEvent.click(more()) + + await screen.findByText('Showing 7 of 7') + expect(more().disabled).toBe(true) + expect(more().textContent).toBe('Load more elements') + }) + + it('shows the error, keeps what is shown, and lets the user press the button again', async () => { + let attempt = 0 + const { calls } = renderPage((operation) => { + if (operation.variables['after'] && ++attempt === 1) { + return failure('INTERNAL_SERVER_ERROR', 'Something broke') + } + return pagedServer(4)(operation) + }) + await screen.findByText('Showing 4 of 7') + + fireEvent.click(more()) + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toBe( + 'Something went wrong on our side. Try again. Press the button to try again.', + ) + expect(tableRows()).toHaveLength(4) + expect(more().disabled).toBe(false) + + fireEvent.click(more()) + + expect(await screen.findByText('Showing 7 of 7')).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(calls).toHaveLength(3) + }) + + it('starts again with the first page when the search changes, and the error of the old search goes', async () => { + const { calls } = renderPage((operation) => + operation.variables['after'] && !operation.variables['search'] + ? failure('INTERNAL_SERVER_ERROR', 'Something broke') + : serverWithRowsPaged(operation), + ) + await screen.findByText('Showing 4 of 7') + fireEvent.click(more()) + await screen.findByRole('alert') + + fireEvent.change(field(), { target: { value: 'yurchenko' } }) + fireEvent.submit(screen.getByRole('search')) + + expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + const last = calls.at(-1) + expect(last?.operationName).toBe('SearchCatalog') + expect(last?.variables).toMatchObject({ search: 'yurchenko' }) + expect(last?.variables['after']).toBeUndefined() + }) +}) diff --git a/src/tests/pages/ElementsPage.results.test.tsx b/src/tests/pages/ElementsPage.results.test.tsx new file mode 100644 index 0000000..c173774 --- /dev/null +++ b/src/tests/pages/ElementsPage.results.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { PAGE_SIZE } from '@/lib/searchOperations' +import { deferred, failure, type Answer } from '@/tests/apollo' +import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows' +import { + searchAnswer, + serverWithRows, + renderPage, + field, + wait, +} from '@/tests/elementsPage' + +afterEach(cleanup) + +describe('ElementsPage: the results from the API', () => { + it('asks for the first page, with no search text, and shows what the server says', async () => { + const { calls } = renderPage(serverWithRows) + + expect( + await screen.findByText( + `Showing ${exampleElementRows.length} of ${exampleTotalCount}`, + ), + ).toBeTruthy() + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + operationName: 'SearchCatalog', + variables: { search: null, first: PAGE_SIZE }, + }) + // Each row is in the table and in a card. + expect(screen.getAllByText(exampleElementRows[1].description)).toHaveLength( + 2, + ) + expect(screen.getAllByText('Yurchenko').length).toBeGreaterThan(0) + }) + + it('says 1000+ and not the number when the server counts more than 1000 (it stops at 1001)', async () => { + renderPage(() => searchAnswer(exampleElementRows, 1001, true)) + + expect( + await screen.findByText(`Showing ${exampleElementRows.length} of 1000+`), + ).toBeTruthy() + expect(screen.queryByText(/1001/)).toBeNull() + }) + + it('says the number as it is up to 1000', async () => { + renderPage(() => searchAnswer(exampleElementRows, 1000, true)) + + expect( + await screen.findByText(`Showing ${exampleElementRows.length} of 1000`), + ).toBeTruthy() + }) + + it('shows the fields of the API in each row: the group number of a vault, the value, the drawing and the variants', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + + const vault = exampleElementRows.find((row) => row.isVault)! + const withVariants = exampleElementRows.find((row) => row.hasVariants)! + // The group of a vault is its number, and not the long title of the group. + expect( + screen.getAllByText(`Group ${vault.elementGroupNumber}`).length, + ).toBeGreaterThan(0) + expect(screen.queryByText(vault.elementGroupName)).toBeNull() + // The drawing is what the API says (imageUrl). + const images = screen.getAllByRole('img') + expect(images.map((image) => image.src)).toContain(vault.imageUrl) + // "More variants" only where the API lists variants (a card and a row). + expect(withVariants.hasVariants).toBe(true) + expect(screen.getAllByText('More variants')).toHaveLength(2) + }) + + it('says that the elements are loading, and then shows them', async () => { + const answer = deferred() + renderPage(() => answer.promise) + + expect( + (await screen.findByText('Loading elements…')).getAttribute('role'), + ).toBe('status') + expect( + screen.getByRole('region', { name: 'Results' }).getAttribute('aria-busy'), + ).toBe('true') + + answer.resolve(searchAnswer(exampleElementRows)) + expect(await screen.findByText(/Showing/)).toBeTruthy() + expect(screen.queryByText('Loading elements…')).toBeNull() + }) + + it('says so when nothing is found, in a status a screen reader reads, with what was searched for', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + + fireEvent.change(field(), { target: { value: 'zzz' } }) + const empty = await screen.findByText('No elements match “zzz”.', {}, wait) + + expect(empty.getAttribute('role')).toBe('status') + expect(screen.queryByText(/Showing/)).toBeNull() + }) + + it('shows the error with "Try again" when the server fails, and loads again when the user tries again', async () => { + let attempt = 0 + const { calls } = renderPage(() => + ++attempt === 1 + ? failure('INTERNAL_SERVER_ERROR', 'Something broke') + : searchAnswer(exampleElementRows), + ) + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toBe( + 'Something went wrong on our side. Try again.', + ) + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + + expect(await screen.findByText(/Showing/)).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(calls).toHaveLength(2) + }) + + it('shows what the server says when the search text is not allowed, without "Try again"', async () => { + renderPage(() => failure('BAD_USER_INPUT', 'Use at most 8 words.')) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'Use at most 8 words.', + ) + expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull() + }) +}) diff --git a/src/tests/pages/ElementsPage.search.test.tsx b/src/tests/pages/ElementsPage.search.test.tsx new file mode 100644 index 0000000..8bfa3b5 --- /dev/null +++ b/src/tests/pages/ElementsPage.search.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { PAGE_SIZE } from '@/lib/searchOperations' +import { deferred, type Answer } from '@/tests/apollo' +import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows' +import { + searchAnswer, + serverWithRows, + renderPage, + field, + address, + wait, +} from '@/tests/elementsPage' + +afterEach(cleanup) + +describe('ElementsPage: the search', () => { + it('sends the text when the user has stopped typing, and not for each letter', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + + for (const text of ['y', 'yu', 'yur', 'yurchenko']) { + fireEvent.change(field(), { target: { value: text } }) + } + + await waitFor(() => expect(calls).toHaveLength(2), wait) + expect(calls[1].variables).toMatchObject({ + search: 'yurchenko', + first: PAGE_SIZE, + }) + expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy() + }) + + it('puts the search in the address, so a result can be shared, and takes it out when the field is empty', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + expect(address()).toBe('/elements') + + fireEvent.change(field(), { target: { value: ' Yurchenko ' } }) + await waitFor( + () => expect(address()).toBe('/elements?search=Yurchenko'), + wait, + ) + + fireEvent.change(field(), { target: { value: '' } }) + await waitFor(() => expect(address()).toBe('/elements'), wait) + expect( + await screen.findByText( + `Showing ${exampleElementRows.length} of ${exampleTotalCount}`, + {}, + wait, + ), + ).toBeTruthy() + }) + + it('starts with the search of the address, in the field and in the request', async () => { + const { calls } = renderPage(serverWithRows, '/elements?search=amanar') + + expect(field().value).toBe('amanar') + expect(await screen.findByText('Showing 1 of 1')).toBeTruthy() + expect(calls[0].variables).toMatchObject({ search: 'amanar' }) + }) + + it('searches at once when the user presses Enter, and does not wait', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + + fireEvent.change(field(), { target: { value: 'biles' } }) + fireEvent.submit(screen.getByRole('search')) + + expect(address()).toBe('/elements?search=biles') + await waitFor(() => expect(calls).toHaveLength(2)) + expect(calls[1].variables).toMatchObject({ search: 'biles' }) + }) + + it('keeps the old results while a new search is on its way', async () => { + const second = deferred() + renderPage(({ variables }) => + variables['search'] ? second.promise : searchAnswer(exampleElementRows), + ) + await screen.findByText(/Showing/) + + fireEvent.change(field(), { target: { value: 'biles' } }) + fireEvent.submit(screen.getByRole('search')) + + // The new answer has not come, and the old results are still there. + await waitFor(() => expect(address()).toBe('/elements?search=biles')) + expect( + screen.getByText( + `Showing ${exampleElementRows.length} of ${exampleElementRows.length}`, + ), + ).toBeTruthy() + expect(screen.queryByText('Loading elements…')).toBeNull() + + second.resolve(searchAnswer([exampleElementRows[5]])) + expect(await screen.findByText('Showing 1 of 1')).toBeTruthy() + }) + + it('follows the address when the user goes back or forward: the field and the results change', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements', + '/elements?search=amanar', + ) + await screen.findByText(/Showing/) + expect(field().value).toBe('') + + fireEvent.click( + screen.getByRole('button', { name: 'go to /elements?search=amanar' }), + ) + + await waitFor(() => expect(field().value).toBe('amanar')) + expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy() + expect(calls.at(-1)?.variables).toMatchObject({ search: 'amanar' }) + }) +}) diff --git a/src/tests/pages/ElementsPage.sort.test.tsx b/src/tests/pages/ElementsPage.sort.test.tsx new file mode 100644 index 0000000..e47fc4a --- /dev/null +++ b/src/tests/pages/ElementsPage.sort.test.tsx @@ -0,0 +1,216 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { + serverWithRows, + pagedServer, + renderPage, + field, + address, + wait, + openMenu, + chooseSort, + sortButton, +} from '@/tests/elementsPage' + +afterEach(cleanup) + +describe('ElementsPage: the sort', () => { + it('sends no sort when it is the default (code, ascending), and keeps the address clean', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + + expect(calls[0].variables).not.toHaveProperty('sort') + expect(address()).toBe('/elements') + }) + + it('has one list of choices that each say the outcome, and the chosen one is checked', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu(/Sort by/) + + const checked = (name: string) => + screen.getByRole('menuitemradio', { name }).getAttribute('aria-checked') + const names = screen + .getAllByRole('menuitemradio') + .map((item) => item.textContent) + expect(names).toEqual([ + 'Code: low to high', + 'Code: high to low', + 'Difficulty: low to high', + 'Difficulty: high to low', + 'Name: A to Z', + 'Name: Z to A', + ]) + expect(checked('Code: low to high')).toBe('true') + expect(checked('Code: high to low')).toBe('false') + expect(checked('Name: A to Z')).toBe('false') + }) + + it('sorts by name: sends it, puts it in the address and says it on the button', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu(/Sort by/) + chooseSort('Name: A to Z') + + await waitFor(() => expect(address()).toBe('/elements?sort=name')) + await waitFor(() => + expect(calls.at(-1)!.variables).toMatchObject({ + sort: { field: 'NAME', direction: 'ASC' }, + }), + ) + expect(sortButton(/Sort by Name: A to Z/)).toBeTruthy() + }) + + it('sorts descending, and goes back to a clean address with the default again', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu(/Sort by/) + chooseSort('Code: high to low') + + await waitFor(() => expect(address()).toBe('/elements?dir=desc')) + await waitFor(() => + expect(calls.at(-1)!.variables).toMatchObject({ + sort: { field: 'CODE', direction: 'DESC' }, + }), + ) + + await openMenu(/Sort by/) + chooseSort('Code: low to high') + await waitFor(() => expect(address()).toBe('/elements')) + }) + + it('starts from the sort in the address, and keeps the search when the sort changes', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?search=amanar&sort=name&dir=desc', + ) + await screen.findByText(/Showing/) + + expect(calls[0].variables).toMatchObject({ + search: 'amanar', + sort: { field: 'NAME', direction: 'DESC' }, + }) + expect(field().value).toBe('amanar') + expect(sortButton(/Sort by Name: Z to A/)).toBeTruthy() + + await openMenu(/Sort by/) + chooseSort('Name: A to Z') + await waitFor(() => + expect(address()).toBe('/elements?search=amanar&sort=name'), + ) + }) + + it('keeps the sort in the address when the search changes', async () => { + renderPage(serverWithRows, '/elements?sort=name') + await screen.findByText(/Showing/) + + fireEvent.change(field(), { target: { value: 'biles' } }) + await waitFor( + () => expect(address()).toBe('/elements?sort=name&search=biles'), + wait, + ) + }) + + it('starts a new sort from the first page, and loads more with the same sort', async () => { + const { calls } = renderPage(pagedServer(4)) + await screen.findByText(/Showing 4 of/) + + fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) + await screen.findByText(/Showing 7 of/) + expect(calls.at(-1)).toMatchObject({ + operationName: 'SearchCatalogMore', + variables: { after: expect.any(String) }, + }) + + await openMenu(/Sort by/) + chooseSort('Name: A to Z') + await screen.findByText(/Showing 4 of/) + const last = calls.at(-1)! + expect(last.operationName).toBe('SearchCatalog') + expect(last.variables).not.toHaveProperty('after') + expect(last.variables).toMatchObject({ sort: { field: 'NAME' } }) + + fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) + await screen.findByText(/Showing 7 of/) + expect(calls.at(-1)!.variables).toMatchObject({ + sort: { field: 'NAME', direction: 'ASC' }, + after: expect.any(String), + }) + }) + + it('cannot sort by difficulty without an apparatus, and says why', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu(/Sort by/) + + const reason = screen.getByText( + 'Choose an apparatus to sort by difficulty.', + ) + for (const name of ['Difficulty: low to high', 'Difficulty: high to low']) { + const difficulty = screen.getByRole('menuitemradio', { name }) + expect(difficulty.getAttribute('aria-disabled')).toBe('true') + expect(difficulty.getAttribute('aria-describedby')).toBe(reason.id) + } + // The other choices are open. + expect( + screen + .getByRole('menuitemradio', { name: 'Name: A to Z' }) + .getAttribute('aria-disabled'), + ).not.toBe('true') + + fireEvent.click( + screen.getByRole('menuitemradio', { name: 'Difficulty: low to high' }), + ) + expect(address()).toBe('/elements') + expect(calls).toHaveLength(1) + }) + + it('does not send difficulty without an apparatus, even if the address says so (BAD_USER_INPUT)', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?sort=difficulty&dir=desc', + ) + await screen.findByText(/Showing/) + + // The whole default sort, and not "Code: high to low" from the direction of the address. + expect(calls[0].variables).not.toHaveProperty('sort') + expect(sortButton(/Sort by Code: low to high/)).toBeTruthy() + }) + + it('can sort by difficulty with an apparatus, and sends the apparatus with it', async () => { + const { calls } = renderPage(serverWithRows, '/elements?apparatus=VT') + await screen.findByText(/Showing/) + await openMenu(/Sort by/) + + const difficulty = screen.getByRole('menuitemradio', { + name: 'Difficulty: low to high', + }) + expect(difficulty.getAttribute('aria-disabled')).not.toBe('true') + expect(screen.queryByText(/Choose an apparatus/)).toBeNull() + chooseSort('Difficulty: low to high') + + await waitFor(() => + expect(address()).toBe('/elements?apparatus=VT&sort=difficulty'), + ) + await waitFor(() => + expect(calls.at(-1)!.variables).toMatchObject({ + filter: { apparatusId: 'VT' }, + sort: { field: 'DIFFICULTY', direction: 'ASC' }, + }), + ) + expect(sortButton(/Sort by Difficulty: low to high/)).toBeTruthy() + }) + + it('ignores a sort in the address that it does not know', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?sort=banana&dir=sideways', + ) + await screen.findByText(/Showing/) + + expect(calls[0].variables).not.toHaveProperty('sort') + expect(sortButton()).toBeTruthy() + }) +}) diff --git a/src/tests/pages/ElementsPage.test.tsx b/src/tests/pages/ElementsPage.test.tsx index c75b6df..80f7881 100644 --- a/src/tests/pages/ElementsPage.test.tsx +++ b/src/tests/pages/ElementsPage.test.tsx @@ -1,205 +1,17 @@ // @vitest-environment jsdom -// The page runs on the real Apollo Client, with a stub for `fetch` that answers like -// the server (see src/tests/apollo.ts and "Search, filter, sort and pagination" in -// docs/API.md). -import { ApolloProvider } from '@apollo/client/react' -import { - cleanup, - fireEvent, - render, - screen, - waitFor, - within, -} from '@testing-library/react' -import { print } from 'graphql' -import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom' +import { cleanup, screen, within } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import type { ElementListRow } from '@/lib/elementList' -import { - MAX_SEARCH_LENGTH, - PAGE_SIZE, - SEARCH_CATALOG_MORE_QUERY, - SEARCH_CATALOG_QUERY, -} from '@/lib/searchOperations' +import { MAX_SEARCH_LENGTH } from '@/lib/searchOperations' import { - createTestClient, - deferred, - failure, - ok, - type Answer, - type Handler, - type Operation, -} from '@/tests/apollo' -import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows' -import ElementsPage from '@/pages/ElementsPage' + serverWithRows, + pagedServer, + renderPage, + field, +} from '@/tests/elementsPage' afterEach(cleanup) -/** What the server says for one row (an Element has `value`, a Vault has `dValue`). */ -const nodeOf = (row: ElementListRow) => ({ - __typename: row.isVault ? 'Vault' : 'Element', - id: row.id, - code: row.code, - name: row.name, - description: row.description, - imageUrl: row.imageUrl, - elementGroup: { - __typename: 'ElementGroup', - name: row.elementGroupName, - number: row.elementGroupNumber, - apparatus: { __typename: 'Apparatus', name: row.apparatusName }, - }, - ...(row.isVault ? { dValue: row.value } : { value: row.value }), - variants: row.hasVariants - ? [{ __typename: 'Element', id: `${row.id}-x` }] - : [], -}) - -/** What the server says to `SearchCatalog`. */ -const searchAnswer = ( - rows: ElementListRow[], - totalCount = rows.length, - hasNextPage = false, -): Answer => - ok({ - searchCatalog: { - __typename: 'CatalogConnection', - totalCount, - pageInfo: { - __typename: 'PageInfo', - hasNextPage, - endCursor: rows.length ? rows[rows.length - 1].id : null, - }, - edges: rows.map((row) => ({ - __typename: 'CatalogEdge', - cursor: row.id, - node: nodeOf(row), - })), - }, - }) - -/** The server has seven rows, and finds only those with the search text in them. */ -const serverWithRows: Handler = ({ variables }) => { - const text = String(variables['search'] ?? '').toLowerCase() - const found = exampleElementRows.filter((row) => - `${row.code} ${row.name ?? ''} ${row.description}` - .toLowerCase() - .includes(text), - ) - return searchAnswer(found, text ? found.length : exampleTotalCount) -} - -/** - * A server that has the seven rows in pages of `size`: the first page, and then the - * page after the row that `after` is the cursor of (the id of a row). - */ -const pagedServer = - (size: number): Handler => - ({ variables }) => { - const after = variables['after'] - const start = after - ? exampleElementRows.findIndex((row) => row.id === after) + 1 - : 0 - const page = exampleElementRows.slice(start, start + size) - return searchAnswer( - page, - exampleElementRows.length, - start + size < exampleElementRows.length, - ) - } - -/** Shows the address, so a test can see that the search is in it. */ -function Address() { - const location = useLocation() - return ( - {location.pathname + location.search} - ) -} - -/** A button that goes to another address, like the back button does. */ -function GoTo({ to }: { to: string }) { - const navigate = useNavigate() - return ( - - ) -} - -/** What the server says to `CatalogStructure`: the apparatus with their groups. */ -const structure = [ - { - __typename: 'Apparatus', - id: 'VT', - name: 'Vault', - difficultyValues: [1.6, 2, 2.4, 3.2], - groups: [ - { - __typename: 'ElementGroup', - id: 'VT-1', - number: 1, - name: 'Long vault title', - }, - { - __typename: 'ElementGroup', - id: 'VT-2', - number: 2, - name: 'Another long title', - }, - ], - }, - { - __typename: 'Apparatus', - id: 'UB', - name: 'Uneven bars', - difficultyValues: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1], - groups: [ - { __typename: 'ElementGroup', id: 'UB-1', number: 1, name: 'Mounts' }, - { __typename: 'ElementGroup', id: 'UB-2', number: 2, name: 'Dismounts' }, - ], - }, -] - -/** - * Starts the page. `calls` are the searches that were sent: the structure of the - * filters is answered here and is counted in `structureCalls`, so each test can look at - * the searches alone. - */ -function renderPage( - handler: Handler, - address = '/elements', - extra?: string, - // What the server says to the structure, by the number of the call (the first is 0). - structureAnswer: (call: number) => Answer = () => - ok({ apparatus: structure }), -) { - const calls: Operation[] = [] - const structureCalls: Operation[] = [] - const { client } = createTestClient((operation) => { - if (operation.operationName === 'CatalogStructure') { - structureCalls.push(operation) - return structureAnswer(structureCalls.length - 1) - } - calls.push(operation) - return handler(operation) - }) - render( - - - -
- {extra && } - - , - ) - return { calls, structureCalls } -} - -const field = () => screen.getByRole('searchbox') -const address = () => screen.getByTestId('address').textContent -const wait = { timeout: 3000 } - describe('ElementsPage: the page', () => { it('has a title that says what the page is (WCAG 2.4.2)', async () => { renderPage(serverWithRows) @@ -270,1106 +82,3 @@ describe('ElementsPage: the page', () => { } }) }) - -/** Opens a menu like a click does: the pointer goes down and up on the button. */ -async function openMenu(name: string | RegExp) { - const button = screen.getByRole('button', { name }) - fireEvent.pointerDown(button, { button: 0, pointerType: 'mouse' }) - fireEvent.mouseDown(button, { button: 0 }) - fireEvent.pointerUp(button, { button: 0, pointerType: 'mouse' }) - fireEvent.mouseUp(button, { button: 0 }) - fireEvent.click(button, { button: 0 }) - return screen.findByRole('menu') -} - -const choose = (role: 'menuitemradio' | 'menuitemcheckbox', name: string) => - fireEvent.click(screen.getByRole(role, { name })) - -const lastFilter = (calls: Operation[]) => calls.at(-1)!.variables['filter'] - -describe('ElementsPage: the filters', () => { - it('sends no filter when none is chosen, and keeps the address clean', async () => { - const { calls } = renderPage(serverWithRows) - await screen.findByText(/Showing/) - - expect(calls[0].variables['filter']).toBeNull() - expect(address()).toBe('/elements') - expect(screen.queryByRole('button', { name: 'Clear filters' })).toBeNull() - }) - - it('asks for the structure of the filters once, however many filters change', async () => { - const { structureCalls } = renderPage(serverWithRows) - await screen.findByText(/Showing/) - - await openMenu('Apparatus') - choose('menuitemradio', 'Uneven bars') - await waitFor(() => expect(address()).toContain('apparatus=UB')) - await screen.findByText(/Showing/) - - expect(structureCalls).toHaveLength(1) - }) - - it('filters by apparatus: sends it, puts it in the address, and says it on the button', async () => { - const { calls } = renderPage(serverWithRows) - await screen.findByText(/Showing/) - await openMenu('Apparatus') - choose('menuitemradio', 'Uneven bars') - - await waitFor(() => expect(address()).toBe('/elements?apparatus=UB')) - // One choice is made, so the menu closes. - await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()) - await waitFor(() => - expect(lastFilter(calls)).toEqual({ apparatusId: 'UB' }), - ) - expect( - screen.getByRole('button', { name: 'Apparatus: Uneven bars' }), - ).toBeTruthy() - }) - - it('has the apparatus as radio items, and the chosen one is checked', async () => { - renderPage(serverWithRows, '/elements?apparatus=UB') - await screen.findByText(/Showing/) - await openMenu(/Apparatus/) - - const checked = (name: string) => - screen.getByRole('menuitemradio', { name }).getAttribute('aria-checked') - expect(checked('Uneven bars')).toBe('true') - expect(checked('Vault')).toBe('false') - expect(checked('All apparatus')).toBe('false') - }) - - it('disables the group filter before an apparatus is chosen, still reachable by the keyboard, and says why', async () => { - renderPage(serverWithRows) - await screen.findByText(/Showing/) - - const button = screen.getByRole('button', { name: 'Element group' }) - const reason = 'Choose an apparatus to choose a group.' - // aria-disabled and not disabled: the keyboard can reach it, so the reason can be found. - expect(button.getAttribute('aria-disabled')).toBe('true') - expect(button.hasAttribute('disabled')).toBe(false) - expect(button.getAttribute('tabindex')).not.toBe('-1') - // A screen reader says the reason with the button. - expect(button.getAttribute('aria-describedby')).toBeTruthy() - expect( - document.getElementById(button.getAttribute('aria-describedby')!) - ?.textContent, - ).toBe(reason) - - // It opens no menu and changes nothing when it is pressed, but the reason is shown: a - // tooltip does not open on a tap, so a touch user would not see it otherwise. - expect(screen.getAllByText(reason)).toHaveLength(1) - fireEvent.click(button) - expect(screen.queryByRole('menu')).toBeNull() - expect(address()).toBe('/elements') - await waitFor(() => expect(screen.getAllByText(reason)).toHaveLength(2)) - - // The tooltip (hover and focus) repeats the reason for sighted users. Base UI opens it - // from real pointer and focus events, which jsdom does not give, so it is checked in a browser. - }) - - it('enables the group filter when an apparatus is chosen', async () => { - renderPage(serverWithRows) - await screen.findByText(/Showing/) - await openMenu('Apparatus') - choose('menuitemradio', 'Uneven bars') - - await waitFor(() => - expect( - screen - .getByRole('button', { name: 'Element group' }) - .getAttribute('aria-disabled'), - ).toBeNull(), - ) - await openMenu('Element group') - expect(screen.getByRole('menuitemradio', { name: 'Mounts' })).toBeTruthy() - }) - - it('filters by group of the chosen apparatus, and shows a vault group as Group N', async () => { - const { calls } = renderPage(serverWithRows, '/elements?apparatus=UB') - await screen.findByText(/Showing/) - await openMenu('Element group') - expect(screen.getByRole('menuitemradio', { name: 'Mounts' })).toBeTruthy() - expect(screen.queryByRole('menuitemradio', { name: 'Group 1' })).toBeNull() - choose('menuitemradio', 'Dismounts') - - await waitFor(() => - expect(address()).toBe('/elements?apparatus=UB&group=UB-2'), - ) - await waitFor(() => - expect(lastFilter(calls)).toEqual({ - apparatusId: 'UB', - elementGroupId: 'UB-2', - }), - ) - cleanup() - - renderPage(serverWithRows, '/elements?apparatus=VT') - await screen.findByText(/Showing/) - await openMenu('Element group') - expect(screen.getByRole('menuitemradio', { name: 'Group 1' })).toBeTruthy() - expect(screen.queryByText('Long vault title')).toBeNull() - }) - - it('clears the group when the apparatus changes', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?apparatus=UB&group=UB-2', - ) - await screen.findByText(/Showing/) - await openMenu(/Apparatus/) - choose('menuitemradio', 'Vault') - - await waitFor(() => expect(address()).toBe('/elements?apparatus=VT')) - await waitFor(() => - expect(lastFilter(calls)).toEqual({ apparatusId: 'VT' }), - ) - }) - - it('filters by one or more difficulty letters, sends the values, and keeps the menu open', async () => { - const { calls } = renderPage(serverWithRows, '/elements?apparatus=UB') - await screen.findByText(/Showing/) - await openMenu('Difficulty') - choose('menuitemcheckbox', 'B') - choose('menuitemcheckbox', 'A') - - expect(screen.getByRole('menu')).toBeTruthy() - await waitFor(() => - expect(address()).toBe('/elements?apparatus=UB&difficulty=A%2CB'), - ) - await waitFor(() => - expect(lastFilter(calls)).toEqual({ - apparatusId: 'UB', - difficultyValues: [0.1, 0.2], - }), - ) - expect( - screen - .getByRole('menuitemcheckbox', { name: 'A' }) - .getAttribute('aria-checked'), - ).toBe('true') - expect( - screen.getByRole('button', { name: 'Difficulty: A, B' }), - ).toBeTruthy() - - choose('menuitemcheckbox', 'A') - await waitFor(() => - expect(address()).toBe('/elements?apparatus=UB&difficulty=B'), - ) - }) - - it('filters vault by bands of its D-score, in ascending order, sent as the values inside', async () => { - const { calls } = renderPage(serverWithRows, '/elements?apparatus=VT') - await screen.findByText(/Showing/) - const button = screen.getByRole('button', { name: 'Difficulty' }) - expect(button.getAttribute('aria-disabled')).toBeNull() - await openMenu('Difficulty') - - // The bands that have a D-value (1.6, 2, 2.4, 3.2), and not 23 single values. - expect( - screen.getAllByRole('menuitemcheckbox').map((item) => item.textContent), - ).toEqual(['1.0–1.9', '2.0–2.9', '3.0–3.9']) - choose('menuitemcheckbox', '3.0–3.9') - choose('menuitemcheckbox', '2.0–2.9') - - await waitFor(() => - expect(address()).toBe('/elements?apparatus=VT&difficulty=2%2C3'), - ) - // Each band is the D-values that exist inside it, so the API needs no ranges. - await waitFor(() => - expect(lastFilter(calls)).toEqual({ - apparatusId: 'VT', - difficultyValues: [2, 2.4, 3.2], - }), - ) - expect( - screen.getByRole('button', { name: 'Difficulty: 2.0–2.9, 3.0–3.9' }), - ).toBeTruthy() - }) - - it('starts from the bands in the address, and leaves out letters, values and bands that do not exist', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?apparatus=VT&difficulty=3,A,9,2.4', - ) - await screen.findByText(/Showing/) - expect(calls[0].variables['filter']).toEqual({ - apparatusId: 'VT', - difficultyValues: [3.2], - }) - }) - - it('drops the chosen difficulty when the apparatus changes between vault and the others, but not between two others', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?apparatus=UB&difficulty=A', - ) - await screen.findByText(/Showing/) - await openMenu(/Apparatus/) - choose('menuitemradio', 'Vault') - await waitFor(() => expect(address()).toBe('/elements?apparatus=VT')) - await waitFor(() => - expect(lastFilter(calls)).toEqual({ apparatusId: 'VT' }), - ) - - await openMenu('Difficulty') - choose('menuitemcheckbox', '2.0–2.9') - await waitFor(() => expect(address()).toContain('difficulty=2')) - await openMenu(/Apparatus/) - choose('menuitemradio', 'Uneven bars') - await waitFor(() => expect(address()).toBe('/elements?apparatus=UB')) - }) - - it('can filter by difficulty without an apparatus', async () => { - const { calls } = renderPage(serverWithRows) - await screen.findByText(/Showing/) - await openMenu('Difficulty') - choose('menuitemcheckbox', 'C') - - await waitFor(() => - expect(lastFilter(calls)).toEqual({ difficultyValues: [0.3] }), - ) - }) - - it('starts from the filters in the address, together with the search', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?search=amanar&apparatus=UB&group=UB-1&difficulty=D,E', - ) - await screen.findByText(/Showing/) - - expect(calls).toHaveLength(1) - expect(calls[0].variables).toMatchObject({ - search: 'amanar', - filter: { - apparatusId: 'UB', - elementGroupId: 'UB-1', - difficultyValues: [0.4, 0.5], - }, - }) - expect( - screen.getByRole('button', { name: 'Apparatus: Uneven bars' }), - ).toBeTruthy() - expect( - screen.getByRole('button', { name: 'Element group: Mounts' }), - ).toBeTruthy() - expect( - screen.getByRole('button', { name: 'Difficulty: D, E' }), - ).toBeTruthy() - expect(field().value).toBe('amanar') - }) - - it('keeps the filters when the search changes, and the search when a filter changes', async () => { - renderPage(serverWithRows, '/elements?apparatus=UB') - await screen.findByText(/Showing/) - - fireEvent.change(field(), { target: { value: 'biles' } }) - await waitFor( - () => expect(address()).toBe('/elements?apparatus=UB&search=biles'), - wait, - ) - - await openMenu('Difficulty') - choose('menuitemcheckbox', 'A') - await waitFor(() => - expect(address()).toBe( - '/elements?apparatus=UB&search=biles&difficulty=A', - ), - ) - }) - - it('leaves out what the address says that does not exist or does not fit, so the API gets no BAD_USER_INPUT', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?apparatus=XX&group=VT-1&difficulty=A,Z', - ) - await screen.findByText(/Showing/) - // The group is of vault, so it gives the apparatus, and vault has no difficulty. - expect(calls[0].variables['filter']).toEqual({ - apparatusId: 'VT', - elementGroupId: 'VT-1', - }) - cleanup() - - const other = renderPage( - serverWithRows, - '/elements?apparatus=UB&group=VT-1&difficulty=a,zz', - ) - await screen.findByText(/Showing/) - expect(other.calls[0].variables['filter']).toEqual({ - apparatusId: 'UB', - difficultyValues: [0.1], - }) - }) - - it('does not search before the structure is known when the address has a filter, so a wrong value is never sent', async () => { - const wait1 = deferred() - const calls: Operation[] = [] - const { client } = createTestClient((operation) => { - calls.push(operation) - return operation.operationName === 'CatalogStructure' - ? wait1.promise - : searchAnswer(exampleElementRows) - }) - render( - - - - - , - ) - - expect(await screen.findByText('Loading elements…')).toBeTruthy() - expect(calls.map((c) => c.operationName)).toEqual(['CatalogStructure']) - - wait1.resolve(ok({ apparatus: structure })) - await screen.findByText(/Showing/) - expect(calls.map((c) => c.operationName)).toEqual([ - 'CatalogStructure', - 'SearchCatalog', - ]) - }) - - it('says what went wrong and does not search when the structure fails and the address has filters', async () => { - const { calls, structureCalls } = renderPage( - serverWithRows, - '/elements?apparatus=UB&difficulty=A', - undefined, - (call) => - call === 0 - ? failure('INTERNAL_SERVER_ERROR', 'Something broke') - : ok({ apparatus: structure }), - ) - - // The error, and no results under an address that says UB: the filters are not dropped. - expect( - await screen.findByText('Something went wrong on our side. Try again.'), - ).toBeTruthy() - expect(calls).toHaveLength(0) - expect(screen.queryByText(/Showing/)).toBeNull() - expect(address()).toBe('/elements?apparatus=UB&difficulty=A') - - // Trying again loads the structure, and then searches with the filters of the address. - fireEvent.click(screen.getByRole('button', { name: 'Try again' })) - await screen.findByText(/Showing/) - expect(structureCalls).toHaveLength(2) - expect(calls).toHaveLength(1) - expect(calls[0].variables['filter']).toEqual({ - apparatusId: 'UB', - difficultyValues: [0.1], - }) - expect(screen.queryByRole('alert')).toBeNull() - }) - - it('still searches when the structure fails and the address has no filter', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?search=amanar', - undefined, - () => failure('INTERNAL_SERVER_ERROR', 'Something broke'), - ) - await screen.findByText(/Showing/) - expect(calls).toHaveLength(1) - expect(calls[0].variables).toMatchObject({ search: 'amanar', filter: null }) - }) - - it('clears every filter at once, and keeps the search', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?search=amanar&apparatus=UB&group=UB-1&difficulty=D', - ) - await screen.findByText(/Showing/) - fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })) - - await waitFor(() => expect(address()).toBe('/elements?search=amanar')) - await waitFor(() => expect(lastFilter(calls)).toBeNull()) - expect(screen.queryByRole('button', { name: 'Clear filters' })).toBeNull() - // The button is gone, so the focus goes to the first filter and not to the page. - expect(document.activeElement).toBe( - screen.getByRole('button', { name: 'Apparatus' }), - ) - }) - - it('follows the address when the user goes to another one, like the back button does', async () => { - renderPage(serverWithRows, '/elements', '/elements?apparatus=UB') - await screen.findByText(/Showing/) - fireEvent.click( - screen.getByRole('button', { name: 'go to /elements?apparatus=UB' }), - ) - - await waitFor(() => expect(address()).toBe('/elements?apparatus=UB')) - await screen.findByRole('button', { name: 'Clear filters' }) - }) - - it('starts a changed filter from the first page, and loads more with the same filter', async () => { - const { calls } = renderPage(pagedServer(4)) - await screen.findByText(/Showing 4 of/) - fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) - await screen.findByText(/Showing 7 of/) - - await openMenu('Apparatus') - choose('menuitemradio', 'Uneven bars') - await screen.findByText(/Showing 4 of/) - const last = calls.at(-1)! - expect(last.operationName).toBe('SearchCatalog') - expect(last.variables).not.toHaveProperty('after') - expect(last.variables['filter']).toEqual({ apparatusId: 'UB' }) - - fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) - await screen.findByText(/Showing 7 of/) - expect(calls.at(-1)!.variables).toMatchObject({ - filter: { apparatusId: 'UB' }, - after: expect.any(String), - }) - }) - - it('says that nothing matches the filters', async () => { - renderPage(() => searchAnswer([]), '/elements?apparatus=UB') - expect( - await screen.findByText('No elements match these filters.'), - ).toBeTruthy() - }) -}) - -const chooseSort = (name: string) => - fireEvent.click(screen.getByRole('menuitemradio', { name })) - -const sortButton = (name = /Sort by Code: low to high/) => - screen.getByRole('button', { name }) - -describe('ElementsPage: the sort', () => { - it('sends no sort when it is the default (code, ascending), and keeps the address clean', async () => { - const { calls } = renderPage(serverWithRows) - await screen.findByText(/Showing/) - - expect(calls[0].variables).not.toHaveProperty('sort') - expect(address()).toBe('/elements') - }) - - it('has one list of choices that each say the outcome, and the chosen one is checked', async () => { - renderPage(serverWithRows) - await screen.findByText(/Showing/) - await openMenu(/Sort by/) - - const checked = (name: string) => - screen.getByRole('menuitemradio', { name }).getAttribute('aria-checked') - const names = screen - .getAllByRole('menuitemradio') - .map((item) => item.textContent) - expect(names).toEqual([ - 'Code: low to high', - 'Code: high to low', - 'Difficulty: low to high', - 'Difficulty: high to low', - 'Name: A to Z', - 'Name: Z to A', - ]) - expect(checked('Code: low to high')).toBe('true') - expect(checked('Code: high to low')).toBe('false') - expect(checked('Name: A to Z')).toBe('false') - }) - - it('sorts by name: sends it, puts it in the address and says it on the button', async () => { - const { calls } = renderPage(serverWithRows) - await screen.findByText(/Showing/) - await openMenu(/Sort by/) - chooseSort('Name: A to Z') - - await waitFor(() => expect(address()).toBe('/elements?sort=name')) - await waitFor(() => - expect(calls.at(-1)!.variables).toMatchObject({ - sort: { field: 'NAME', direction: 'ASC' }, - }), - ) - expect(sortButton(/Sort by Name: A to Z/)).toBeTruthy() - }) - - it('sorts descending, and goes back to a clean address with the default again', async () => { - const { calls } = renderPage(serverWithRows) - await screen.findByText(/Showing/) - await openMenu(/Sort by/) - chooseSort('Code: high to low') - - await waitFor(() => expect(address()).toBe('/elements?dir=desc')) - await waitFor(() => - expect(calls.at(-1)!.variables).toMatchObject({ - sort: { field: 'CODE', direction: 'DESC' }, - }), - ) - - await openMenu(/Sort by/) - chooseSort('Code: low to high') - await waitFor(() => expect(address()).toBe('/elements')) - }) - - it('starts from the sort in the address, and keeps the search when the sort changes', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?search=amanar&sort=name&dir=desc', - ) - await screen.findByText(/Showing/) - - expect(calls[0].variables).toMatchObject({ - search: 'amanar', - sort: { field: 'NAME', direction: 'DESC' }, - }) - expect(field().value).toBe('amanar') - expect(sortButton(/Sort by Name: Z to A/)).toBeTruthy() - - await openMenu(/Sort by/) - chooseSort('Name: A to Z') - await waitFor(() => - expect(address()).toBe('/elements?search=amanar&sort=name'), - ) - }) - - it('keeps the sort in the address when the search changes', async () => { - renderPage(serverWithRows, '/elements?sort=name') - await screen.findByText(/Showing/) - - fireEvent.change(field(), { target: { value: 'biles' } }) - await waitFor( - () => expect(address()).toBe('/elements?sort=name&search=biles'), - wait, - ) - }) - - it('starts a new sort from the first page, and loads more with the same sort', async () => { - const { calls } = renderPage(pagedServer(4)) - await screen.findByText(/Showing 4 of/) - - fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) - await screen.findByText(/Showing 7 of/) - expect(calls.at(-1)).toMatchObject({ - operationName: 'SearchCatalogMore', - variables: { after: expect.any(String) }, - }) - - await openMenu(/Sort by/) - chooseSort('Name: A to Z') - await screen.findByText(/Showing 4 of/) - const last = calls.at(-1)! - expect(last.operationName).toBe('SearchCatalog') - expect(last.variables).not.toHaveProperty('after') - expect(last.variables).toMatchObject({ sort: { field: 'NAME' } }) - - fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) - await screen.findByText(/Showing 7 of/) - expect(calls.at(-1)!.variables).toMatchObject({ - sort: { field: 'NAME', direction: 'ASC' }, - after: expect.any(String), - }) - }) - - it('cannot sort by difficulty without an apparatus, and says why', async () => { - const { calls } = renderPage(serverWithRows) - await screen.findByText(/Showing/) - await openMenu(/Sort by/) - - const reason = screen.getByText( - 'Choose an apparatus to sort by difficulty.', - ) - for (const name of ['Difficulty: low to high', 'Difficulty: high to low']) { - const difficulty = screen.getByRole('menuitemradio', { name }) - expect(difficulty.getAttribute('aria-disabled')).toBe('true') - expect(difficulty.getAttribute('aria-describedby')).toBe(reason.id) - } - // The other choices are open. - expect( - screen - .getByRole('menuitemradio', { name: 'Name: A to Z' }) - .getAttribute('aria-disabled'), - ).not.toBe('true') - - fireEvent.click( - screen.getByRole('menuitemradio', { name: 'Difficulty: low to high' }), - ) - expect(address()).toBe('/elements') - expect(calls).toHaveLength(1) - }) - - it('does not send difficulty without an apparatus, even if the address says so (BAD_USER_INPUT)', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?sort=difficulty&dir=desc', - ) - await screen.findByText(/Showing/) - - // The whole default sort, and not "Code: high to low" from the direction of the address. - expect(calls[0].variables).not.toHaveProperty('sort') - expect(sortButton(/Sort by Code: low to high/)).toBeTruthy() - }) - - it('can sort by difficulty with an apparatus, and sends the apparatus with it', async () => { - const { calls } = renderPage(serverWithRows, '/elements?apparatus=VT') - await screen.findByText(/Showing/) - await openMenu(/Sort by/) - - const difficulty = screen.getByRole('menuitemradio', { - name: 'Difficulty: low to high', - }) - expect(difficulty.getAttribute('aria-disabled')).not.toBe('true') - expect(screen.queryByText(/Choose an apparatus/)).toBeNull() - chooseSort('Difficulty: low to high') - - await waitFor(() => - expect(address()).toBe('/elements?apparatus=VT&sort=difficulty'), - ) - await waitFor(() => - expect(calls.at(-1)!.variables).toMatchObject({ - filter: { apparatusId: 'VT' }, - sort: { field: 'DIFFICULTY', direction: 'ASC' }, - }), - ) - expect(sortButton(/Sort by Difficulty: low to high/)).toBeTruthy() - }) - - it('ignores a sort in the address that it does not know', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?sort=banana&dir=sideways', - ) - await screen.findByText(/Showing/) - - expect(calls[0].variables).not.toHaveProperty('sort') - expect(sortButton()).toBeTruthy() - }) -}) - -describe('ElementsPage: the search, the filters and the sort together', () => { - it('sends all three in one request, and keeps all three in the address', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?search=salto&apparatus=UB&group=UB-2&difficulty=A,B&sort=difficulty&dir=desc', - ) - await screen.findByText(/Showing/) - - expect(calls).toHaveLength(1) - expect(calls[0].variables).toMatchObject({ - search: 'salto', - filter: { - apparatusId: 'UB', - elementGroupId: 'UB-2', - difficultyValues: [0.1, 0.2], - }, - sort: { field: 'DIFFICULTY', direction: 'DESC' }, - }) - expect(sortButton(/Sort by Difficulty: high to low/)).toBeTruthy() - - // A new filter keeps the search and the sort. - await openMenu(/^Difficulty: A, B/) - choose('menuitemcheckbox', 'C') - await waitFor(() => - expect(address()).toBe( - '/elements?search=salto&apparatus=UB&group=UB-2&difficulty=A%2CB%2CC&sort=difficulty&dir=desc', - ), - ) - }) - - it('can sort vault by difficulty, because the apparatus filter is set', async () => { - const { calls } = renderPage(serverWithRows) - await screen.findByText(/Showing/) - await openMenu('Apparatus') - choose('menuitemradio', 'Vault') - await waitFor(() => expect(address()).toBe('/elements?apparatus=VT')) - - await openMenu(/Sort by/) - chooseSort('Difficulty: low to high') - await waitFor(() => - expect(calls.at(-1)!.variables).toMatchObject({ - filter: { apparatusId: 'VT' }, - sort: { field: 'DIFFICULTY', direction: 'ASC' }, - }), - ) - }) - - it('falls back to sorting by code when the apparatus is cleared, so the API gets no BAD_USER_INPUT', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements?apparatus=UB&sort=difficulty', - ) - await screen.findByText(/Showing/) - expect(sortButton(/Sort by Difficulty/)).toBeTruthy() - - fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })) - await waitFor(() => - expect(sortButton(/Sort by Code: low to high/)).toBeTruthy(), - ) - await waitFor(() => - expect(calls.at(-1)!.variables).not.toHaveProperty('sort'), - ) - expect(calls.at(-1)!.variables['filter']).toBeNull() - }) - - it('offers Difficulty in the sort menu once an apparatus is chosen in the filter', async () => { - renderPage(serverWithRows) - await screen.findByText(/Showing/) - await openMenu(/Sort by/) - expect( - screen - .getByRole('menuitemradio', { name: 'Difficulty: low to high' }) - .getAttribute('aria-disabled'), - ).toBe('true') - fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) - await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()) - - await openMenu('Apparatus') - choose('menuitemradio', 'Uneven bars') - await waitFor(() => expect(address()).toBe('/elements?apparatus=UB')) - await openMenu(/Sort by/) - expect( - screen - .getByRole('menuitemradio', { name: 'Difficulty: low to high' }) - .getAttribute('aria-disabled'), - ).not.toBe('true') - }) -}) - -describe('ElementsPage: the results from the API', () => { - it('asks for the first page, with no search text, and shows what the server says', async () => { - const { calls } = renderPage(serverWithRows) - - expect( - await screen.findByText( - `Showing ${exampleElementRows.length} of ${exampleTotalCount}`, - ), - ).toBeTruthy() - expect(calls).toHaveLength(1) - expect(calls[0]).toMatchObject({ - operationName: 'SearchCatalog', - variables: { search: null, first: PAGE_SIZE }, - }) - // Each row is in the table and in a card. - expect(screen.getAllByText(exampleElementRows[1].description)).toHaveLength( - 2, - ) - expect(screen.getAllByText('Yurchenko').length).toBeGreaterThan(0) - }) - - it('says 1000+ and not the number when the server counts more than 1000 (it stops at 1001)', async () => { - renderPage(() => searchAnswer(exampleElementRows, 1001, true)) - - expect( - await screen.findByText(`Showing ${exampleElementRows.length} of 1000+`), - ).toBeTruthy() - expect(screen.queryByText(/1001/)).toBeNull() - }) - - it('says the number as it is up to 1000', async () => { - renderPage(() => searchAnswer(exampleElementRows, 1000, true)) - - expect( - await screen.findByText(`Showing ${exampleElementRows.length} of 1000`), - ).toBeTruthy() - }) - - it('shows the fields of the API in each row: the group number of a vault, the value, the drawing and the variants', async () => { - renderPage(serverWithRows) - await screen.findByText(/Showing/) - - const vault = exampleElementRows.find((row) => row.isVault)! - const withVariants = exampleElementRows.find((row) => row.hasVariants)! - // The group of a vault is its number, and not the long title of the group. - expect( - screen.getAllByText(`Group ${vault.elementGroupNumber}`).length, - ).toBeGreaterThan(0) - expect(screen.queryByText(vault.elementGroupName)).toBeNull() - // The drawing is what the API says (imageUrl). - const images = screen.getAllByRole('img') - expect(images.map((image) => image.src)).toContain(vault.imageUrl) - // "More variants" only where the API lists variants (a card and a row). - expect(withVariants.hasVariants).toBe(true) - expect(screen.getAllByText('More variants')).toHaveLength(2) - }) - - it('says that the elements are loading, and then shows them', async () => { - const answer = deferred() - renderPage(() => answer.promise) - - expect( - (await screen.findByText('Loading elements…')).getAttribute('role'), - ).toBe('status') - expect( - screen.getByRole('region', { name: 'Results' }).getAttribute('aria-busy'), - ).toBe('true') - - answer.resolve(searchAnswer(exampleElementRows)) - expect(await screen.findByText(/Showing/)).toBeTruthy() - expect(screen.queryByText('Loading elements…')).toBeNull() - }) - - it('says so when nothing is found, in a status a screen reader reads, with what was searched for', async () => { - renderPage(serverWithRows) - await screen.findByText(/Showing/) - - fireEvent.change(field(), { target: { value: 'zzz' } }) - const empty = await screen.findByText('No elements match “zzz”.', {}, wait) - - expect(empty.getAttribute('role')).toBe('status') - expect(screen.queryByText(/Showing/)).toBeNull() - }) - - it('shows the error with "Try again" when the server fails, and loads again when the user tries again', async () => { - let attempt = 0 - const { calls } = renderPage(() => - ++attempt === 1 - ? failure('INTERNAL_SERVER_ERROR', 'Something broke') - : searchAnswer(exampleElementRows), - ) - - const alert = await screen.findByRole('alert') - expect(alert.textContent).toBe( - 'Something went wrong on our side. Try again.', - ) - fireEvent.click(screen.getByRole('button', { name: 'Try again' })) - - expect(await screen.findByText(/Showing/)).toBeTruthy() - expect(screen.queryByRole('alert')).toBeNull() - expect(calls).toHaveLength(2) - }) - - it('shows what the server says when the search text is not allowed, without "Try again"', async () => { - renderPage(() => failure('BAD_USER_INPUT', 'Use at most 8 words.')) - - expect((await screen.findByRole('alert')).textContent).toBe( - 'Use at most 8 words.', - ) - expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull() - }) -}) - -describe('ElementsPage: the search', () => { - it('sends the text when the user has stopped typing, and not for each letter', async () => { - const { calls } = renderPage(serverWithRows) - await screen.findByText(/Showing/) - - for (const text of ['y', 'yu', 'yur', 'yurchenko']) { - fireEvent.change(field(), { target: { value: text } }) - } - - await waitFor(() => expect(calls).toHaveLength(2), wait) - expect(calls[1].variables).toMatchObject({ - search: 'yurchenko', - first: PAGE_SIZE, - }) - expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy() - }) - - it('puts the search in the address, so a result can be shared, and takes it out when the field is empty', async () => { - renderPage(serverWithRows) - await screen.findByText(/Showing/) - expect(address()).toBe('/elements') - - fireEvent.change(field(), { target: { value: ' Yurchenko ' } }) - await waitFor( - () => expect(address()).toBe('/elements?search=Yurchenko'), - wait, - ) - - fireEvent.change(field(), { target: { value: '' } }) - await waitFor(() => expect(address()).toBe('/elements'), wait) - expect( - await screen.findByText( - `Showing ${exampleElementRows.length} of ${exampleTotalCount}`, - {}, - wait, - ), - ).toBeTruthy() - }) - - it('starts with the search of the address, in the field and in the request', async () => { - const { calls } = renderPage(serverWithRows, '/elements?search=amanar') - - expect(field().value).toBe('amanar') - expect(await screen.findByText('Showing 1 of 1')).toBeTruthy() - expect(calls[0].variables).toMatchObject({ search: 'amanar' }) - }) - - it('searches at once when the user presses Enter, and does not wait', async () => { - const { calls } = renderPage(serverWithRows) - await screen.findByText(/Showing/) - - fireEvent.change(field(), { target: { value: 'biles' } }) - fireEvent.submit(screen.getByRole('search')) - - expect(address()).toBe('/elements?search=biles') - await waitFor(() => expect(calls).toHaveLength(2)) - expect(calls[1].variables).toMatchObject({ search: 'biles' }) - }) - - it('keeps the old results while a new search is on its way', async () => { - const second = deferred() - renderPage(({ variables }) => - variables['search'] ? second.promise : searchAnswer(exampleElementRows), - ) - await screen.findByText(/Showing/) - - fireEvent.change(field(), { target: { value: 'biles' } }) - fireEvent.submit(screen.getByRole('search')) - - // The new answer has not come, and the old results are still there. - await waitFor(() => expect(address()).toBe('/elements?search=biles')) - expect( - screen.getByText( - `Showing ${exampleElementRows.length} of ${exampleElementRows.length}`, - ), - ).toBeTruthy() - expect(screen.queryByText('Loading elements…')).toBeNull() - - second.resolve(searchAnswer([exampleElementRows[5]])) - expect(await screen.findByText('Showing 1 of 1')).toBeTruthy() - }) - - it('follows the address when the user goes back or forward: the field and the results change', async () => { - const { calls } = renderPage( - serverWithRows, - '/elements', - '/elements?search=amanar', - ) - await screen.findByText(/Showing/) - expect(field().value).toBe('') - - fireEvent.click( - screen.getByRole('button', { name: 'go to /elements?search=amanar' }), - ) - - await waitFor(() => expect(field().value).toBe('amanar')) - expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy() - expect(calls.at(-1)?.variables).toMatchObject({ search: 'amanar' }) - }) -}) - -describe('ElementsPage: Load more elements', () => { - const more = () => - screen.getByRole('button', { - name: /Load more|Loading more/, - }) - const tableRows = () => document.querySelectorAll('tbody tr') - - it('fetches the next page with the cursor of the last row and adds it below, and the count follows', async () => { - const { calls } = renderPage(pagedServer(4)) - await screen.findByText('Showing 4 of 7') - expect(tableRows()).toHaveLength(4) - expect(more().disabled).toBe(false) - - fireEvent.click(more()) - - expect(await screen.findByText('Showing 7 of 7')).toBeTruthy() - expect(tableRows()).toHaveLength(7) - // The next page comes in the order of the server, after the first one. - const codes = [...tableRows()].map( - (row) => row.querySelector('td')?.textContent, - ) - expect(codes).toEqual(exampleElementRows.map((row) => row.code)) - expect(calls).toHaveLength(2) - expect(calls[1]).toMatchObject({ - operationName: 'SearchCatalogMore', - variables: { - search: null, - first: PAGE_SIZE, - after: exampleElementRows[3].id, - }, - }) - }) - - it('does not ask for the count again with the next page', () => { - expect(print(SEARCH_CATALOG_QUERY)).toContain('totalCount') - expect(print(SEARCH_CATALOG_MORE_QUERY)).not.toContain('totalCount') - }) - - it('is disabled and says so while the next page is on its way, so a double click fetches it once', async () => { - const next = deferred() - const { calls } = renderPage((operation) => - operation.variables['after'] ? next.promise : pagedServer(4)(operation), - ) - await screen.findByText('Showing 4 of 7') - - fireEvent.click(more()) - await waitFor(() => expect(more().disabled).toBe(true)) - expect(more().textContent).toBe('Loading more…') - expect(more().getAttribute('aria-busy')).toBe('true') - fireEvent.click(more()) - - next.resolve(searchAnswer(exampleElementRows.slice(4), 7)) - expect(await screen.findByText('Showing 7 of 7')).toBeTruthy() - expect(calls).toHaveLength(2) - }) - - it('is disabled when everything is shown (N of N), and the count is what describes it', async () => { - renderPage(pagedServer(10)) - const count = await screen.findByText('Showing 7 of 7') - - expect(more().disabled).toBe(true) - expect(more().getAttribute('aria-describedby')).toBe(count.id) - expect(count.id).not.toBe('') - }) - - it('becomes disabled when the last page has come', async () => { - renderPage(pagedServer(4)) - await screen.findByText('Showing 4 of 7') - - fireEvent.click(more()) - - await screen.findByText('Showing 7 of 7') - expect(more().disabled).toBe(true) - expect(more().textContent).toBe('Load more elements') - }) - - it('shows the error, keeps what is shown, and lets the user press the button again', async () => { - let attempt = 0 - const { calls } = renderPage((operation) => { - if (operation.variables['after'] && ++attempt === 1) { - return failure('INTERNAL_SERVER_ERROR', 'Something broke') - } - return pagedServer(4)(operation) - }) - await screen.findByText('Showing 4 of 7') - - fireEvent.click(more()) - - const alert = await screen.findByRole('alert') - expect(alert.textContent).toBe( - 'Something went wrong on our side. Try again. Press the button to try again.', - ) - expect(tableRows()).toHaveLength(4) - expect(more().disabled).toBe(false) - - fireEvent.click(more()) - - expect(await screen.findByText('Showing 7 of 7')).toBeTruthy() - expect(screen.queryByRole('alert')).toBeNull() - expect(calls).toHaveLength(3) - }) - - it('starts again with the first page when the search changes, and the error of the old search goes', async () => { - const { calls } = renderPage((operation) => - operation.variables['after'] && !operation.variables['search'] - ? failure('INTERNAL_SERVER_ERROR', 'Something broke') - : serverWithRowsPaged(operation), - ) - await screen.findByText('Showing 4 of 7') - fireEvent.click(more()) - await screen.findByRole('alert') - - fireEvent.change(field(), { target: { value: 'yurchenko' } }) - fireEvent.submit(screen.getByRole('search')) - - expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy() - expect(screen.queryByRole('alert')).toBeNull() - const last = calls.at(-1) - expect(last?.operationName).toBe('SearchCatalog') - expect(last?.variables).toMatchObject({ search: 'yurchenko' }) - expect(last?.variables['after']).toBeUndefined() - }) -}) - -/** A server that pages the seven rows, and also finds rows by their text. */ -function serverWithRowsPaged(operation: Parameters[0]) { - const text = String(operation.variables['search'] ?? '').toLowerCase() - if (!text) return pagedServer(4)(operation) - const found = exampleElementRows.filter((row) => - `${row.code} ${row.name ?? ''} ${row.description}` - .toLowerCase() - .includes(text), - ) - return searchAnswer(found, found.length) -}