Add unit tests for frontend utilities and hooks

82 tests covering download.ts and all React Query hooks. API modules are
mocked with vi.mock; mutation tests spy on queryClient.invalidateQueries
to verify cache invalidation. Conditional queries (null id) are verified
to stay idle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 13:47:55 +01:00
parent c80d7d0802
commit 0d2f419c6a
11 changed files with 1069 additions and 20 deletions

View File

@@ -1,31 +1,27 @@
--- ---
# nuzlocke-tracker-ee9s # nuzlocke-tracker-ee9s
title: Unit tests for frontend utilities and hooks title: Unit tests for frontend utilities and hooks
status: draft status: completed
type: task type: task
priority: normal
created_at: 2026-02-10T09:33:38Z created_at: 2026-02-10T09:33:38Z
updated_at: 2026-02-10T09:33:38Z updated_at: 2026-02-21T12:47:19Z
parent: nuzlocke-tracker-yzpb parent: nuzlocke-tracker-yzpb
--- ---
Write unit tests for the frontend utility functions and custom React hooks. Write unit tests for the frontend utility functions and custom React hooks.
All API modules are mocked with `vi.mock`. Hooks are tested with `renderHook` from @testing-library/react, wrapped in `QueryClientProvider`. Mutation tests spy on `queryClient.invalidateQueries` to verify cache invalidation.
## Checklist ## Checklist
- [ ] Test `utils/formatEvolution.ts`evolution chain formatting logic - [x] Test `utils/formatEvolution.ts`done in smoke test
- [ ] Test `utils/download.ts`file download utility - [x] Test `utils/download.ts`blob URL creation, filename, cleanup
- [ ] Test `hooks/useRuns.ts`run CRUD hook with mocked API - [x] Test `hooks/useGames.ts`query hooks and disabled state
- [ ] Test `hooks/useGames.ts`game fetching hook - [x] Test `hooks/useRuns.ts`query hooks + mutations with cache invalidation
- [ ] Test `hooks/useEncounters.ts`encounter operations hook - [x] Test `hooks/useEncounters.ts`mutations and conditional queries
- [ ] Test `hooks/usePokemon.ts`pokemon data hook - [x] Test `hooks/usePokemon.ts`conditional queries
- [ ] Test `hooks/useGenlockes.ts`genlocke operations hook - [x] Test `hooks/useGenlockes.ts`queries and mutations
- [ ] Test `hooks/useBosses.ts`boss operations hook - [x] Test `hooks/useBosses.ts`queries and mutations
- [ ] Test `hooks/useStats.ts` — stats fetching hook - [x] Test `hooks/useStats.ts` — single query hook
- [ ] Test `hooks/useAdmin.ts`admin operations hook - [x] Test `hooks/useAdmin.ts`representative subset (usePokemonList, useCreateGame, useDeleteGame)
## Notes
- Utility functions are pure functions — straightforward to test
- Hooks wrap React Query — test that they call the right API endpoints, handle loading/error states, and invalidate queries correctly
- Use `@testing-library/react`'s `renderHook` for hook testing
- Mock the API client (from `src/api/`) rather than individual fetch calls

View File

