diff --git a/README.md b/README.md index 631877e..e139d7f 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 | @@ -350,7 +350,7 @@ The search is done in PostgreSQL through Prisma (no raw SQL), with words matched The frontend and the backend keep their tests in different places, on purpose: -- **Frontend: `src/tests/`.** The tests are in the same folders as the code they test (`src/tests/pages/ElementsPage.test.tsx` tests `src/pages/ElementsPage.tsx`), and they import it with the `@/` alias. The folder also has what the tests share: `setupTests.ts`, the Apollo test client (`apollo.ts`) and the example rows (`elementRows.ts`). Keeping the tests apart from the code means that `src/pages/` and `src/components/` only show what the app is made of. +- **Frontend: `src/tests/`.** The tests are in the same folders as the code they test (`src/tests/pages/AboutPage.test.tsx` tests `src/pages/AboutPage.tsx`), and they import it with the `@/` alias. The folder also has what the tests share: `setupTests.ts`, the Apollo test client (`apollo.ts`) and the example rows (`elementRows.ts`). Keeping the tests apart from the code means that `src/pages/` and `src/components/` only show what the app is made of. - **Backend: next to the code, in `backend/src/`.** `backend/src/catalog/search.test.ts` tests `backend/src/catalog/search.ts`. The backend is its own package, with Node's module system and relative imports (`'../errors.js'`), and its folders are small and grouped by job (`auth/`, `catalog/`, `seed/`, `server/`), so a test next to its file is easy to find. This is the usual layout in a Node package. The tests that use the database need a migrated and seeded database (see "Database (local setup)"). - **End-to-end: `e2e/`** (see "End-to-end tests"). diff --git a/docs/ELEMENTS_PAGE.md b/docs/ELEMENTS_PAGE.md index 5373dea..b513738 100644 --- a/docs/ELEMENTS_PAGE.md +++ b/docs/ELEMENTS_PAGE.md @@ -29,7 +29,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) -}