Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 @@ -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").

Expand Down
2 changes: 1 addition & 1 deletion docs/ELEMENTS_PAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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