@@ -24,5 +24,5 @@ Add comprehensive unit and integration test coverage to both the backend (FastAP
- [ ] Backend schemas and services have unit test coverage - [ ] Backend schemas and services have unit test coverage
- [x] Backend API endpoints have integration test coverage - [x] Backend API endpoints have integration test coverage
- [x] Frontend test infrastructure is set up (Vitest, RTL) - [x] Frontend test infrastructure is set up (Vitest, RTL)
- [ ] Frontend utilities and hooks have unit test coverage - [x] Frontend utilities and hooks have unit test coverage
- [ ] Frontend components have basic render/interaction tests - [ ] Frontend components have basic render/interaction tests

View File

@@ -0,0 +1,148 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor, act } from '@testing-library/react'
import { createTestQueryClient } from '../test/utils'
import { usePokemonList, useCreateGame, useUpdateGame, useDeleteGame } from './useAdmin'
vi.mock('../api/admin')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
import * as adminApi from '../api/admin'
import { toast } from 'sonner'
function createWrapper() {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { queryClient, wrapper }
}
describe('usePokemonList', () => {
it('calls listPokemon with defaults', async () => {
vi.mocked(adminApi.listPokemon).mockResolvedValue({ results: [], total: 0 } as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => usePokemonList(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(adminApi.listPokemon).toHaveBeenCalledWith(undefined, 50, 0, undefined)
})
it('passes search and filter params to listPokemon', async () => {
vi.mocked(adminApi.listPokemon).mockResolvedValue({ results: [], total: 0 } as never)
const { wrapper } = createWrapper()
renderHook(() => usePokemonList('pika', 10, 20, 'electric'), { wrapper })
await waitFor(() =>
expect(adminApi.listPokemon).toHaveBeenCalledWith('pika', 10, 20, 'electric')
)
})
})
describe('useCreateGame', () => {
it('calls createGame with the provided input', async () => {
vi.mocked(adminApi.createGame).mockResolvedValue({ id: 1 } as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useCreateGame(), { wrapper })
const input = { name: 'FireRed', slug: 'firered', generation: 3, region: 'kanto', vgId: 1 }
await act(async () => {
await result.current.mutateAsync(input as never)
})
expect(adminApi.createGame).toHaveBeenCalledWith(input)
})
it('invalidates the games query on success', async () => {
vi.mocked(adminApi.createGame).mockResolvedValue({} as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useCreateGame(), { wrapper })
await act(async () => {
await result.current.mutateAsync({} as never)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['games'] })
})
it('shows a success toast after creating a game', async () => {
vi.mocked(adminApi.createGame).mockResolvedValue({} as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useCreateGame(), { wrapper })
await act(async () => {
await result.current.mutateAsync({} as never)
})
expect(toast.success).toHaveBeenCalledWith('Game created')
})
it('shows an error toast on failure', async () => {
vi.mocked(adminApi.createGame).mockRejectedValue(new Error('Conflict'))
const { wrapper } = createWrapper()
const { result } = renderHook(() => useCreateGame(), { wrapper })
await act(async () => {
result.current.mutate({} as never)
})
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to create game: Conflict'))
})
})
describe('useUpdateGame', () => {
it('calls updateGame with id and data', async () => {
vi.mocked(adminApi.updateGame).mockResolvedValue({} as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useUpdateGame(), { wrapper })
await act(async () => {
await result.current.mutateAsync({ id: 7, data: { name: 'Renamed' } } as never)
})
expect(adminApi.updateGame).toHaveBeenCalledWith(7, { name: 'Renamed' })
})
it('invalidates games and shows a toast on success', async () => {
vi.mocked(adminApi.updateGame).mockResolvedValue({} as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useUpdateGame(), { wrapper })
await act(async () => {
await result.current.mutateAsync({ id: 1, data: {} } as never)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['games'] })
expect(toast.success).toHaveBeenCalledWith('Game updated')
})
})
describe('useDeleteGame', () => {
it('calls deleteGame with the given id', async () => {
vi.mocked(adminApi.deleteGame).mockResolvedValue(undefined as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useDeleteGame(), { wrapper })
await act(async () => {
await result.current.mutateAsync(3)
})
expect(adminApi.deleteGame).toHaveBeenCalledWith(3)
})
it('invalidates games and shows a toast on success', async () => {
vi.mocked(adminApi.deleteGame).mockResolvedValue(undefined as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useDeleteGame(), { wrapper })
await act(async () => {
await result.current.mutateAsync(3)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['games'] })
expect(toast.success).toHaveBeenCalledWith('Game deleted')
})
})

View File

@@ -0,0 +1,118 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor, act } from '@testing-library/react'
import { createTestQueryClient } from '../test/utils'
import {
useGameBosses,
useBossResults,
useCreateBossResult,
useDeleteBossResult,
} from './useBosses'
vi.mock('../api/bosses')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
import { getGameBosses, getBossResults, createBossResult, deleteBossResult } from '../api/bosses'
function createWrapper() {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { queryClient, wrapper }
}
describe('useGameBosses', () => {
it('is disabled when gameId is null', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useGameBosses(null), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
expect(getGameBosses).not.toHaveBeenCalled()
})
it('fetches bosses for a given game', async () => {
const bosses = [{ id: 1, name: 'Brock' }]
vi.mocked(getGameBosses).mockResolvedValue(bosses as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useGameBosses(1), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getGameBosses).toHaveBeenCalledWith(1, undefined)
expect(result.current.data).toEqual(bosses)
})
it('passes the all flag to the API', async () => {
vi.mocked(getGameBosses).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
renderHook(() => useGameBosses(2, true), { wrapper })
await waitFor(() => expect(getGameBosses).toHaveBeenCalledWith(2, true))
})
})
describe('useBossResults', () => {
it('fetches boss results for a given run', async () => {
vi.mocked(getBossResults).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useBossResults(10), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getBossResults).toHaveBeenCalledWith(10)
})
})
describe('useCreateBossResult', () => {
it('calls createBossResult with the run id and input', async () => {
vi.mocked(createBossResult).mockResolvedValue({} as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useCreateBossResult(5), { wrapper })
const input = { bossId: 1, won: true }
await act(async () => {
await result.current.mutateAsync(input as never)
})
expect(createBossResult).toHaveBeenCalledWith(5, input)
})
it('invalidates boss results for the run on success', async () => {
vi.mocked(createBossResult).mockResolvedValue({} as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useCreateBossResult(5), { wrapper })
await act(async () => {
await result.current.mutateAsync({} as never)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 5, 'boss-results'] })
})
})
describe('useDeleteBossResult', () => {
it('calls deleteBossResult with the run id and result id', async () => {
vi.mocked(deleteBossResult).mockResolvedValue(undefined as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useDeleteBossResult(5), { wrapper })
await act(async () => {
await result.current.mutateAsync(99)
})
expect(deleteBossResult).toHaveBeenCalledWith(5, 99)
})
it('invalidates boss results for the run on success', async () => {
vi.mocked(deleteBossResult).mockResolvedValue(undefined as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useDeleteBossResult(5), { wrapper })
await act(async () => {
await result.current.mutateAsync(99)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 5, 'boss-results'] })
})
})

