Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -254,7 +254,7 @@ The Elements page (`/elements`, `src/pages/ElementsPage.tsx`) is where a coach s

### Testing

- `src/tests/pages/ElementsPage.test.tsx` runs the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it.
- `src/tests/pages/ElementsPage*.test.tsx` (one file for each topic, with the shared set-up in `src/tests/elementsPage.tsx`) run the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it.
- The backend tests (`backend/src/catalog/search.test.ts`, `backend/src/server/server.test.ts`) cover the vault filter, the range of the values and `Apparatus.difficultyValues`, and need the seeded database like the rest.
- Not everything can be tested in jsdom (for example the tooltip, which Base UI opens from real pointer events). Those parts were checked in a real browser against the real backend.

Expand Down
220 changes: 220 additions & 0 deletions src/tests/elementsPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<output data-testid="address">{location.pathname + location.search}</output>
)
}

/** A button that goes to another address, like the back button does. */
export function GoTo({ to }: { to: string }) {
const navigate = useNavigate()
return (
<button type="button" onClick={() => navigate(to)}>
go to {to}
</button>
)
}

/** 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(
<ApolloProvider client={client}>
<MemoryRouter initialEntries={[address]}>
<ElementsPage />
<Address />
{extra && <GoTo to={extra} />}
</MemoryRouter>
</ApolloProvider>,
)
return { calls, structureCalls }
}

export const field = () => screen.getByRole<HTMLInputElement>('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<Handler>[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)
}
104 changes: 104 additions & 0 deletions src/tests/pages/ElementsPage.combined.test.tsx
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading