Add component tests for EndRunModal, GameGrid, RulesConfiguration, Layout

33 tests covering rendering, user interactions (userEvent clicks), prop
callbacks, filter state, and conditional description text. Adds a
matchMedia stub to the vitest setup file so components importing
useTheme don't throw in jsdom. Also adds actionlint and zizmor
pre-commit hooks for GitHub Actions linting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 13:57:12 +01:00
parent 0d2f419c6a
commit 9aaa95a1c7
8 changed files with 374 additions and 20 deletions

View File

@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { EndRunModal } from './EndRunModal'
function setup(overrides: Partial<React.ComponentProps<typeof EndRunModal>> = {}) {
const props = {
onConfirm: vi.fn(),
onClose: vi.fn(),
...overrides,
}
render(<EndRunModal {...props} />)
return props
}
describe('EndRunModal', () => {
it('renders Victory, Defeat, and Cancel buttons', () => {
setup()
expect(screen.getByRole('button', { name: /victory/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /defeat/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
it('calls onConfirm with "completed" when Victory is clicked', async () => {
const { onConfirm } = setup()
await userEvent.click(screen.getByRole('button', { name: /victory/i }))
expect(onConfirm).toHaveBeenCalledWith('completed')
})
it('calls onConfirm with "failed" when Defeat is clicked', async () => {
const { onConfirm } = setup()
await userEvent.click(screen.getByRole('button', { name: /defeat/i }))
expect(onConfirm).toHaveBeenCalledWith('failed')
})
it('calls onClose when Cancel is clicked', async () => {
const { onClose } = setup()
await userEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onClose when the backdrop is clicked', async () => {
const { onClose } = setup()
const backdrop = document.querySelector('.fixed.inset-0.bg-black\\/50') as HTMLElement
await userEvent.click(backdrop)
expect(onClose).toHaveBeenCalledOnce()
})
it('disables all buttons when isPending is true', () => {
setup({ isPending: true })
expect(screen.getByRole('button', { name: /victory/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /defeat/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled()
})
it('shows default description text without a genlocke context', () => {
setup()
expect(screen.getByText('Beat the game successfully')).toBeInTheDocument()
expect(screen.getByText('All Pokemon fainted or gave up')).toBeInTheDocument()
})
it('shows genlocke-specific description for non-final legs', () => {
setup({ genlockeContext: { isFinalLeg: false, legOrder: 1, totalLegs: 3 } as never })
expect(screen.getByText('Complete this leg and continue your genlocke')).toBeInTheDocument()
expect(screen.getByText('This will end the entire genlocke')).toBeInTheDocument()
})
it('shows final-leg description on the last genlocke leg', () => {
setup({ genlockeContext: { isFinalLeg: true, legOrder: 3, totalLegs: 3 } as never })
expect(screen.getByText('Complete the final leg of your genlocke!')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,115 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { Game } from '../types'
import { GameGrid } from './GameGrid'
const RED: Game = {
id: 1,
name: 'Pokemon Red',
slug: 'red',
generation: 1,
region: 'kanto',
category: null,
boxArtUrl: null,
color: null,
releaseYear: null,
versionGroupId: 1,
}
const GOLD: Game = {
id: 2,
name: 'Pokemon Gold',
slug: 'gold',
generation: 2,
region: 'johto',
category: null,
boxArtUrl: null,
color: null,
releaseYear: null,
versionGroupId: 2,
}
const RUBY: Game = {
id: 3,
name: 'Pokemon Ruby',
slug: 'ruby',
generation: 3,
region: 'hoenn',
category: null,
boxArtUrl: null,
color: null,
releaseYear: null,
versionGroupId: 3,
}
function setup(overrides: Partial<React.ComponentProps<typeof GameGrid>> = {}) {
const props = {
games: [RED, GOLD, RUBY],
selectedId: null,
onSelect: vi.fn(),
...overrides,
}
render(<GameGrid {...props} />)
return props
}
describe('GameGrid', () => {
it('renders all game names', () => {
setup()
expect(screen.getByText('Pokemon Red')).toBeInTheDocument()
expect(screen.getByText('Pokemon Gold')).toBeInTheDocument()
expect(screen.getByText('Pokemon Ruby')).toBeInTheDocument()
})
it('renders generation filter pills for each unique generation', () => {
setup()
expect(screen.getByRole('button', { name: 'Gen 1' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Gen 2' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Gen 3' })).toBeInTheDocument()
})
it('filters games when a generation pill is clicked', async () => {
setup()
await userEvent.click(screen.getByRole('button', { name: 'Gen 1' }))
expect(screen.getByText('Pokemon Red')).toBeInTheDocument()
expect(screen.queryByText('Pokemon Gold')).not.toBeInTheDocument()
expect(screen.queryByText('Pokemon Ruby')).not.toBeInTheDocument()
})
it('restores all games when "All" generation pill is clicked', async () => {
setup()
await userEvent.click(screen.getByRole('button', { name: 'Gen 1' }))
await userEvent.click(screen.getAllByRole('button', { name: 'All' })[0]!)
expect(screen.getByText('Pokemon Red')).toBeInTheDocument()
expect(screen.getByText('Pokemon Gold')).toBeInTheDocument()
expect(screen.getByText('Pokemon Ruby')).toBeInTheDocument()
})
it('filters games when a region pill is clicked', async () => {
setup()
await userEvent.click(screen.getByRole('button', { name: 'Johto' }))
expect(screen.queryByText('Pokemon Red')).not.toBeInTheDocument()
expect(screen.getByText('Pokemon Gold')).toBeInTheDocument()
expect(screen.queryByText('Pokemon Ruby')).not.toBeInTheDocument()
})
it('calls onSelect with the game when a game card is clicked', async () => {
const { onSelect } = setup()
await userEvent.click(screen.getByText('Pokemon Red'))
expect(onSelect).toHaveBeenCalledWith(RED)
})
it('hides games with active runs when the checkbox is ticked', async () => {
setup({
runs: [{ id: 10, gameId: 1, status: 'active' } as never],
})
await userEvent.click(screen.getByLabelText(/hide games with active run/i))
expect(screen.queryByText('Pokemon Red')).not.toBeInTheDocument()
expect(screen.getByText('Pokemon Gold')).toBeInTheDocument()
})
it('does not render run-based checkboxes when runs prop is omitted', () => {
setup({ runs: undefined })
expect(screen.queryByLabelText(/hide games with active run/i)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Layout } from './Layout'
vi.mock('../hooks/useTheme', () => ({
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
}))
function renderLayout(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Layout />
</MemoryRouter>
)
}
describe('Layout', () => {
it('renders all desktop navigation links', () => {
renderLayout()
expect(screen.getAllByRole('link', { name: /new run/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /my runs/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /genlockes/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /stats/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /admin/i })[0]).toBeInTheDocument()
})
it('renders the brand logo link', () => {
renderLayout()
expect(screen.getByRole('link', { name: /ant/i })).toBeInTheDocument()
})
it('renders the theme toggle button', () => {
renderLayout()
expect(screen.getAllByRole('button', { name: /switch to light mode/i })[0]).toBeInTheDocument()
})
it('initially hides the mobile dropdown menu', () => {
renderLayout()
// Mobile menu items exist in DOM but menu is hidden; the mobile dropdown
// only appears inside the sm:hidden block after state toggle.
// The hamburger button should be present.
expect(screen.getByRole('button', { name: /toggle menu/i })).toBeInTheDocument()
})
it('shows the mobile dropdown when the hamburger is clicked', async () => {
renderLayout()
const hamburger = screen.getByRole('button', { name: /toggle menu/i })
await userEvent.click(hamburger)
// After click, the menu open state adds a dropdown with nav links
// We can verify the menu is open by checking a class change or that
// the nav links appear in the mobile dropdown section.
// The mobile dropdown renders navLinks in a div inside sm:hidden
expect(screen.getAllByRole('link', { name: /my runs/i }).length).toBeGreaterThan(1)
})
it('renders the footer with PokeDB attribution', () => {
renderLayout()
expect(screen.getByRole('link', { name: /pokedb/i })).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { RulesConfiguration } from './RulesConfiguration'
import { DEFAULT_RULES } from '../types/rules'
import type { NuzlockeRules } from '../types/rules'
function setup(overrides: Partial<React.ComponentProps<typeof RulesConfiguration>> = {}) {
const props = {
rules: { ...DEFAULT_RULES },
onChange: vi.fn(),
...overrides,
}
render(<RulesConfiguration {...props} />)
return props
}
describe('RulesConfiguration', () => {
it('renders all rule section headings', () => {
setup()
expect(screen.getByText('Core Rules')).toBeInTheDocument()
expect(screen.getByText('Playstyle')).toBeInTheDocument()
expect(screen.getByText('Run Variant')).toBeInTheDocument()
expect(screen.getByText('Type Restriction')).toBeInTheDocument()
})
it('renders the enabled/total count', () => {
setup()
expect(screen.getByText(/\d+ of \d+ rules enabled/)).toBeInTheDocument()
})
it('renders the Reset to Default button', () => {
setup()
expect(screen.getByRole('button', { name: /reset to default/i })).toBeInTheDocument()
})
it('calls onChange with updated rules when a rule is toggled off', async () => {
const { onChange } = setup()
// RuleToggle renders a role="switch" with no accessible name; navigate
// to it via the sibling label text.
const label = screen.getByText('Duplicates Clause')
// Structure: span → .flex.items-center.gap-2 → .flex-1.pr-4 → row div → switch button
const switchEl = label
.closest('div[class]')
?.parentElement?.parentElement?.querySelector('[role="switch"]') as HTMLElement
await userEvent.click(switchEl)
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ duplicatesClause: false }))
})
it('calls onChange with DEFAULT_RULES when Reset to Default is clicked', async () => {
const { onChange } = setup({ rules: { ...DEFAULT_RULES, duplicatesClause: false } })
await userEvent.click(screen.getByRole('button', { name: /reset to default/i }))
expect(onChange).toHaveBeenCalledWith(DEFAULT_RULES)
})
it('calls onReset when Reset to Default is clicked', async () => {
const onReset = vi.fn()
setup({ onReset })
await userEvent.click(screen.getByRole('button', { name: /reset to default/i }))
expect(onReset).toHaveBeenCalledOnce()
})
it('toggles a type on when a type button is clicked', async () => {
const { onChange } = setup()
await userEvent.click(screen.getByRole('button', { name: /fire/i }))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ allowedTypes: ['fire'] }))
})
it('shows Clear selection button when types are selected', () => {
setup({ rules: { ...DEFAULT_RULES, allowedTypes: ['fire'] } })
expect(screen.getByRole('button', { name: /clear selection/i })).toBeInTheDocument()
})
it('clears selected types when Clear selection is clicked', async () => {
const { onChange } = setup({ rules: { ...DEFAULT_RULES, allowedTypes: ['fire', 'water'] } })
await userEvent.click(screen.getByRole('button', { name: /clear selection/i }))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ allowedTypes: [] }))
})
it('hides rules in the hiddenRules set', () => {
const hiddenRules = new Set<keyof NuzlockeRules>(['duplicatesClause'])
setup({ hiddenRules })
expect(screen.queryByText('Duplicates Clause')).not.toBeInTheDocument()
})
})

View File

@@ -1 +1,17 @@
import '@testing-library/jest-dom'
// jsdom does not implement window.matchMedia; provide a minimal stub so
// modules that reference it at load time (e.g. useTheme) don't throw.
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})