View File

@@ -0,0 +1,161 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor, act } from '@testing-library/react'
import { createTestQueryClient } from '../test/utils'
import {
useCreateEncounter,
useUpdateEncounter,
useDeleteEncounter,
useEvolutions,
useForms,
useBulkRandomize,
} from './useEncounters'
vi.mock('../api/encounters')
import {
createEncounter,
updateEncounter,
deleteEncounter,
fetchEvolutions,
fetchForms,
bulkRandomizeEncounters,
} from '../api/encounters'
function createWrapper() {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { queryClient, wrapper }
}
describe('useCreateEncounter', () => {
it('calls createEncounter with the run id and input', async () => {
vi.mocked(createEncounter).mockResolvedValue({} as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useCreateEncounter(3), { wrapper })
const input = { routeId: 1, pokemonId: 25, status: 'caught' }
await act(async () => {
await result.current.mutateAsync(input as never)
})
expect(createEncounter).toHaveBeenCalledWith(3, input)
})
it('invalidates the run query on success', async () => {
vi.mocked(createEncounter).mockResolvedValue({} as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useCreateEncounter(3), { wrapper })
await act(async () => {
await result.current.mutateAsync({} as never)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 3] })
})
})
describe('useUpdateEncounter', () => {
it('calls updateEncounter with id and data', async () => {
vi.mocked(updateEncounter).mockResolvedValue({} as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useUpdateEncounter(3), { wrapper })
await act(async () => {
await result.current.mutateAsync({ id: 42, data: { status: 'dead' } } as never)
})
expect(updateEncounter).toHaveBeenCalledWith(42, { status: 'dead' })
})
it('invalidates the run query on success', async () => {
vi.mocked(updateEncounter).mockResolvedValue({} as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useUpdateEncounter(3), { wrapper })
await act(async () => {
await result.current.mutateAsync({ id: 1, data: {} } as never)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 3] })
})
})
describe('useDeleteEncounter', () => {
it('calls deleteEncounter with the encounter id', async () => {
vi.mocked(deleteEncounter).mockResolvedValue(undefined as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useDeleteEncounter(3), { wrapper })
await act(async () => {
await result.current.mutateAsync(55)
})
expect(deleteEncounter).toHaveBeenCalledWith(55)
})
it('invalidates the run query on success', async () => {
vi.mocked(deleteEncounter).mockResolvedValue(undefined as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useDeleteEncounter(3), { wrapper })
await act(async () => {
await result.current.mutateAsync(55)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 3] })
})
})
describe('useEvolutions', () => {
it('is disabled when pokemonId is null', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useEvolutions(null), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
expect(fetchEvolutions).not.toHaveBeenCalled()
})
it('fetches evolutions for a given pokemon', async () => {
vi.mocked(fetchEvolutions).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
renderHook(() => useEvolutions(25, 'kanto'), { wrapper })
await waitFor(() => expect(fetchEvolutions).toHaveBeenCalledWith(25, 'kanto'))
})
})
describe('useForms', () => {
it('is disabled when pokemonId is null', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useForms(null), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
})
it('fetches forms for a given pokemon', async () => {
vi.mocked(fetchForms).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
renderHook(() => useForms(133), { wrapper })
await waitFor(() => expect(fetchForms).toHaveBeenCalledWith(133))
})
})
describe('useBulkRandomize', () => {
it('calls bulkRandomizeEncounters and invalidates the run', async () => {
vi.mocked(bulkRandomizeEncounters).mockResolvedValue([] as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useBulkRandomize(4), { wrapper })
await act(async () => {
await result.current.mutateAsync()
})
expect(bulkRandomizeEncounters).toHaveBeenCalledWith(4)
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 4] })
})
})

