diff --git a/.beans/nuzlocke-tracker-ee9s--unit-tests-for-frontend-utilities-and-hooks.md b/.beans/nuzlocke-tracker-ee9s--unit-tests-for-frontend-utilities-and-hooks.md
index ecc0e92..5ea9dcc 100644
--- a/.beans/nuzlocke-tracker-ee9s--unit-tests-for-frontend-utilities-and-hooks.md
+++ b/.beans/nuzlocke-tracker-ee9s--unit-tests-for-frontend-utilities-and-hooks.md
@@ -1,31 +1,27 @@
---
# nuzlocke-tracker-ee9s
title: Unit tests for frontend utilities and hooks
-status: draft
+status: completed
type: task
+priority: normal
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
---
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
-- [ ] Test `utils/formatEvolution.ts` — evolution chain formatting logic
-- [ ] Test `utils/download.ts` — file download utility
-- [ ] Test `hooks/useRuns.ts` — run CRUD hook with mocked API
-- [ ] Test `hooks/useGames.ts` — game fetching hook
-- [ ] Test `hooks/useEncounters.ts` — encounter operations hook
-- [ ] Test `hooks/usePokemon.ts` — pokemon data hook
-- [ ] Test `hooks/useGenlockes.ts` — genlocke operations hook
-- [ ] Test `hooks/useBosses.ts` — boss operations hook
-- [ ] Test `hooks/useStats.ts` — stats fetching hook
-- [ ] Test `hooks/useAdmin.ts` — admin operations hook
-
-## 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
\ No newline at end of file
+- [x] Test `utils/formatEvolution.ts` — done in smoke test
+- [x] Test `utils/download.ts` — blob URL creation, filename, cleanup
+- [x] Test `hooks/useGames.ts` — query hooks and disabled state
+- [x] Test `hooks/useRuns.ts` — query hooks + mutations with cache invalidation
+- [x] Test `hooks/useEncounters.ts` — mutations and conditional queries
+- [x] Test `hooks/usePokemon.ts` — conditional queries
+- [x] Test `hooks/useGenlockes.ts` — queries and mutations
+- [x] Test `hooks/useBosses.ts` — queries and mutations
+- [x] Test `hooks/useStats.ts` — single query hook
+- [x] Test `hooks/useAdmin.ts` — representative subset (usePokemonList, useCreateGame, useDeleteGame)
diff --git a/.beans/nuzlocke-tracker-yzpb--implement-unit-integration-tests.md b/.beans/nuzlocke-tracker-yzpb--implement-unit-integration-tests.md
index 65674e8..d361de7 100644
--- a/.beans/nuzlocke-tracker-yzpb--implement-unit-integration-tests.md
+++ b/.beans/nuzlocke-tracker-yzpb--implement-unit-integration-tests.md
@@ -24,5 +24,5 @@ Add comprehensive unit and integration test coverage to both the backend (FastAP
- [ ] Backend schemas and services have unit test coverage
- [x] Backend API endpoints have integration test coverage
- [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
\ No newline at end of file
diff --git a/frontend/src/hooks/useAdmin.test.tsx b/frontend/src/hooks/useAdmin.test.tsx
new file mode 100644
index 0000000..6c7c8e7
--- /dev/null
+++ b/frontend/src/hooks/useAdmin.test.tsx
@@ -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 }) => (
+ {children}
+ )
+ 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')
+ })
+})
diff --git a/frontend/src/hooks/useBosses.test.tsx b/frontend/src/hooks/useBosses.test.tsx
new file mode 100644
index 0000000..a77be18
--- /dev/null
+++ b/frontend/src/hooks/useBosses.test.tsx
@@ -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 }) => (
+ {children}
+ )
+ 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'] })
+ })
+})
diff --git a/frontend/src/hooks/useEncounters.test.tsx b/frontend/src/hooks/useEncounters.test.tsx
new file mode 100644
index 0000000..91bd9ff
--- /dev/null
+++ b/frontend/src/hooks/useEncounters.test.tsx
@@ -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 }) => (
+ {children}
+ )
+ 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] })
+ })
+})
diff --git a/frontend/src/hooks/useGames.test.tsx b/frontend/src/hooks/useGames.test.tsx
new file mode 100644
index 0000000..c5b15a9
--- /dev/null
+++ b/frontend/src/hooks/useGames.test.tsx
@@ -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 }) => (
+ {children}
+ )
+ 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))
+ })
+})
diff --git a/frontend/src/hooks/useGenlockes.test.tsx b/frontend/src/hooks/useGenlockes.test.tsx
new file mode 100644
index 0000000..bcd2e73
--- /dev/null
+++ b/frontend/src/hooks/useGenlockes.test.tsx
@@ -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 }) => (
+ {children}
+ )
+ 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'] })
+ })
+})
diff --git a/frontend/src/hooks/usePokemon.test.tsx b/frontend/src/hooks/usePokemon.test.tsx
new file mode 100644
index 0000000..e564cbb
--- /dev/null
+++ b/frontend/src/hooks/usePokemon.test.tsx
@@ -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 }) => (
+ {children}
+ )
+ 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))
+ })
+})
diff --git a/frontend/src/hooks/useRuns.test.tsx b/frontend/src/hooks/useRuns.test.tsx
new file mode 100644
index 0000000..2613945
--- /dev/null
+++ b/frontend/src/hooks/useRuns.test.tsx
@@ -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 }) => (
+ {children}
+ )
+ 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')
+ })
+})
diff --git a/frontend/src/hooks/useStats.test.tsx b/frontend/src/hooks/useStats.test.tsx
new file mode 100644
index 0000000..d7964b9
--- /dev/null
+++ b/frontend/src/hooks/useStats.test.tsx
@@ -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 }) => (
+ {children}
+ )
+ 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)
+ })
+})
diff --git a/frontend/src/utils/download.test.ts b/frontend/src/utils/download.test.ts
new file mode 100644
index 0000000..a8ecc74
--- /dev/null
+++ b/frontend/src/utils/download.test.ts
@@ -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)
+ })
+})