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:
71
frontend/src/components/EndRunModal.test.tsx
Normal file
71
frontend/src/components/EndRunModal.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
115
frontend/src/components/GameGrid.test.tsx
Normal file
115
frontend/src/components/GameGrid.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
61
frontend/src/components/Layout.test.tsx
Normal file
61
frontend/src/components/Layout.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
84
frontend/src/components/RulesConfiguration.test.tsx
Normal file
84
frontend/src/components/RulesConfiguration.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
})),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user