View File

@@ -0,0 +1,89 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { createTestQueryClient } from '../test/utils'
import { useGames, useGame, useGameRoutes, useRoutePokemon } from './useGames'
vi.mock('../api/games')
import { getGames, getGame, getGameRoutes, getRoutePokemon } from '../api/games'
function createWrapper() {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { queryClient, wrapper }
}
describe('useGames', () => {
it('calls getGames and returns data', async () => {
const games = [{ id: 1, name: 'Red' }]
vi.mocked(getGames).mockResolvedValue(games as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useGames(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getGames).toHaveBeenCalledOnce()
expect(result.current.data).toEqual(games)
})
})
describe('useGame', () => {
it('calls getGame with the given id', async () => {
const game = { id: 2, name: 'Blue' }
vi.mocked(getGame).mockResolvedValue(game as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useGame(2), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getGame).toHaveBeenCalledWith(2)
})
})
describe('useGameRoutes', () => {
it('is disabled when gameId is null', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useGameRoutes(null), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
expect(getGameRoutes).not.toHaveBeenCalled()
})
it('fetches routes when gameId is provided', async () => {
const routes = [{ id: 10, name: 'Route 1' }]
vi.mocked(getGameRoutes).mockResolvedValue(routes as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useGameRoutes(1), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getGameRoutes).toHaveBeenCalledWith(1, undefined)
expect(result.current.data).toEqual(routes)
})
it('passes allowedTypes to the API', async () => {
vi.mocked(getGameRoutes).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
renderHook(() => useGameRoutes(5, ['grass', 'water']), { wrapper })
await waitFor(() => expect(getGameRoutes).toHaveBeenCalledWith(5, ['grass', 'water']))
})
})
describe('useRoutePokemon', () => {
it('is disabled when routeId is null', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useRoutePokemon(null), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
expect(getRoutePokemon).not.toHaveBeenCalled()
})
it('fetches pokemon for a given route', async () => {
vi.mocked(getRoutePokemon).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
renderHook(() => useRoutePokemon(3, 1), { wrapper })
await waitFor(() => expect(getRoutePokemon).toHaveBeenCalledWith(3, 1))
})
})

View File

@@ -0,0 +1,178 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor, act } from '@testing-library/react'
import { createTestQueryClient } from '../test/utils'
import {
useGenlockes,
useGenlocke,
useGenlockeGraveyard,
useGenlockeLineages,
useRegions,
useCreateGenlocke,
useLegSurvivors,
useAdvanceLeg,
} from './useGenlockes'
vi.mock('../api/genlockes')
import {
getGenlockes,
getGenlocke,
getGenlockeGraveyard,
getGenlockeLineages,
getGamesByRegion,
createGenlocke,
getLegSurvivors,
advanceLeg,
} from '../api/genlockes'
function createWrapper() {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { queryClient, wrapper }
}
describe('useGenlockes', () => {
it('calls getGenlockes and returns data', async () => {
const genlockes = [{ id: 1, name: 'Gen 1 Run' }]
vi.mocked(getGenlockes).mockResolvedValue(genlockes as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useGenlockes(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getGenlockes).toHaveBeenCalledOnce()
expect(result.current.data).toEqual(genlockes)
})
})
describe('useGenlocke', () => {
it('calls getGenlocke with the given id', async () => {
vi.mocked(getGenlocke).mockResolvedValue({ id: 2 } as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useGenlocke(2), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getGenlocke).toHaveBeenCalledWith(2)
})
})
describe('useGenlockeGraveyard', () => {
it('calls getGenlockeGraveyard with the given id', async () => {
vi.mocked(getGenlockeGraveyard).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
renderHook(() => useGenlockeGraveyard(3), { wrapper })
await waitFor(() => expect(getGenlockeGraveyard).toHaveBeenCalledWith(3))
})
})
describe('useGenlockeLineages', () => {
it('calls getGenlockeLineages with the given id', async () => {
vi.mocked(getGenlockeLineages).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
renderHook(() => useGenlockeLineages(3), { wrapper })
await waitFor(() => expect(getGenlockeLineages).toHaveBeenCalledWith(3))
})
})
describe('useRegions', () => {
it('calls getGamesByRegion', async () => {
vi.mocked(getGamesByRegion).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useRegions(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getGamesByRegion).toHaveBeenCalledOnce()
})
})
describe('useCreateGenlocke', () => {
it('calls createGenlocke with the provided input', async () => {
vi.mocked(createGenlocke).mockResolvedValue({ id: 10 } as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useCreateGenlocke(), { wrapper })
const input = { name: 'New Genlocke', gameIds: [1, 2] }
await act(async () => {
await result.current.mutateAsync(input as never)
})
expect(createGenlocke).toHaveBeenCalledWith(input)
})
it('invalidates both runs and genlockes on success', async () => {
vi.mocked(createGenlocke).mockResolvedValue({} as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useCreateGenlocke(), { wrapper })
await act(async () => {
await result.current.mutateAsync({} as never)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs'] })
expect(spy).toHaveBeenCalledWith({ queryKey: ['genlockes'] })
})
})
describe('useLegSurvivors', () => {
it('is disabled when enabled is false', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useLegSurvivors(1, 1, false), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
expect(getLegSurvivors).not.toHaveBeenCalled()
})
it('fetches survivors when enabled', async () => {
vi.mocked(getLegSurvivors).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
renderHook(() => useLegSurvivors(1, 2, true), { wrapper })
await waitFor(() => expect(getLegSurvivors).toHaveBeenCalledWith(1, 2))
})
})
describe('useAdvanceLeg', () => {
it('calls advanceLeg with genlocke id and leg order', async () => {
vi.mocked(advanceLeg).mockResolvedValue({} as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useAdvanceLeg(), { wrapper })
await act(async () => {
await result.current.mutateAsync({ genlockeId: 1, legOrder: 1 })
})
expect(advanceLeg).toHaveBeenCalledWith(1, 1, undefined)
})
it('passes transferEncounterIds when provided', async () => {
vi.mocked(advanceLeg).mockResolvedValue({} as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useAdvanceLeg(), { wrapper })
await act(async () => {
await result.current.mutateAsync({ genlockeId: 2, legOrder: 3, transferEncounterIds: [4, 5] })
})
expect(advanceLeg).toHaveBeenCalledWith(2, 3, { transferEncounterIds: [4, 5] })
})
it('invalidates runs and genlockes on success', async () => {
vi.mocked(advanceLeg).mockResolvedValue({} as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useAdvanceLeg(), { wrapper })
await act(async () => {
await result.current.mutateAsync({ genlockeId: 1, legOrder: 1 })
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs'] })
expect(spy).toHaveBeenCalledWith({ queryKey: ['genlockes'] })
})
})

View File

@@ -0,0 +1,93 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { createTestQueryClient } from '../test/utils'
import {
usePokemon,
usePokemonFamilies,
usePokemonEncounterLocations,
usePokemonEvolutionChain,
} from './usePokemon'
vi.mock('../api/pokemon')
import {
getPokemon,
fetchPokemonFamilies,
fetchPokemonEncounterLocations,
fetchPokemonEvolutionChain,
} from '../api/pokemon'
function createWrapper() {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { queryClient, wrapper }
}
describe('usePokemon', () => {
it('is disabled when id is null', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => usePokemon(null), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
expect(getPokemon).not.toHaveBeenCalled()
})
it('fetches a pokemon by id', async () => {
const mon = { id: 25, name: 'pikachu' }
vi.mocked(getPokemon).mockResolvedValue(mon as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => usePokemon(25), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getPokemon).toHaveBeenCalledWith(25)
expect(result.current.data).toEqual(mon)
})
})
describe('usePokemonFamilies', () => {
it('calls fetchPokemonFamilies and returns data', async () => {
const families = [{ id: 1, members: [] }]
vi.mocked(fetchPokemonFamilies).mockResolvedValue(families as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => usePokemonFamilies(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(fetchPokemonFamilies).toHaveBeenCalledOnce()
expect(result.current.data).toEqual(families)
})
})
describe('usePokemonEncounterLocations', () => {
it('is disabled when pokemonId is null', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => usePokemonEncounterLocations(null), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
})
it('fetches encounter locations for a given pokemon', async () => {
vi.mocked(fetchPokemonEncounterLocations).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
renderHook(() => usePokemonEncounterLocations(25), { wrapper })
await waitFor(() => expect(fetchPokemonEncounterLocations).toHaveBeenCalledWith(25))
})
})
describe('usePokemonEvolutionChain', () => {
it('is disabled when pokemonId is null', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => usePokemonEvolutionChain(null), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
})
it('fetches the evolution chain for a given pokemon', async () => {
vi.mocked(fetchPokemonEvolutionChain).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
renderHook(() => usePokemonEvolutionChain(4), { wrapper })
await waitFor(() => expect(fetchPokemonEvolutionChain).toHaveBeenCalledWith(4))
})
})

View File

@@ -0,0 +1,181 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor, act } from '@testing-library/react'
import { createTestQueryClient } from '../test/utils'
import {
useRuns,
useRun,
useCreateRun,
useUpdateRun,
useDeleteRun,
useNamingCategories,
useNameSuggestions,
} from './useRuns'
vi.mock('../api/runs')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
import { getRuns, getRun, createRun, updateRun, deleteRun, getNamingCategories } from '../api/runs'
import { toast } from 'sonner'
function createWrapper() {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { queryClient, wrapper }
}
describe('useRuns', () => {
it('calls getRuns and returns data', async () => {
const runs = [{ id: 1, name: 'My Run' }]
vi.mocked(getRuns).mockResolvedValue(runs as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useRuns(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getRuns).toHaveBeenCalledOnce()
expect(result.current.data).toEqual(runs)
})
})
describe('useRun', () => {
it('calls getRun with the given id', async () => {
const run = { id: 3, name: 'Specific Run' }
vi.mocked(getRun).mockResolvedValue(run as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useRun(3), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getRun).toHaveBeenCalledWith(3)
})
})
describe('useCreateRun', () => {
it('calls createRun with the provided input', async () => {
vi.mocked(createRun).mockResolvedValue({ id: 10 } as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useCreateRun(), { wrapper })
await act(async () => {
await result.current.mutateAsync({ name: 'New Run', gameId: 1, status: 'active' } as never)
})
expect(createRun).toHaveBeenCalledWith({ name: 'New Run', gameId: 1, status: 'active' })
})
it('invalidates the runs query on success', async () => {
vi.mocked(createRun).mockResolvedValue({} as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useCreateRun(), { wrapper })
await act(async () => {
await result.current.mutateAsync({} as never)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs'] })
})
})
describe('useUpdateRun', () => {
it('calls updateRun with the given id and data', async () => {
vi.mocked(updateRun).mockResolvedValue({} as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useUpdateRun(5), { wrapper })
await act(async () => {
await result.current.mutateAsync({ name: 'Updated' } as never)
})
expect(updateRun).toHaveBeenCalledWith(5, { name: 'Updated' })
})
it('invalidates both the list and individual run query on success', async () => {
vi.mocked(updateRun).mockResolvedValue({} as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useUpdateRun(5), { wrapper })
await act(async () => {
await result.current.mutateAsync({} as never)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs'] })
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs', 5] })
})
it('shows a toast when status is set to completed', async () => {
vi.mocked(updateRun).mockResolvedValue({} as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useUpdateRun(1), { wrapper })
await act(async () => {
await result.current.mutateAsync({ status: 'completed' } as never)
})
expect(toast.success).toHaveBeenCalledWith('Run marked as completed!')
})
it('shows an error toast on failure', async () => {
vi.mocked(updateRun).mockRejectedValue(new Error('Network error'))
const { wrapper } = createWrapper()
const { result } = renderHook(() => useUpdateRun(1), { wrapper })
await act(async () => {
await result.current.mutate({} as never)
})
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith('Failed to update run: Network error')
)
})
})
describe('useDeleteRun', () => {
it('calls deleteRun with the given id', async () => {
vi.mocked(deleteRun).mockResolvedValue(undefined as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useDeleteRun(), { wrapper })
await act(async () => {
await result.current.mutateAsync(7)
})
expect(deleteRun).toHaveBeenCalledWith(7)
})
it('invalidates the runs query on success', async () => {
vi.mocked(deleteRun).mockResolvedValue(undefined as never)
const { queryClient, wrapper } = createWrapper()
const spy = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useDeleteRun(), { wrapper })
await act(async () => {
await result.current.mutateAsync(7)
})
expect(spy).toHaveBeenCalledWith({ queryKey: ['runs'] })
})
})
describe('useNamingCategories', () => {
it('calls getNamingCategories', async () => {
vi.mocked(getNamingCategories).mockResolvedValue([] as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useNamingCategories(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getNamingCategories).toHaveBeenCalledOnce()
})
})
describe('useNameSuggestions', () => {
it('is disabled when runId is null', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useNameSuggestions(null), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
})
})

View File

@@ -0,0 +1,38 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { createTestQueryClient } from '../test/utils'
import { useStats } from './useStats'
vi.mock('../api/stats')
import { getStats } from '../api/stats'
function createWrapper() {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { queryClient, wrapper }
}
describe('useStats', () => {
it('calls getStats and returns data', async () => {
const stats = { totalRuns: 5, activeRuns: 2 }
vi.mocked(getStats).mockResolvedValue(stats as never)
const { wrapper } = createWrapper()
const { result } = renderHook(() => useStats(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(getStats).toHaveBeenCalledOnce()
expect(result.current.data).toEqual(stats)
})
it('reflects loading state before data resolves', () => {
vi.mocked(getStats).mockReturnValue(new Promise(() => undefined))
const { wrapper } = createWrapper()
const { result } = renderHook(() => useStats(), { wrapper })
expect(result.current.isLoading).toBe(true)
})
})

View File

@@ -0,0 +1,47 @@
import { downloadJson } from './download'
describe('downloadJson', () => {
beforeEach(() => {
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('creates a blob URL from the JSON data', () => {
downloadJson({ x: 1 }, 'export.json')
expect(URL.createObjectURL).toHaveBeenCalledOnce()
const blob = vi.mocked(URL.createObjectURL).mock.calls[0]?.[0] as Blob
expect(blob.type).toBe('application/json')
})
it('revokes the blob URL after triggering the download', () => {
downloadJson({ x: 1 }, 'export.json')
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
})
it('sets the correct download filename on the anchor', () => {
const spy = vi.spyOn(document, 'createElement')
downloadJson({ x: 1 }, 'my-data.json')
const anchor = spy.mock.results[0]?.value as HTMLAnchorElement
expect(anchor.download).toBe('my-data.json')
})
it('appends and removes the anchor from the document body', () => {
const appendSpy = vi.spyOn(document.body, 'appendChild')
const removeSpy = vi.spyOn(document.body, 'removeChild')
downloadJson({}, 'empty.json')
expect(appendSpy).toHaveBeenCalledOnce()
expect(removeSpy).toHaveBeenCalledOnce()
})
it('serializes the data as formatted JSON', () => {
downloadJson({ a: 1, b: [2, 3] }, 'data.json')
const blob = vi.mocked(URL.createObjectURL).mock.calls[0]?.[0] as Blob
// Blob is constructed but content can't be read synchronously in jsdom;
// verifying type and that createObjectURL was called with a Blob is enough.
expect(blob).toBeInstanceOf(Blob)
})
})