Compare commits

...

37 Commits

Author SHA1 Message Date
Renovate Bot
70aa1156f5 Add renovate.json
All checks were successful
CI / backend-tests (pull_request) Successful in 27s
CI / frontend-tests (pull_request) Successful in 28s
2026-02-22 11:00:54 +00:00
1513bb3658 Split e2e tests into manual workflow_dispatch workflow
All checks were successful
CI / frontend-tests (push) Successful in 27s
CI / backend-tests (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:54:25 +01:00
3b63285bd1 Fix FK violations when pruning stale routes
Some checks failed
CI / backend-tests (push) Successful in 26s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Has been cancelled
Bulk delete bypasses ORM-level cascades, so manually delete
route_encounters, nullify boss_battle.after_route_id, and skip
routes referenced by user encounters before deleting stale routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:50:54 +01:00
4f0f881736 Update remaining FireRed boss sprites
All checks were successful
CI / backend-tests (push) Successful in 25s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Successful in 5m29s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:39:14 +01:00
dde20c932b Update Brock and Misty boss sprites
Some checks failed
CI / backend-tests (push) Successful in 26s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:37:29 +01:00
efa0b5f855 Add --prune flag to seed command to remove stale data
Without --prune, seeds continue to only upsert (add/update).
With --prune, routes, encounters, and bosses not present in the
seed JSON files are deleted from the database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:33:54 +01:00
d535433583 Archive 23 completed beans
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:59:54 +01:00
bf4302cdd4 Use host IP for backend test database URL in CI
All checks were successful
CI / backend-tests (push) Successful in 25s
CI / frontend-tests (push) Successful in 26s
CI / e2e-tests (push) Successful in 4m58s
The Postgres service container is not reachable via localhost from
inside the act runner container. Use the Docker host IP instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:49:04 +01:00
9a8a4f75f9 Use uv run for backend tests instead of system pip install
Some checks failed
CI / backend-tests (push) Failing after 1m13s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Has been cancelled
The uv-managed Python is externally managed and rejects --system pip
installs. Use uv run --extra dev to handle venv creation, dependency
installation, and test execution in a single step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:43:35 +01:00
00734ee233 Use host IP for e2e test API in CI
Some checks failed
CI / backend-tests (push) Failing after 26s
CI / frontend-tests (push) Successful in 29s
CI / e2e-tests (push) Successful in 5m37s
The act runner executes steps inside a container where localhost does
not reach the Docker host. Use E2E_API_URL env var (set to the host IP
192.168.1.10:8100 in CI) so both the global setup and Vite proxy can
reach the test API container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:38:29 +01:00
b50e9160ba Add uv to PATH after install in CI
The uv installer places the binary in ~/.local/bin which isn't on
PATH by default in the act runner. Source the env file for the current
step and append to GITHUB_PATH for subsequent steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:32:51 +01:00
f6bcb1fbe5 Fix CI failures for backend and e2e test jobs
Some checks failed
CI / backend-tests (push) Failing after 9s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Failing after 2m6s
Replace astral-sh/setup-uv action with direct curl install to avoid
Node.js 18 incompatibility (setup-uv v6+ requires Node 20+). Change
e2e test API host port from 8000 to 8100 to avoid conflict with
existing service on the CI runner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:29:04 +01:00
bf3a3d3329 Replace CI lint jobs with backend, frontend, and e2e test jobs
Some checks failed
CI / backend-tests (push) Failing after 37s
CI / frontend-tests (push) Successful in 28s
CI / e2e-tests (push) Failing after 1m42s
Lint, formatting, and type checks are already enforced by prek pre-commit
hooks, so CI now focuses on running the actual test suites instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:13:34 +01:00
9aaa95a1c7 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>
2026-02-21 13:57:12 +01:00
0d2f419c6a 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>
2026-02-21 13:47:55 +01:00
c80d7d0802 Set up frontend test infrastructure
Install @testing-library/react, @testing-library/jest-dom,
@testing-library/user-event, and jsdom. Configure Vitest with globals,
jsdom environment, and a setup file importing jest-dom matchers. Add a
custom render helper wrapping components with QueryClientProvider and
MemoryRouter. Exclude e2e/ from vitest. Smoke test covers
formatEvolutionMethod.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:35:15 +01:00
ee5bf03f19 Add integration tests for Genlockes & Bosses API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:21:32 +01:00
34835abe0c Add integration tests for Pokemon & Evolutions API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:15:00 +01:00
ca736e0f39 Add unit tests for services layer
36 tests covering build_families (linear chains, branching, disjoint,
Shedinja case), resolve_base_form, to_roman (parametrized), and
strip_roman_suffix including round-trip verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:05:24 +01:00
d6a0b60585 Add integration tests for Runs & Encounters API
28 tests covering run CRUD, rules JSONB storage, encounter creation,
route-lock enforcement, shinyClause and giftClause bypasses, status
transitions (complete/fail), and encounter update/delete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:58:28 +01:00
79eabf4f9f Add integration tests for Games & Routes API
25 tests covering game CRUD (create/list/get/update/delete), slug
uniqueness enforcement, by-region grouping, and route operations
(create/update/delete/reorder). Verifies that list_game_routes
excludes routes with no Pokemon encounters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:51:37 +01:00
4aae12cd72 Add unit tests for Pydantic schemas
46 tests across 12 schema classes covering CamelModel alias generation,
required field validation, optional field defaults, camelCase input/output,
nested model coercion, and from_attributes support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:41:22 +01:00
b0ac3714a9 Set up backend test infrastructure
Add pytest fixtures (engine, db_session, client) with session-scoped
event loop to avoid asyncpg loop mismatch errors. Smoke tests verify
all three main API endpoints return empty results on a clean DB.
Test DB provided by docker-compose.test.yml on port 5433.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:35:22 +01:00
16f9e68821 Mark Overhaul Nuzlocke Rules System epic as completed
All child features are done.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:23:37 +01:00
993ad09d9c Add type restriction rule (monolocke)
All checks were successful
CI / backend-lint (push) Successful in 10s
CI / actions-lint (push) Successful in 14s
CI / frontend-lint (push) Successful in 22s
Adds allowedTypes: string[] to NuzlockeRules. When set, the encounter
selector hides non-matching Pokemon and the routes endpoint filters out
routes with no matching encounters, so only eligible locations appear.

Type picker UI in RulesConfiguration; active restriction shown in
RuleBadges. Backend accepts allowed_types query param and joins through
RouteEncounter.pokemon to filter by type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:22:05 +01:00
85fef68dae Add static clause rule for encounter selector filtering
All checks were successful
CI / backend-lint (push) Successful in 10s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 23s
When disabled, static encounters (legendaries, scripted Pokémon) are
grayed out and unselectable in the encounter selector. Enabled by default.
Adds 'static' to METHOD_CONFIG/METHOD_ORDER with a teal badge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:04:39 +01:00
aea5d1d84d Update bean 2026-02-20 22:03:52 +01:00
347c25e8ed Add boss team match playstyle rule
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 16s
CI / frontend-lint (push) Successful in 21s
When enabled, the sticky boss banner shows the next boss's team size
as a hint for players who voluntarily match the boss's party count.
Handles variant boss teams by using the auto-detected starter variant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:03:11 +01:00
6968d35a33 Fix boss banner sticking behind nav header on scroll
The sticky level cap banner had z-10 and top-0, placing it behind the
nav (z-40) and overlapping it. Use top-14 to clear the nav height and
z-30 to layer correctly below the nav but above page content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:59:46 +01:00
18cc116348 Add gift clause rule for free gift encounters
When enabled, in-game gift Pokemon (starters, trades, fossils) do not
count against a location's encounter limit. Both a gift encounter and
a regular encounter can coexist on the same route, in any order.

Persists encounter origin on the Encounter model so the backend can
exclude gift encounters from route-lock checks bidirectionally, and the
frontend can split them into a separate display layer that doesn't lock
the route for regular encounters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:55:16 +01:00
ed1f7ad3d0 Increase encounter method badge sizes for readability
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 21s
Bump xs from 8px to 10px and sm from 9px to 12px so the route-list
badges (Grass, Surfing, Gift, etc.) are legible at a glance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:35:54 +01:00
2298c32691 Add egglocke, wonderlocke, and randomizer variant rules
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 14s
CI / frontend-lint (push) Successful in 21s
When any variant rule is enabled, the encounter modal switches from
the game's regional dex to an all-Pokemon search (same debounced
API pattern as EggEncounterModal). A new "Run Variant" section in
rules configuration groups these rules, and badges render in amber.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:33:01 +01:00
e25d1cf24c Remove unused nuzlocke rules, reorganize into core and playstyle
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 21s
Remove firstEncounterOnly, permadeath, nicknameRequired, and
postGameCompletion from the rules system — they are either implicit
(it's a nuzlocke tracker) or not enforced. Move levelCaps to core
(it's displayed in the sticky bar). Create a new "playstyle" category
for hardcoreMode and setModeOnly — informational rules useful for
stats but not enforced by the tracker. Remove the completion category
entirely. Add sub-task beans for the rules overhaul epic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:20:23 +01:00
4fbfcf9b29 Fix WCAG AA color contrast violations across all pages
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 21s
Replace incorrect perceived-brightness formula in Stats progress bars
with proper WCAG relative luminance calculation, and convert type bar
colors to hex values for reliable contrast detection. Add light: variant
classes to status badges, yellow/purple text, and admin nav links across
17 files. Darken light-mode status-active token and text-tertiary/muted
tokens. Add aria-labels to admin filter selects and flex-wrap for mobile
overflow on AdminEvolutions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:48:16 +01:00
a12478f24b Fix e2e tests for ESM and podman-compose compatibility
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 14s
CI / frontend-lint (push) Successful in 21s
Replace __dirname with import.meta.url (required by "type": "module").
Replace --wait flag with manual health polling (unsupported by
podman-compose). Use explicit -p project name to isolate test
containers from dev environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:19:17 +01:00
a7ec49fcad Add Playwright accessibility and mobile layout e2e tests
All checks were successful
CI / backend-lint (push) Successful in 49s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 1m2s
Set up end-to-end test infrastructure with Docker Compose test
environment, Playwright config, and automated global setup/teardown
that seeds a test database and creates fixtures via the API.

Tests cover 11 pages across both dark/light themes for WCAG 2.0 AA
accessibility (axe-core), and across 3 viewports (mobile, tablet,
desktop) for horizontal overflow and touch target validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:08:17 +01:00
a381633413 Add dark/light mode toggle with adaptive badge colors
Implement theme switching via sun/moon toggle in nav bar. Dark
remains the default; light mode overrides surface, text, border,
accent, and status color tokens. Preference persists in localStorage
and falls back to prefers-color-scheme. An inline script in
index.html prevents flash of wrong theme on load.

Define a Tailwind v4 @custom-variant for light mode and update all
badge components (encounter method, rule, condition) to use
light:bg-{color}-100 / light:text-{color}-700 for readable contrast
on light surfaces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:45:12 +01:00
103 changed files with 7145 additions and 831 deletions

View File

@@ -1,30 +0,0 @@
---
# nuzlocke-tracker-0arz
title: Integration tests for Runs & Encounters API
status: draft
type: task
created_at: 2026-02-10T09:33:21Z
updated_at: 2026-02-10T09:33:21Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the core run tracking and encounter API endpoints. This is the heart of the application.
## Checklist
- [ ] Test run CRUD operations (create, list, get, update, delete)
- [ ] Test run creation with rules configuration (JSONB field)
- [ ] Test encounter logging on a run (create encounter on a route)
- [ ] Test encounter status changes (alive → dead, alive → retired, etc.)
- [ ] Test duplicate encounter prevention (dupes clause logic)
- [ ] Test shiny encounter handling
- [ ] Test egg encounter handling
- [ ] Test ending a run (completion/failure)
- [ ] Test error cases (encounter on invalid route, duplicate route encounters, etc.)
## Notes
- Run endpoints: `backend/src/app/api/runs.py`
- Encounter endpoints: `backend/src/app/api/encounters.py`
- This is the most critical area — Nuzlocke rules enforcement should be thoroughly tested
- Tests need game + pokemon + route fixtures as prerequisites

View File

@@ -1,30 +0,0 @@
---
# nuzlocke-tracker-1guz
title: Component tests for key frontend components
status: draft
type: task
created_at: 2026-02-10T09:33:45Z
updated_at: 2026-02-10T09:33:45Z
parent: nuzlocke-tracker-yzpb
---
Write component tests for the most important frontend React components, focusing on user interactions and rendering correctness.
## Checklist
- [ ] Test `EncounterModal` — form submission, validation, Pokemon selection
- [ ] Test `StatusChangeModal` — status transitions, confirmation flow
- [ ] Test `EndRunModal` — run completion/failure flow
- [ ] Test `GameGrid` — game selection rendering, click handling
- [ ] Test `RulesConfiguration` — rules toggle interactions, state management
- [ ] Test `Layout` — navigation rendering, responsive behavior
- [ ] Test admin form modals (GameFormModal, RouteFormModal, PokemonFormModal) — CRUD form flows
- [ ] Test `AdminTable` — sorting, filtering, action buttons
## Notes
- Focus on user-facing behavior, not implementation details
- Use @testing-library/user-event for simulating clicks, typing, etc.
- Mock API responses for components that fetch data
- Don't aim for 100% coverage — prioritise the most complex/interactive components
- Page components (RunEncounters, RunDashboard, etc.) are large and complex — consider testing their sub-components instead

View File

@@ -1,72 +0,0 @@
---
# nuzlocke-tracker-49xj
title: Overhaul Nuzlocke Rules System
status: todo
type: epic
priority: normal
created_at: 2026-02-20T13:22:23Z
updated_at: 2026-02-20T13:27:58Z
---
Audit and overhaul the nuzlocke rules configuration. The current rules are a flat collection of boolean settings, some of which don't meaningfully affect tracker behavior. This epic cleans up existing rules and adds new rules for popular variants with actual tracker logic.
## Scope
### Rules to REMOVE (5)
These rules either define what a nuzlocke is (always true) or don't affect tracker behavior at all:
- `firstEncounterOnly` — implicit; it's a nuzlocke tracker
- `permadeath` — implicit; it's a nuzlocke tracker
- `nicknameRequired` — not enforced or tracked
- `setModeOnly` — not enforced or tracked
- `postGameCompletion` — not enforced or tracked
### Rules to KEEP (5)
These actively affect tracker logic:
- `duplicatesClause` — used in encounter creation and bulk randomization
- `shinyClause` — used in encounter creation (bypass route-lock)
- `pinwheelClause` — used for zone-based encounter logic
- `hardcoreMode` — used in BossDefeatModal (auto-win, 1 attempt)
- `levelCaps` — displayed in sticky bar on encounters page
### New rules to ADD (4)
These are boolean flags with real tracker logic:
- `egglocke` — all caught Pokemon are replaced with traded eggs. When enabled, encounter Pokemon selection should allow picking from ALL Pokemon (not just the game's regional dex), similar to the admin panel encounter creation / boss team creation flow.
- `wonderlocke` — all caught Pokemon are Wonder Traded away. Same as egglocke: encounter Pokemon selection allows picking from ALL Pokemon.
- `randomizer` — the run uses a randomized ROM. Same behavior: encounter Pokemon selection allows picking from ALL Pokemon since the dex is randomized.
- `giftClause` — in-game gift Pokemon are free and do not count against the area's encounter limit. When enabled, gift-origin encounters should bypass the route-lock check (similar to how shinyClause bypasses it for shinies).
### Follow-up beans to CREATE
Rules that need more complex logic, tracked separately:
- Type Restrictions (Monolocke) — restrict team to specific types
- Team Size Limit — cap active party size with warnings
- Static/Legendary Clause — whether static encounters count or are banned
## Checklist
### Cleanup: remove unused rules
- [ ] Remove `firstEncounterOnly`, `permadeath`, `nicknameRequired`, `setModeOnly`, `postGameCompletion` from `NuzlockeRules` interface and `DEFAULT_RULES`
- [ ] Remove their entries from `RULE_DEFINITIONS`
### Add new rules: frontend types
- [ ] Add `egglocke`, `wonderlocke`, `randomizer`, `giftClause` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
- [ ] Add `RuleDefinition` entries for the new rules with appropriate categories
### Add new rules: egglocke / wonderlocke / randomizer logic
- [ ] When any of `egglocke`, `wonderlocke`, or `randomizer` is enabled, the encounter Pokemon selector should allow picking from ALL Pokemon (not just the game's regional dex)
- [ ] Reuse the existing "all Pokemon" selector pattern used in admin panel encounter creation and boss team creation
### Add new rules: giftClause logic
- [ ] When `giftClause` is enabled, gift-origin encounters should bypass the route-lock check in the backend (similar to shinyClause bypass)
- [ ] Update the encounter creation endpoint to check for giftClause when origin is "gift"
### Update components and pages
- [ ] Update `RulesConfiguration`, `RuleToggle`, and `RuleBadges` components as needed
- [ ] Update `NewRun.tsx` and `NewGenlocke.tsx` if they reference removed rules
### Backend and data
- [ ] Verify backend encounter logic still works for removed rules (uses `.get()` with defaults)
- [ ] Update backend test seed data if it references removed rules
### Follow-ups
- [ ] Create follow-up beans for: Type Restrictions, Team Size Limit, Static/Legendary Clause

View File

@@ -1,30 +0,0 @@
---
# nuzlocke-tracker-9c66
title: Integration tests for Genlockes & Bosses API
status: draft
type: task
created_at: 2026-02-10T09:33:26Z
updated_at: 2026-02-10T09:33:26Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the genlocke challenge and boss battle API endpoints.
## Checklist
- [ ] Test genlocke CRUD operations (create, list, get, update, delete)
- [ ] Test leg management (add/remove legs to a genlocke)
- [ ] Test Pokemon transfers between genlocke legs
- [ ] Test boss battle CRUD (create, list, update, delete per game)
- [ ] Test boss battle results per run (record win/loss)
- [ ] Test stats endpoint for run statistics
- [ ] Test export endpoint
- [ ] Test error cases (invalid transfers, boss results for wrong game, etc.)
## Notes
- Genlocke endpoints: `backend/src/app/api/genlockes.py`
- Boss endpoints: `backend/src/app/api/bosses.py`
- Stats endpoints: `backend/src/app/api/stats.py`
- Export endpoints: `backend/src/app/api/export.py`
- Genlocke tests require multiple runs as fixtures

View File

@@ -1,25 +0,0 @@
---
# nuzlocke-tracker-9c8d
title: Rebrand to Another Nuzlocke Tracker (ANT)
status: completed
type: task
priority: normal
created_at: 2026-02-10T14:46:09Z
updated_at: 2026-02-17T19:17:22Z
---
Adopt the new branding: **Another Nuzlocke Tracker**, abbreviated **ANT**.
## Context
- No existing Nuzlocke tracker uses this name or acronym.
- The name is self-deprecating/playful ("yet another...") and the acronym opens up mascot/logo possibilities (ant character).
- **Durant** (Steel/Bug, Gen V) is the mascot Pokémon — an actual ant Pokémon that ties the ANT acronym directly into the Pokémon universe.
## Checklist
- [x] Update project name in package.json / config files
- [x] Update page titles, meta tags, and any visible app name references
- [x] Update README and any documentation with the new name
- [x] Design or source a Durant-themed logo/icon
- [x] Update favicon and app icons

View File

@@ -1,26 +0,0 @@
---
# nuzlocke-tracker-ch77
title: Integration tests for Games & Routes API
status: draft
type: task
created_at: 2026-02-10T09:33:13Z
updated_at: 2026-02-10T09:33:13Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the games and routes API endpoints in `backend/src/app/api/games.py`.
## Checklist
- [ ] Test CRUD operations for games (create, list, get, update, delete)
- [ ] Test route management within a game (create, list, reorder, update, delete)
- [ ] Test route encounter management (add/remove Pokemon to routes)
- [ ] Test bulk import functionality
- [ ] Test region grouping/filtering
- [ ] Test error cases (404 for missing games, validation errors, duplicate handling)
## Notes
- Use the httpx AsyncClient fixture from the test infrastructure task
- Each test should be independent — use fixtures to set up required data
- Test both success and error response codes and bodies

View File

@@ -1,33 +0,0 @@
---
# nuzlocke-tracker-d8cp
title: Set up frontend test infrastructure
status: draft
type: task
priority: normal
created_at: 2026-02-10T09:33:33Z
updated_at: 2026-02-10T09:34:00Z
parent: nuzlocke-tracker-yzpb
blocking:
- nuzlocke-tracker-ee9s
- nuzlocke-tracker-1guz
---
Set up the test infrastructure for the React/TypeScript frontend. No testing tooling currently exists.
## Checklist
- [ ] Install Vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, jsdom
- [ ] Configure Vitest in `vite.config.ts` or a dedicated `vitest.config.ts`
- [ ] Set up jsdom as the test environment
- [ ] Create a test setup file (e.g. `src/test/setup.ts`) that imports @testing-library/jest-dom matchers
- [ ] Create test utility helpers (e.g. render wrapper with providers — QueryClientProvider, BrowserRouter)
- [ ] Add a \`test\` script to package.json
- [ ] Verify the setup by writing a simple smoke test
- [ ] Set up MSW (Mock Service Worker) or a similar API mocking strategy for hook/component tests
## Notes
- Vitest integrates natively with Vite, which the project already uses
- React Testing Library is the standard for testing React components
- The app uses React Query (TanStack Query) and React Router — the test wrapper needs to provide these contexts
- MSW is recommended for mocking API calls in hook and component tests, but simpler approaches (vi.mock) may suffice initially

View File

@@ -1,127 +0,0 @@
---
# nuzlocke-tracker-dpw7
title: Modernize website design and look-and-feel
status: in-progress
type: feature
priority: normal
created_at: 2026-02-17T19:16:39Z
updated_at: 2026-02-17T21:04:45Z
---
Overhaul the UI to a dark-first, techy aesthetic with a cohesive brand identity derived from the ANT steel ant logo.
## Design direction
**Dark & techy** — dark-first surfaces, subtle glow/accent effects, code-editor-influenced aesthetic. Think GitHub dark, Discord, or Linear dark mode. Light mode becomes the secondary theme.
## 1. Brand palette + Tailwind theme
Define custom Tailwind v4 theme tokens in `index.css` using `@theme`:
- **Surfaces:** dark navy/charcoal base (`#0f1117`, `#161b22`, `#1c2128`) with layered elevation (darker = further back, lighter = elevated)
- **Accent:** steel blue from the logo (`#395E73`, `#7EB0CE`) as the primary interactive color
- **Text:** off-white primary (`#e6edf3`), muted gray secondary (`#7d8590`)
- **Status colors:** keep green/red/blue semantics but shift to darker, more saturated variants that work on dark surfaces
- **Borders:** subtle (`rgba(255,255,255,0.08)`) instead of gray-200/700
Replace ad-hoc Tailwind color classes throughout all components with theme tokens.
## 2. Typography
Self-host **Geist** (or Inter/JetBrains Mono pairing):
- Geist Sans for UI text (headings, labels, body)
- Geist Mono for data-heavy elements (stats numbers, encounter rates, levels)
- Set up via `@font-face` in `index.css`, configure in Tailwind `@theme`
- Establish clear size/weight hierarchy: page titles (2xl bold), section headers (lg semibold), body (sm regular), labels (xs medium)
## 3. Navigation redesign
- Add the ant SVG logo mark next to "ANT" in the nav
- Active route indicator (accent-colored underline or background highlight)
- Subtle bottom border glow or gradient accent line
- Slightly translucent/backdrop-blur nav background for depth
- Better mobile menu transitions (slide or fade instead of instant toggle)
## 4. Home page hero
- Full-width dark gradient hero section with the ant logo as a subtle watermark/background element
- Tagline with stronger typography hierarchy
- Stats summary (total runs, completion rate) as glowing stat pills if the user has data
- CTA button with accent glow/gradient
## 5. Cards & surfaces
- Dark elevated cards (`bg-[#161b22]`) with subtle border (`border-white/[0.06]`)
- Hover state: slight border brightness increase + subtle shadow glow in accent color
- Active/selected states with accent border
- Pokemon cards: dark backgrounds make sprites pop better, accent ring on hover
- Stat cards: accent-colored left border or top gradient
- Modals: dark overlay with backdrop-blur, card-style modal surface
## 6. Status indicators & badges
- Status badges: more vibrant on dark backgrounds (alive=emerald glow, dead=red glow, caught=blue)
- Type badges: use the established Pokemon type colors but tuned for dark surfaces
- Encounter method badges: same treatment
- Pulse animation on active run indicators
## 7. Micro-interactions
- Smooth transitions on all interactive elements (`transition-all duration-150`)
- Hover lift on cards (`hover:-translate-y-0.5`)
- Button press feedback (`active:scale-[0.98]`)
- Loading spinners in accent color
- Skeleton loading states for data-heavy pages
## 8. Dark/light mode
- Dark is the default and primary design target
- Light mode: invert surfaces to white/gray-50, keep accent colors, adjust contrast
- Toggle in nav (sun/moon icon)
- Persist preference in localStorage, respect `prefers-color-scheme`
## Checklist
- [x] Define Tailwind v4 `@theme` tokens (colors, fonts, spacing) in `index.css`
- [x] Self-host Geist font family, configure in theme
- [x] Redesign nav bar (logo mark, active states, backdrop blur, dark surface)
- [x] Redesign home page hero section
- [x] Update card/surface styles globally (Layout, PokemonCard, StatCard, GameCard)
- [x] Update all page-level backgrounds and containers
- [x] Update modal styles (EncounterModal, StatusChangeModal, etc.)
- [x] Update badge/indicator styles (TypeBadge, RuleBadges, EncounterMethodBadge)
- [ ] Add dark/light mode toggle to nav
- [x] Polish hover states and transitions across all interactive elements
- [ ] Add automated Playwright accessibility and mobile layout tests
- [ ] Verify accessibility (contrast ratios, focus indicators)
- [ ] Verify mobile layout and touch targets
## Automated verification approach
Add a Playwright test suite that covers both accessibility and mobile layout:
### Accessibility (axe-core + Playwright)
- Install `@axe-core/playwright` as a dev dependency
- Write a test that visits each major page and runs axe-core
- Pages to cover: Home, RunList, RunDashboard, RunEncounters, Stats, NewRun, GenlockeList, GenlockeDetail, NewGenlocke, admin pages
- Check for: color contrast (WCAG AA), missing ARIA labels, heading hierarchy, focus indicators, form label associations
- Run as part of CI
### Mobile layout (Playwright viewports)
- Test each major page at 3 viewport sizes: mobile (375x667), tablet (768x1024), desktop (1280x800)
- Assert no horizontal overflow (`document.documentElement.scrollWidth <= window.innerWidth`)
- Assert touch targets are at least 44x44px (axe-core `target-size` rule)
- Screenshot each page at each viewport for visual review
### Implementation
- Add test file: `frontend/e2e/accessibility.spec.ts`
- Add Playwright config if not present
- Add `test:a11y` script to `package.json`
## Constraints
- Tailwind-only (no additional CSS frameworks or component libraries)
- Self-hosted fonts only (no Google Fonts CDN)
- Maintain accessibility (WCAG AA contrast ratios, visible focus indicators)
- No performance regression (fonts loaded with `font-display: swap`, no layout shift)

View File

@@ -0,0 +1,19 @@
---
# nuzlocke-tracker-ecn3
title: Prune stale seed data during seeding
status: completed
type: bug
priority: normal
created_at: 2026-02-21T16:28:37Z
updated_at: 2026-02-21T16:29:43Z
---
Seeds only upsert (add/update), they never remove routes, encounters, or bosses that no longer exist in the seed JSON. When routes are renamed, old route names persist in production.
## Fix
After upserting each entity type, delete rows not present in the seed data:
1. **Routes**: After upserting all routes for a version group, delete routes whose names are not in the seed set. FK cascades handle child routes and encounters.
2. **Encounters**: After upserting encounters for a route+game, delete encounters not in the seed data for that route+game pair.
3. **Bosses**: After upserting bosses for a version group, delete bosses with order values beyond what the seed provides.

View File

@@ -1,31 +0,0 @@
---
# nuzlocke-tracker-ee9s
title: Unit tests for frontend utilities and hooks
status: draft
type: task
created_at: 2026-02-10T09:33:38Z
updated_at: 2026-02-10T09:33:38Z
parent: nuzlocke-tracker-yzpb
---
Write unit tests for the frontend utility functions and custom React hooks.
## 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

View File

@@ -1,28 +0,0 @@
---
# nuzlocke-tracker-hjkk
title: Unit tests for Pydantic schemas and model validation
status: draft
type: task
created_at: 2026-02-10T09:33:03Z
updated_at: 2026-02-10T09:33:03Z
parent: nuzlocke-tracker-yzpb
---
Write unit tests for the Pydantic schemas in `backend/src/app/schemas/`. These are pure validation logic and can be tested without a database.
## Checklist
- [ ] Test `CamelModel` base class (snake_case → camelCase alias generation)
- [ ] Test run schemas — creation validation, required fields, optional fields, serialization
- [ ] Test game schemas — validation rules, field constraints
- [ ] Test encounter schemas — status enum validation, field dependencies
- [ ] Test boss schemas — nested model validation
- [ ] Test genlocke schemas — complex nested structures
- [ ] Test stats schemas — response model structure
- [ ] Test evolution schemas — validation of evolution chain data
## Notes
- Focus on: valid input acceptance, invalid input rejection, serialization output format
- The `CamelModel` base class does alias generation — verify both input (camelCase) and output (camelCase) work
- Test edge cases like empty strings, negative numbers, missing required fields

View File

@@ -1,24 +0,0 @@
---
# nuzlocke-tracker-iam7
title: Unit tests for services layer
status: draft
type: task
created_at: 2026-02-10T09:33:08Z
updated_at: 2026-02-10T09:33:08Z
parent: nuzlocke-tracker-yzpb
---
Write unit tests for the business logic in `backend/src/app/services/`. Currently this is the `families.py` service which handles Pokemon evolution family resolution.
## Checklist
- [ ] Test family resolution with simple linear evolution chains (e.g. A → B → C)
- [ ] Test family resolution with branching evolutions (e.g. Eevee)
- [ ] Test family resolution with region-specific evolutions
- [ ] Test edge cases: single-stage Pokemon, circular references (if possible), missing data
## Notes
- `services/families.py` contains the core logic for resolving Pokemon evolution families
- These tests may need mock database sessions or in-memory data depending on how the service queries data
- If the service methods take a DB session, mock it; if they operate on data objects, pass test data directly

View File

@@ -0,0 +1,11 @@
---
# nuzlocke-tracker-m8ki
title: Split e2e tests into manual workflow
status: completed
type: task
priority: normal
created_at: 2026-02-21T16:53:37Z
updated_at: 2026-02-21T16:54:04Z
---
Remove e2e-tests job from ci.yml and create a new e2e.yml workflow with workflow_dispatch trigger only.

View File

@@ -1,36 +0,0 @@
---
# nuzlocke-tracker-rrcf
title: Set up backend test infrastructure
status: draft
type: task
priority: normal
created_at: 2026-02-10T09:32:57Z
updated_at: 2026-02-10T09:33:59Z
parent: nuzlocke-tracker-yzpb
blocking:
- nuzlocke-tracker-hjkk
- nuzlocke-tracker-iam7
- nuzlocke-tracker-ch77
- nuzlocke-tracker-ugb7
- nuzlocke-tracker-0arz
- nuzlocke-tracker-9c66
---
Set up the foundational test infrastructure for the FastAPI backend so that all subsequent test tasks can build on it.
## Checklist
- [ ] Create `backend/tests/conftest.py` with shared fixtures
- [ ] Set up a test database strategy (use a separate test PostgreSQL database or SQLite for speed — evaluate trade-offs)
- [ ] Create an async test client fixture using `httpx.AsyncClient` with the FastAPI `app`
- [ ] Create a database session fixture that creates/drops tables per test session or uses transactions for isolation
- [ ] Add factory fixtures or helpers for creating common test entities (games, pokemon, runs, etc.)
- [ ] Verify the setup works by writing a simple smoke test (e.g. health endpoint returns 200)
- [ ] Document how to run tests (e.g. `pytest` from backend dir, any env vars needed)
## Notes
- pytest, pytest-asyncio, and httpx are already in pyproject.toml dev dependencies
- AsyncIO mode is set to "auto" in pyproject.toml
- The app uses SQLAlchemy async with asyncpg — test fixtures need to handle async session management
- Consider using `SAVEPOINT`-based transaction rollback for test isolation (faster than recreating tables)

View File

@@ -1,25 +0,0 @@
---
# nuzlocke-tracker-ugb7
title: Integration tests for Pokemon & Evolutions API
status: draft
type: task
created_at: 2026-02-10T09:33:16Z
updated_at: 2026-02-10T09:33:16Z
parent: nuzlocke-tracker-yzpb
---
Write integration tests for the Pokemon and evolutions API endpoints.
## Checklist
- [ ] Test Pokemon CRUD operations (create, list, search, update, delete)
- [ ] Test Pokemon filtering and search
- [ ] Test evolution chain CRUD (create, list, get, update, delete)
- [ ] Test evolution family resolution endpoint
- [ ] Test error cases (invalid Pokemon references, circular evolutions, etc.)
## Notes
- Pokemon endpoints are in `backend/src/app/api/pokemon.py`
- Evolution endpoints are in `backend/src/app/api/evolutions.py`
- Evolution tests should cover multi-stage and branching chains

View File

@@ -1,28 +0,0 @@
---
# nuzlocke-tracker-yzpb
title: Implement Unit & Integration Tests
status: draft
type: epic
priority: high
created_at: 2026-02-10T09:32:47Z
updated_at: 2026-02-10T12:05:43Z
---
Add comprehensive unit and integration test coverage to both the backend (FastAPI/Python) and frontend (React/TypeScript). The project currently has zero tests — pytest is configured in pyproject.toml with pytest-asyncio and httpx, but no actual test files exist. The frontend has no test tooling at all.
**Scope:**
- Unit tests for isolated logic (schemas, services, utilities)
- Integration tests for API endpoints (using httpx AsyncClient against a test database)
- Frontend unit/component tests (using Vitest + React Testing Library)
**Explicitly out of scope:**
- End-to-end / browser tests (e.g. Selenium, Playwright) — requires specialised infrastructure
## Success Criteria
- [ ] Backend test infrastructure is set up (conftest, fixtures, test DB)
- [ ] Backend schemas and services have unit test coverage
- [ ] Backend API endpoints have integration test coverage
- [ ] Frontend test infrastructure is set up (Vitest, RTL)
- [ ] Frontend utilities and hooks have unit test coverage
- [ ] Frontend components have basic render/interaction tests

View File

@@ -22,40 +22,39 @@ permissions:
contents: read contents: read
jobs: jobs:
backend-lint: backend-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
ports:
- 5433:5432
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: nuzlocke_test
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - name: Install uv and Python
with:
python-version: "3.14"
- run: pip install ruff ty
- name: Check linting
run: ruff check backend/
- name: Check formatting
run: ruff format --check backend/
- name: Type check
run: ty check backend/src/
continue-on-error: true
actions-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Install actionlint
run: | run: |
bash <(curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) curl -LsSf https://astral.sh/uv/install.sh | sh
sudo mv actionlint /usr/local/bin/ source "$HOME/.local/bin/env"
- name: Lint GitHub Actions echo "$HOME/.local/bin" >> "$GITHUB_PATH"
run: actionlint uv python install 3.14
- name: Audit GitHub Actions security - name: Run tests
run: pipx run zizmor .github/workflows/ run: uv run --python 3.14 --extra dev pytest -q
working-directory: backend
env:
TEST_DATABASE_URL: postgresql+asyncpg://postgres:postgres@192.168.1.10:5433/nuzlocke_test
frontend-lint: frontend-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
@@ -67,12 +66,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
working-directory: frontend working-directory: frontend
- name: Lint - name: Run tests
run: npm run lint run: npm test
working-directory: frontend working-directory: frontend
- name: Check formatting
run: npx oxfmt --check "src/"
working-directory: frontend
- name: Type check
run: npx tsc -b
working-directory: frontend

35
.github/workflows/e2e.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: E2E Tests
on:
workflow_dispatch:
permissions:
contents: read
jobs:
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"
- name: Install dependencies
run: npm ci
working-directory: frontend
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
working-directory: frontend
- name: Run e2e tests
run: npm run test:e2e
working-directory: frontend
env:
E2E_API_URL: http://192.168.1.10:8100
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-report
path: frontend/playwright-report/

View File

@@ -66,4 +66,6 @@ root = "src"
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
testpaths = ["tests"] testpaths = ["tests"]

View File

@@ -0,0 +1,29 @@
"""add origin to encounters
Revision ID: i0d1e2f3a4b5
Revises: h9c0d1e2f3a4
Create Date: 2026-02-20 12:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "i0d1e2f3a4b5"
down_revision: str | Sequence[str] | None = "h9c0d1e2f3a4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"encounters",
sa.Column("origin", sa.String(20), nullable=True),
)
def downgrade() -> None:
op.drop_column("encounters", "origin")

View File

@@ -58,12 +58,13 @@ async def create_encounter(
detail="Cannot create encounter on a parent route. Use a child route instead.", detail="Cannot create encounter on a parent route. Use a child route instead.",
) )
# Shiny clause: shiny encounters bypass the route-lock check # Shiny/gift clause: certain encounters bypass the route-lock check
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
skip_route_lock = (data.is_shiny and shiny_clause_on) or data.origin in ( gift_clause_on = run.rules.get("giftClause", False) if run.rules else False
"shed_evolution", skip_route_lock = (
"egg", (data.is_shiny and shiny_clause_on)
"transfer", or (data.origin == "gift" and gift_clause_on)
or data.origin in ("shed_evolution", "egg", "transfer")
) )
# If this route has a parent, check if sibling already has an encounter # If this route has a parent, check if sibling already has an encounter
@@ -93,13 +94,17 @@ async def create_encounter(
# Check if any relevant sibling already has an encounter in this run # Check if any relevant sibling already has an encounter in this run
# Exclude transfer-target encounters so they don't block the starter # Exclude transfer-target encounters so they don't block the starter
transfer_target_ids = select(GenlockeTransfer.target_encounter_id) transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
existing_encounter = await session.execute( lock_query = select(Encounter).where(
select(Encounter).where( Encounter.run_id == run_id,
Encounter.run_id == run_id, Encounter.route_id.in_(sibling_ids),
Encounter.route_id.in_(sibling_ids), ~Encounter.id.in_(transfer_target_ids),
~Encounter.id.in_(transfer_target_ids),
)
) )
# Gift-origin encounters don't count toward route lock
if gift_clause_on:
lock_query = lock_query.where(
Encounter.origin.is_(None) | (Encounter.origin != "gift")
)
existing_encounter = await session.execute(lock_query)
if existing_encounter.scalar_one_or_none() is not None: if existing_encounter.scalar_one_or_none() is not None:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
@@ -119,6 +124,7 @@ async def create_encounter(
status=data.status, status=data.status,
catch_level=data.catch_level, catch_level=data.catch_level,
is_shiny=data.is_shiny, is_shiny=data.is_shiny,
origin=data.origin,
) )
session.add(encounter) session.add(encounter)
await session.commit() await session.commit()

View File

@@ -1,7 +1,7 @@
import json import json
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, select, update from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -131,6 +131,7 @@ async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
async def list_game_routes( async def list_game_routes(
game_id: int, game_id: int,
flat: bool = False, flat: bool = False,
allowed_types: list[str] | None = Query(None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
""" """
@@ -138,13 +139,18 @@ async def list_game_routes(
By default, returns a hierarchical structure with top-level routes containing By default, returns a hierarchical structure with top-level routes containing
nested children. Use `flat=True` to get a flat list of all routes. nested children. Use `flat=True` to get a flat list of all routes.
When `allowed_types` is provided, routes with no encounters matching any of
those Pokemon types are excluded.
""" """
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
result = await session.execute( result = await session.execute(
select(Route) select(Route)
.where(Route.version_group_id == vg_id) .where(Route.version_group_id == vg_id)
.options(selectinload(Route.route_encounters)) .options(
selectinload(Route.route_encounters).selectinload(RouteEncounter.pokemon)
)
.order_by(Route.order) .order_by(Route.order)
) )
all_routes = result.scalars().all() all_routes = result.scalars().all()
@@ -170,7 +176,14 @@ async def list_game_routes(
# Determine which routes have encounters for this game # Determine which routes have encounters for this game
def has_encounters(route: Route) -> bool: def has_encounters(route: Route) -> bool:
return any(re.game_id == game_id for re in route.route_encounters) encounters = [re for re in route.route_encounters if re.game_id == game_id]
if not encounters:
return False
if allowed_types:
return any(
t in allowed_types for re in encounters for t in re.pokemon.types
)
return True
# Collect IDs of parent routes that have at least one child with encounters # Collect IDs of parent routes that have at least one child with encounters
parents_with_children = set() parents_with_children = set()

View File

@@ -24,6 +24,7 @@ class Encounter(Base):
is_shiny: Mapped[bool] = mapped_column( is_shiny: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("false") Boolean, default=False, server_default=text("false")
) )
origin: Mapped[str | None] = mapped_column(String(20))
caught_at: Mapped[datetime] = mapped_column( caught_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )

View File

@@ -35,6 +35,7 @@ class EncounterResponse(CamelModel):
faint_level: int | None faint_level: int | None
death_cause: str | None death_cause: str | None
is_shiny: bool is_shiny: bool
origin: str | None
caught_at: datetime caught_at: datetime

View File

@@ -2,6 +2,7 @@
Usage: Usage:
python -m app.seeds # Run seed python -m app.seeds # Run seed
python -m app.seeds --prune # Run seed and remove stale data not in seed files
python -m app.seeds --verify # Run seed + verification python -m app.seeds --verify # Run seed + verification
python -m app.seeds --export # Export all seed data from DB to JSON files python -m app.seeds --export # Export all seed data from DB to JSON files
""" """
@@ -21,7 +22,8 @@ async def main():
await export_all() await export_all()
return return
await seed() prune = "--prune" in sys.argv
await seed(prune=prune)
if "--verify" in sys.argv: if "--verify" in sys.argv:
await verify() await verify()

View File

@@ -125,7 +125,7 @@ RUN_DEFS = [
"name": "Unova Adventure", "name": "Unova Adventure",
"status": "active", "status": "active",
"progress": 0.35, "progress": 0.35,
"rules": {}, "rules": {"randomizer": True},
"started_days_ago": 5, "started_days_ago": 5,
"ended_days_ago": None, "ended_days_ago": None,
}, },
@@ -142,15 +142,19 @@ RUN_DEFS = [
# Default rules (matches frontend DEFAULT_RULES) # Default rules (matches frontend DEFAULT_RULES)
DEFAULT_RULES = { DEFAULT_RULES = {
"firstEncounterOnly": True,
"permadeath": True,
"nicknameRequired": True,
"duplicatesClause": True, "duplicatesClause": True,
"shinyClause": True, "shinyClause": True,
"giftClause": False,
"staticClause": True,
"pinwheelClause": True, "pinwheelClause": True,
"hardcoreMode": False,
"levelCaps": False, "levelCaps": False,
"hardcoreMode": False,
"setModeOnly": False, "setModeOnly": False,
"bossTeamMatch": False,
"egglocke": False,
"wonderlocke": False,
"randomizer": False,
"allowedTypes": [],
} }

View File

@@ -1,11 +1,12 @@
"""Database upsert helpers for seed data.""" """Database upsert helpers for seed data."""
from sqlalchemy import delete, select from sqlalchemy import delete, select, update
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.boss_battle import BossBattle from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon from app.models.boss_pokemon import BossPokemon
from app.models.encounter import Encounter
from app.models.evolution import Evolution from app.models.evolution import Evolution
from app.models.game import Game from app.models.game import Game
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
@@ -124,11 +125,14 @@ async def upsert_routes(
session: AsyncSession, session: AsyncSession,
version_group_id: int, version_group_id: int,
routes: list[dict], routes: list[dict],
*,
prune: bool = False,
) -> dict[str, int]: ) -> dict[str, int]:
"""Upsert route records for a version group, return {name: id} mapping. """Upsert route records for a version group, return {name: id} mapping.
Handles hierarchical routes: routes with 'children' are parent routes, Handles hierarchical routes: routes with 'children' are parent routes,
and their children get parent_route_id set accordingly. and their children get parent_route_id set accordingly.
When prune is True, deletes routes not present in the seed data.
""" """
# First pass: upsert all parent routes (without parent_route_id) # First pass: upsert all parent routes (without parent_route_id)
for route in routes: for route in routes:
@@ -185,6 +189,43 @@ async def upsert_routes(
await session.flush() await session.flush()
if prune:
seed_names: set[str] = set()
for route in routes:
seed_names.add(route["name"])
for child in route.get("children", []):
seed_names.add(child["name"])
# Find stale route IDs, excluding routes with user encounters
in_use_subq = select(Encounter.route_id).distinct().subquery()
stale_route_ids_result = await session.execute(
select(Route.id).where(
Route.version_group_id == version_group_id,
Route.name.not_in(seed_names),
Route.id.not_in(select(in_use_subq)),
)
)
stale_route_ids = [row.id for row in stale_route_ids_result]
if stale_route_ids:
# Delete encounters referencing stale routes (no DB-level cascade)
await session.execute(
delete(RouteEncounter).where(
RouteEncounter.route_id.in_(stale_route_ids)
)
)
# Nullify boss battle references to stale routes
await session.execute(
update(BossBattle)
.where(BossBattle.after_route_id.in_(stale_route_ids))
.values(after_route_id=None)
)
# Now safe to delete the routes
await session.execute(delete(Route).where(Route.id.in_(stale_route_ids)))
print(f" Pruned {len(stale_route_ids)} stale route(s)")
await session.flush()
# Return full mapping including children # Return full mapping including children
result = await session.execute( result = await session.execute(
select(Route.name, Route.id).where(Route.version_group_id == version_group_id) select(Route.name, Route.id).where(Route.version_group_id == version_group_id)
@@ -233,8 +274,15 @@ async def upsert_route_encounters(
encounters: list[dict], encounters: list[dict],
dex_to_id: dict[int, int], dex_to_id: dict[int, int],
game_id: int, game_id: int,
*,
prune: bool = False,
) -> int: ) -> int:
"""Upsert encounters for a route and game, return count of upserted rows.""" """Upsert encounters for a route and game, return count of upserted rows.
When prune is True, deletes encounters not present in the seed data.
"""
seed_keys: set[tuple[int, str, str]] = set()
count = 0 count = 0
for enc in encounters: for enc in encounters:
pokemon_id = dex_to_id.get(enc["pokeapi_id"]) pokemon_id = dex_to_id.get(enc["pokeapi_id"])
@@ -245,6 +293,7 @@ async def upsert_route_encounters(
conditions = enc.get("conditions") conditions = enc.get("conditions")
if conditions: if conditions:
for condition_name, rate in conditions.items(): for condition_name, rate in conditions.items():
seed_keys.add((pokemon_id, enc["method"], condition_name))
await _upsert_single_encounter( await _upsert_single_encounter(
session, session,
route_id, route_id,
@@ -258,6 +307,7 @@ async def upsert_route_encounters(
) )
count += 1 count += 1
else: else:
seed_keys.add((pokemon_id, enc["method"], ""))
await _upsert_single_encounter( await _upsert_single_encounter(
session, session,
route_id, route_id,
@@ -270,6 +320,23 @@ async def upsert_route_encounters(
) )
count += 1 count += 1
if prune:
existing = await session.execute(
select(RouteEncounter).where(
RouteEncounter.route_id == route_id,
RouteEncounter.game_id == game_id,
)
)
stale_ids = [
row.id
for row in existing.scalars()
if (row.pokemon_id, row.encounter_method, row.condition) not in seed_keys
]
if stale_ids:
await session.execute(
delete(RouteEncounter).where(RouteEncounter.id.in_(stale_ids))
)
return count return count
@@ -280,8 +347,13 @@ async def upsert_bosses(
dex_to_id: dict[int, int], dex_to_id: dict[int, int],
route_name_to_id: dict[str, int] | None = None, route_name_to_id: dict[str, int] | None = None,
slug_to_game_id: dict[str, int] | None = None, slug_to_game_id: dict[str, int] | None = None,
*,
prune: bool = False,
) -> int: ) -> int:
"""Upsert boss battles for a version group, return count of bosses upserted.""" """Upsert boss battles for a version group, return count of bosses upserted.
When prune is True, deletes boss battles not present in the seed data.
"""
count = 0 count = 0
for boss in bosses: for boss in bosses:
# Resolve after_route_name to an ID # Resolve after_route_name to an ID
@@ -364,6 +436,20 @@ async def upsert_bosses(
count += 1 count += 1
if prune:
seed_orders = {boss["order"] for boss in bosses}
pruned = await session.execute(
delete(BossBattle)
.where(
BossBattle.version_group_id == version_group_id,
BossBattle.order.not_in(seed_orders),
)
.returning(BossBattle.id)
)
pruned_count = len(pruned.all())
if pruned_count:
print(f" Pruned {pruned_count} stale boss battle(s)")
await session.flush() await session.flush()
return count return count

View File

@@ -38,9 +38,12 @@ def load_json(filename: str):
return json.load(f) return json.load(f)
async def seed(): async def seed(*, prune: bool = False):
"""Run the full seed process.""" """Run the full seed process.
print("Starting seed...")
When prune is True, removes DB rows not present in seed data.
"""
print("Starting seed..." + (" (with pruning)" if prune else ""))
async with async_session() as session, session.begin(): async with async_session() as session, session.begin():
# 1. Upsert version groups # 1. Upsert version groups
@@ -88,7 +91,7 @@ async def seed():
continue continue
# Upsert routes once per version group # Upsert routes once per version group
route_map = await upsert_routes(session, vg_id, routes_data) route_map = await upsert_routes(session, vg_id, routes_data, prune=prune)
route_maps_by_vg[vg_id] = route_map route_maps_by_vg[vg_id] = route_map
total_routes += len(route_map) total_routes += len(route_map)
print(f" {vg_slug}: {len(route_map)} routes") print(f" {vg_slug}: {len(route_map)} routes")
@@ -119,6 +122,7 @@ async def seed():
route["encounters"], route["encounters"],
dex_to_id, dex_to_id,
game_id, game_id,
prune=prune,
) )
total_encounters += enc_count total_encounters += enc_count
@@ -137,6 +141,7 @@ async def seed():
child["encounters"], child["encounters"],
dex_to_id, dex_to_id,
game_id, game_id,
prune=prune,
) )
total_encounters += enc_count total_encounters += enc_count
@@ -160,7 +165,13 @@ async def seed():
route_name_to_id = route_maps_by_vg.get(vg_id, {}) route_name_to_id = route_maps_by_vg.get(vg_id, {})
boss_count = await upsert_bosses( boss_count = await upsert_bosses(
session, vg_id, bosses_data, dex_to_id, route_name_to_id, slug_to_id session,
vg_id,
bosses_data,
dex_to_id,
route_name_to_id,
slug_to_id,
prune=prune,
) )
total_bosses += boss_count total_bosses += boss_count
print(f" {vg_slug}: {boss_count} bosses") print(f" {vg_slug}: {boss_count} bosses")

61
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,61 @@
import os
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import app.models # noqa: F401 — ensures all models register with Base.metadata
from app.core.database import Base, get_session
from app.main import app
TEST_DATABASE_URL = os.getenv(
"TEST_DATABASE_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5433/nuzlocke_test",
)
@pytest.fixture(scope="session")
async def engine():
"""Create the test engine and schema once for the entire session."""
eng = create_async_engine(TEST_DATABASE_URL, echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await eng.dispose()
@pytest.fixture
async def db_session(engine):
"""
Provide a database session for a single test.
Overrides the FastAPI get_session dependency so endpoint handlers use the
same session. Truncates all tables after the test to isolate state.
"""
session_factory = async_sessionmaker(engine, expire_on_commit=False)
session = session_factory()
async def override_get_session():
yield session
app.dependency_overrides[get_session] = override_get_session
yield session
await session.close()
app.dependency_overrides.clear()
async with engine.begin() as conn:
for table in reversed(Base.metadata.sorted_tables):
await conn.execute(table.delete())
@pytest.fixture
async def client(db_session):
"""Async HTTP client wired to the FastAPI app with the test database session."""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac

320
backend/tests/test_games.py Normal file
View File

@@ -0,0 +1,320 @@
"""Integration tests for the Games & Routes API."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.game import Game
from app.models.version_group import VersionGroup
BASE = "/api/v1/games"
GAME_PAYLOAD = {
"name": "Pokemon Red",
"slug": "red",
"generation": 1,
"region": "kanto",
}
@pytest.fixture
async def game(client: AsyncClient) -> dict:
"""A game created via the API (no version_group_id)."""
response = await client.post(BASE, json=GAME_PAYLOAD)
assert response.status_code == 201
return response.json()
@pytest.fixture
async def game_with_vg(db_session: AsyncSession) -> tuple[int, int]:
"""A game with a VersionGroup, required for route operations."""
vg = VersionGroup(name="Red/Blue", slug="red-blue")
db_session.add(vg)
await db_session.flush()
g = Game(
name="Pokemon Red",
slug="red-vg",
generation=1,
region="kanto",
version_group_id=vg.id,
)
db_session.add(g)
await db_session.commit()
await db_session.refresh(g)
return g.id, vg.id
# ---------------------------------------------------------------------------
# Games — list
# ---------------------------------------------------------------------------
class TestListGames:
async def test_empty_returns_empty_list(self, client: AsyncClient):
response = await client.get(BASE)
assert response.status_code == 200
assert response.json() == []
async def test_returns_created_game(self, client: AsyncClient, game: dict):
response = await client.get(BASE)
assert response.status_code == 200
slugs = [g["slug"] for g in response.json()]
assert "red" in slugs
# ---------------------------------------------------------------------------
# Games — create
# ---------------------------------------------------------------------------
class TestCreateGame:
async def test_creates_and_returns_game(self, client: AsyncClient):
response = await client.post(BASE, json=GAME_PAYLOAD)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Pokemon Red"
assert data["slug"] == "red"
assert isinstance(data["id"], int)
async def test_duplicate_slug_returns_409(self, client: AsyncClient, game: dict):
response = await client.post(
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
)
assert response.status_code == 409
async def test_missing_required_field_returns_422(self, client: AsyncClient):
response = await client.post(BASE, json={"name": "Pokemon Red"})
assert response.status_code == 422
# ---------------------------------------------------------------------------
# Games — get
# ---------------------------------------------------------------------------
class TestGetGame:
async def test_returns_game_with_empty_routes(
self, client: AsyncClient, game: dict
):
response = await client.get(f"{BASE}/{game['id']}")
assert response.status_code == 200
data = response.json()
assert data["id"] == game["id"]
assert data["slug"] == "red"
assert data["routes"] == []
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Games — update
# ---------------------------------------------------------------------------
class TestUpdateGame:
async def test_updates_name(self, client: AsyncClient, game: dict):
response = await client.put(
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
)
assert response.status_code == 200
assert response.json()["name"] == "Pokemon Blue"
async def test_slug_unchanged_on_partial_update(
self, client: AsyncClient, game: dict
):
response = await client.put(f"{BASE}/{game['id']}", json={"name": "New Name"})
assert response.json()["slug"] == "red"
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.put(f"{BASE}/9999", json={"name": "x"})).status_code == 404
async def test_duplicate_slug_returns_409(self, client: AsyncClient):
await client.post(BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"})
r1 = await client.post(
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
)
game_id = r1.json()["id"]
response = await client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
assert response.status_code == 409
# ---------------------------------------------------------------------------
# Games — delete
# ---------------------------------------------------------------------------
class TestDeleteGame:
async def test_deletes_game(self, client: AsyncClient, game: dict):
response = await client.delete(f"{BASE}/{game['id']}")
assert response.status_code == 204
assert (await client.get(f"{BASE}/{game['id']}")).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Games — by-region
# ---------------------------------------------------------------------------
class TestListByRegion:
async def test_returns_list(self, client: AsyncClient):
response = await client.get(f"{BASE}/by-region")
assert response.status_code == 200
assert isinstance(response.json(), list)
async def test_region_structure(self, client: AsyncClient):
response = await client.get(f"{BASE}/by-region")
regions = response.json()
assert len(regions) > 0
first = regions[0]
assert "name" in first
assert "generation" in first
assert "games" in first
assert isinstance(first["games"], list)
async def test_game_appears_in_region(self, client: AsyncClient, game: dict):
response = await client.get(f"{BASE}/by-region")
all_games = [g for region in response.json() for g in region["games"]]
assert any(g["slug"] == "red" for g in all_games)
# ---------------------------------------------------------------------------
# Routes — create / get
# ---------------------------------------------------------------------------
class TestCreateRoute:
async def test_creates_route(self, client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg
response = await client.post(
f"{BASE}/{game_id}/routes",
json={"name": "Pallet Town", "order": 1},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Pallet Town"
assert data["order"] == 1
assert isinstance(data["id"], int)
async def test_game_detail_includes_route(
self, client: AsyncClient, game_with_vg: tuple
):
game_id, _ = game_with_vg
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
)
response = await client.get(f"{BASE}/{game_id}")
routes = response.json()["routes"]
assert len(routes) == 1
assert routes[0]["name"] == "Route 1"
async def test_game_without_version_group_returns_400(
self, client: AsyncClient, game: dict
):
response = await client.post(
f"{BASE}/{game['id']}/routes",
json={"name": "Route 1", "order": 1},
)
assert response.status_code == 400
async def test_list_routes_excludes_routes_without_encounters(
self, client: AsyncClient, game_with_vg: tuple
):
"""list_game_routes only returns routes that have Pokemon encounters."""
game_id, _ = game_with_vg
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
)
response = await client.get(f"{BASE}/{game_id}/routes?flat=true")
assert response.status_code == 200
assert response.json() == []
# ---------------------------------------------------------------------------
# Routes — update
# ---------------------------------------------------------------------------
class TestUpdateRoute:
async def test_updates_route_name(self, client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg
r = (
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
)
).json()
response = await client.put(
f"{BASE}/{game_id}/routes/{r['id']}",
json={"name": "New Name"},
)
assert response.status_code == 200
assert response.json()["name"] == "New Name"
async def test_route_not_found_returns_404(
self, client: AsyncClient, game_with_vg: tuple
):
game_id, _ = game_with_vg
assert (
await client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
).status_code == 404
# ---------------------------------------------------------------------------
# Routes — delete
# ---------------------------------------------------------------------------
class TestDeleteRoute:
async def test_deletes_route(self, client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg
r = (
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
)
).json()
assert (
await client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
).status_code == 204
# No longer in game detail
detail = (await client.get(f"{BASE}/{game_id}")).json()
assert all(route["id"] != r["id"] for route in detail["routes"])
async def test_route_not_found_returns_404(
self, client: AsyncClient, game_with_vg: tuple
):
game_id, _ = game_with_vg
assert (await client.delete(f"{BASE}/{game_id}/routes/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Routes — reorder
# ---------------------------------------------------------------------------
class TestReorderRoutes:
async def test_reorders_routes(self, client: AsyncClient, game_with_vg: tuple):
game_id, _ = game_with_vg
r1 = (
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
)
).json()
r2 = (
await client.post(
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
)
).json()
response = await client.put(
f"{BASE}/{game_id}/routes/reorder",
json={
"routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}]
},
)
assert response.status_code == 200
by_id = {r["id"]: r["order"] for r in response.json()}
assert by_id[r1["id"]] == 2
assert by_id[r2["id"]] == 1

View File

@@ -0,0 +1,594 @@
"""Integration tests for the Genlockes & Bosses API."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.game import Game
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.version_group import VersionGroup
GENLOCKES_BASE = "/api/v1/genlockes"
RUNS_BASE = "/api/v1/runs"
GAMES_BASE = "/api/v1/games"
STATS_BASE = "/api/v1/stats"
EXPORT_BASE = "/api/v1/export"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def games_ctx(db_session: AsyncSession) -> dict:
"""Two games with version groups."""
vg1 = VersionGroup(name="GT VG1", slug="gt-vg1")
vg2 = VersionGroup(name="GT VG2", slug="gt-vg2")
db_session.add_all([vg1, vg2])
await db_session.flush()
game1 = Game(
name="GT Game 1",
slug="gt-game-1",
generation=1,
region="kanto",
version_group_id=vg1.id,
)
game2 = Game(
name="GT Game 2",
slug="gt-game-2",
generation=2,
region="johto",
version_group_id=vg2.id,
)
db_session.add_all([game1, game2])
await db_session.commit()
return {
"game1_id": game1.id,
"game2_id": game2.id,
"vg1_id": vg1.id,
"vg2_id": vg2.id,
}
@pytest.fixture
async def ctx(db_session: AsyncSession, client: AsyncClient, games_ctx: dict) -> dict:
"""Full context: routes + pokemon + genlocke + encounter for advance/transfer tests."""
route1 = Route(name="GT Route 1", version_group_id=games_ctx["vg1_id"], order=1)
route2 = Route(name="GT Route 2", version_group_id=games_ctx["vg2_id"], order=1)
db_session.add_all([route1, route2])
pikachu = Pokemon(
pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"]
)
db_session.add(pikachu)
await db_session.commit()
r = await client.post(
GENLOCKES_BASE,
json={
"name": "Test Genlocke",
"gameIds": [games_ctx["game1_id"], games_ctx["game2_id"]],
},
)
assert r.status_code == 201
genlocke = r.json()
leg1 = next(leg for leg in genlocke["legs"] if leg["legOrder"] == 1)
run_id = leg1["runId"]
enc_r = await client.post(
f"{RUNS_BASE}/{run_id}/encounters",
json={"routeId": route1.id, "pokemonId": pikachu.id, "status": "caught"},
)
assert enc_r.status_code == 201
return {
**games_ctx,
"route1_id": route1.id,
"route2_id": route2.id,
"pikachu_id": pikachu.id,
"genlocke_id": genlocke["id"],
"run_id": run_id,
"encounter_id": enc_r.json()["id"],
"genlocke": genlocke,
}
# ---------------------------------------------------------------------------
# Genlockes — list
# ---------------------------------------------------------------------------
class TestListGenlockes:
async def test_empty_returns_empty_list(self, client: AsyncClient):
response = await client.get(GENLOCKES_BASE)
assert response.status_code == 200
assert response.json() == []
async def test_returns_created_genlocke(self, client: AsyncClient, ctx: dict):
response = await client.get(GENLOCKES_BASE)
assert response.status_code == 200
names = [g["name"] for g in response.json()]
assert "Test Genlocke" in names
# ---------------------------------------------------------------------------
# Genlockes — create
# ---------------------------------------------------------------------------
class TestCreateGenlocke:
async def test_creates_with_legs_and_first_run(
self, client: AsyncClient, games_ctx: dict
):
response = await client.post(
GENLOCKES_BASE,
json={
"name": "My Genlocke",
"gameIds": [games_ctx["game1_id"], games_ctx["game2_id"]],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "My Genlocke"
assert data["status"] == "active"
assert len(data["legs"]) == 2
# Leg 1 should already have a run linked
leg1 = next(leg for leg in data["legs"] if leg["legOrder"] == 1)
assert leg1["runId"] is not None
# Leg 2 should not yet have a run
leg2 = next(leg for leg in data["legs"] if leg["legOrder"] == 2)
assert leg2["runId"] is None
async def test_empty_game_ids_returns_400(self, client: AsyncClient):
response = await client.post(
GENLOCKES_BASE, json={"name": "Bad", "gameIds": []}
)
assert response.status_code == 400
async def test_invalid_game_id_returns_404(self, client: AsyncClient):
response = await client.post(
GENLOCKES_BASE, json={"name": "Bad", "gameIds": [9999]}
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Genlockes — get
# ---------------------------------------------------------------------------
class TestGetGenlocke:
async def test_returns_genlocke_with_legs_and_stats(
self, client: AsyncClient, ctx: dict
):
response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}")
assert response.status_code == 200
data = response.json()
assert data["id"] == ctx["genlocke_id"]
assert len(data["legs"]) == 2
assert "stats" in data
assert data["stats"]["totalLegs"] == 2
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{GENLOCKES_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Genlockes — update / delete
# ---------------------------------------------------------------------------
class TestUpdateGenlocke:
async def test_updates_name(self, client: AsyncClient, ctx: dict):
response = await client.patch(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}", json={"name": "Renamed"}
)
assert response.status_code == 200
assert response.json()["name"] == "Renamed"
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.patch(f"{GENLOCKES_BASE}/9999", json={"name": "x"})
).status_code == 404
class TestDeleteGenlocke:
async def test_deletes_genlocke(self, client: AsyncClient, ctx: dict):
assert (
await client.delete(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}")
).status_code == 204
assert (
await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}")
).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{GENLOCKES_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Genlockes — legs (add / remove)
# ---------------------------------------------------------------------------
class TestGenlockeLegs:
async def test_adds_leg(self, client: AsyncClient, ctx: dict):
response = await client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs",
json={"gameId": ctx["game1_id"]},
)
assert response.status_code == 201
legs = response.json()["legs"]
assert len(legs) == 3 # was 2, now 3
async def test_remove_leg_without_run(self, client: AsyncClient, ctx: dict):
# Leg 2 has no run yet — can be removed
leg2 = next(leg for leg in ctx["genlocke"]["legs"] if leg["legOrder"] == 2)
response = await client.delete(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/{leg2['id']}"
)
assert response.status_code == 204
async def test_remove_leg_with_run_returns_400(
self, client: AsyncClient, ctx: dict
):
# Leg 1 has a run — cannot remove
leg1 = next(leg for leg in ctx["genlocke"]["legs"] if leg["legOrder"] == 1)
response = await client.delete(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/{leg1['id']}"
)
assert response.status_code == 400
async def test_add_leg_invalid_game_returns_404(
self, client: AsyncClient, ctx: dict
):
response = await client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs",
json={"gameId": 9999},
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Genlockes — advance leg
# ---------------------------------------------------------------------------
class TestAdvanceLeg:
async def test_uncompleted_run_returns_400(self, client: AsyncClient, ctx: dict):
"""Cannot advance when leg 1's run is still active."""
response = await client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
)
assert response.status_code == 400
async def test_no_next_leg_returns_400(self, client: AsyncClient, games_ctx: dict):
"""A single-leg genlocke cannot be advanced."""
r = await client.post(
GENLOCKES_BASE,
json={"name": "Single Leg", "gameIds": [games_ctx["game1_id"]]},
)
genlocke = r.json()
run_id = genlocke["legs"][0]["runId"]
await client.patch(f"{RUNS_BASE}/{run_id}", json={"status": "completed"})
response = await client.post(
f"{GENLOCKES_BASE}/{genlocke['id']}/legs/1/advance"
)
assert response.status_code == 400
async def test_advances_to_next_leg(self, client: AsyncClient, ctx: dict):
"""Completing the current run allows advancing to the next leg."""
await client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
response = await client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
)
assert response.status_code == 200
legs = response.json()["legs"]
leg2 = next(leg for leg in legs if leg["legOrder"] == 2)
assert leg2["runId"] is not None
async def test_advances_with_transfers(self, client: AsyncClient, ctx: dict):
"""Advancing with transfer_encounter_ids creates egg encounters in the next leg."""
await client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
response = await client.post(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance",
json={"transferEncounterIds": [ctx["encounter_id"]]},
)
assert response.status_code == 200
legs = response.json()["legs"]
leg2 = next(leg for leg in legs if leg["legOrder"] == 2)
new_run_id = leg2["runId"]
assert new_run_id is not None
# The new run should contain the transferred (egg) encounter
run_detail = (await client.get(f"{RUNS_BASE}/{new_run_id}")).json()
assert len(run_detail["encounters"]) == 1
# ---------------------------------------------------------------------------
# Genlockes — read-only detail endpoints
# ---------------------------------------------------------------------------
class TestGenlockeGraveyard:
async def test_returns_empty_graveyard(self, client: AsyncClient, ctx: dict):
response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard")
assert response.status_code == 200
data = response.json()
assert data["entries"] == []
assert data["totalDeaths"] == 0
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{GENLOCKES_BASE}/9999/graveyard")).status_code == 404
class TestGenlockeLineages:
async def test_returns_empty_lineages(self, client: AsyncClient, ctx: dict):
response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages")
assert response.status_code == 200
data = response.json()
assert data["lineages"] == []
assert data["totalLineages"] == 0
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{GENLOCKES_BASE}/9999/lineages")).status_code == 404
class TestGenlockeRetiredFamilies:
async def test_returns_empty_retired_families(self, client: AsyncClient, ctx: dict):
response = await client.get(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/retired-families"
)
assert response.status_code == 200
data = response.json()
assert data["retired_pokemon_ids"] == []
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.get(f"{GENLOCKES_BASE}/9999/retired-families")
).status_code == 404
class TestLegSurvivors:
async def test_returns_survivors(self, client: AsyncClient, ctx: dict):
"""The one caught encounter in leg 1 shows up as a survivor."""
response = await client.get(
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/survivors"
)
assert response.status_code == 200
assert len(response.json()) == 1
async def test_leg_not_found_returns_404(self, client: AsyncClient, ctx: dict):
assert (
await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors")
).status_code == 404
# ---------------------------------------------------------------------------
# Boss battles — CRUD (game-scoped)
# ---------------------------------------------------------------------------
BOSS_PAYLOAD = {
"name": "Brock",
"bossType": "gym",
"levelCap": 14,
"order": 1,
"location": "Pewter City",
}
class TestBossCRUD:
async def test_empty_list(self, client: AsyncClient, games_ctx: dict):
response = await client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses")
assert response.status_code == 200
assert response.json() == []
async def test_creates_boss(self, client: AsyncClient, games_ctx: dict):
response = await client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Brock"
assert data["levelCap"] == 14
assert data["pokemon"] == []
async def test_updates_boss(self, client: AsyncClient, games_ctx: dict):
boss = (
await client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
)
).json()
response = await client.put(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/{boss['id']}",
json={"levelCap": 20},
)
assert response.status_code == 200
assert response.json()["levelCap"] == 20
async def test_deletes_boss(self, client: AsyncClient, games_ctx: dict):
boss = (
await client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
)
).json()
assert (
await client.delete(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/{boss['id']}"
)
).status_code == 204
assert (
await client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses")
).json() == []
async def test_boss_not_found_returns_404(
self, client: AsyncClient, games_ctx: dict
):
assert (
await client.put(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/9999",
json={"levelCap": 10},
)
).status_code == 404
async def test_invalid_game_returns_404(self, client: AsyncClient):
assert (await client.get(f"{GAMES_BASE}/9999/bosses")).status_code == 404
async def test_game_without_version_group_returns_400(self, client: AsyncClient):
game = (
await client.post(
GAMES_BASE,
json={
"name": "No VG",
"slug": "no-vg",
"generation": 1,
"region": "kanto",
},
)
).json()
assert (
await client.get(f"{GAMES_BASE}/{game['id']}/bosses")
).status_code == 400
# ---------------------------------------------------------------------------
# Boss results — CRUD (run-scoped)
# ---------------------------------------------------------------------------
class TestBossResults:
@pytest.fixture
async def boss_ctx(self, client: AsyncClient, games_ctx: dict) -> dict:
"""A boss battle and a run for boss-result tests."""
boss = (
await client.post(
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD
)
).json()
run = (
await client.post(
RUNS_BASE, json={"gameId": games_ctx["game1_id"], "name": "Boss Run"}
)
).json()
return {"boss_id": boss["id"], "run_id": run["id"]}
async def test_empty_list(self, client: AsyncClient, boss_ctx: dict):
response = await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
assert response.status_code == 200
assert response.json() == []
async def test_creates_boss_result(self, client: AsyncClient, boss_ctx: dict):
response = await client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "won", "attempts": 1},
)
assert response.status_code == 201
data = response.json()
assert data["result"] == "won"
assert data["attempts"] == 1
assert data["completedAt"] is not None
async def test_upserts_existing_result(self, client: AsyncClient, boss_ctx: dict):
"""POSTing the same boss twice updates the result (upsert)."""
await client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "won", "attempts": 1},
)
response = await client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "lost", "attempts": 3},
)
assert response.status_code == 201
assert response.json()["result"] == "lost"
assert response.json()["attempts"] == 3
# Still only one record
all_results = (
await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
).json()
assert len(all_results) == 1
async def test_deletes_boss_result(self, client: AsyncClient, boss_ctx: dict):
result = (
await client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": boss_ctx["boss_id"], "result": "won"},
)
).json()
assert (
await client.delete(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results/{result['id']}"
)
).status_code == 204
assert (
await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
).json() == []
async def test_invalid_run_returns_404(self, client: AsyncClient, boss_ctx: dict):
assert (await client.get(f"{RUNS_BASE}/9999/boss-results")).status_code == 404
async def test_invalid_boss_returns_404(self, client: AsyncClient, boss_ctx: dict):
response = await client.post(
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
json={"bossBattleId": 9999, "result": "won"},
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------
class TestStats:
async def test_returns_stats_structure(self, client: AsyncClient):
response = await client.get(STATS_BASE)
assert response.status_code == 200
data = response.json()
assert data["totalRuns"] == 0
assert data["totalEncounters"] == 0
assert data["topCaughtPokemon"] == []
assert data["typeDistribution"] == []
async def test_reflects_created_data(self, client: AsyncClient, ctx: dict):
"""Stats should reflect the run and encounter created in ctx."""
response = await client.get(STATS_BASE)
assert response.status_code == 200
data = response.json()
assert data["totalRuns"] >= 1
assert data["totalEncounters"] >= 1
assert data["caughtCount"] >= 1
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
class TestExport:
async def test_export_games_returns_list(self, client: AsyncClient):
response = await client.get(f"{EXPORT_BASE}/games")
assert response.status_code == 200
assert isinstance(response.json(), list)
async def test_export_pokemon_returns_list(self, client: AsyncClient):
response = await client.get(f"{EXPORT_BASE}/pokemon")
assert response.status_code == 200
assert isinstance(response.json(), list)
async def test_export_evolutions_returns_list(self, client: AsyncClient):
response = await client.get(f"{EXPORT_BASE}/evolutions")
assert response.status_code == 200
assert isinstance(response.json(), list)
async def test_export_game_routes_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{EXPORT_BASE}/games/9999/routes")).status_code == 404
async def test_export_game_bosses_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{EXPORT_BASE}/games/9999/bosses")).status_code == 404

View File

@@ -0,0 +1,572 @@
"""Integration tests for the Pokemon & Evolutions API."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.encounter import Encounter
from app.models.game import Game
from app.models.nuzlocke_run import NuzlockeRun
from app.models.route import Route
from app.models.version_group import VersionGroup
POKEMON_BASE = "/api/v1/pokemon"
EVO_BASE = "/api/v1/evolutions"
ROUTE_BASE = "/api/v1/routes"
PIKACHU_DATA = {
"pokeapiId": 25,
"nationalDex": 25,
"name": "pikachu",
"types": ["electric"],
}
CHARMANDER_DATA = {
"pokeapiId": 4,
"nationalDex": 4,
"name": "charmander",
"types": ["fire"],
}
@pytest.fixture
async def pikachu(client: AsyncClient) -> dict:
response = await client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert response.status_code == 201
return response.json()
@pytest.fixture
async def charmander(client: AsyncClient) -> dict:
response = await client.post(POKEMON_BASE, json=CHARMANDER_DATA)
assert response.status_code == 201
return response.json()
@pytest.fixture
async def ctx(db_session: AsyncSession, client: AsyncClient) -> dict:
"""Full context: game + route + two pokemon + nuzlocke encounter on pikachu."""
vg = VersionGroup(name="Poke Test VG", slug="poke-test-vg")
db_session.add(vg)
await db_session.flush()
game = Game(
name="Poke Game",
slug="poke-game",
generation=1,
region="kanto",
version_group_id=vg.id,
)
db_session.add(game)
await db_session.flush()
route = Route(name="Poke Route", version_group_id=vg.id, order=1)
db_session.add(route)
await db_session.flush()
r1 = await client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert r1.status_code == 201
pikachu = r1.json()
r2 = await client.post(POKEMON_BASE, json=CHARMANDER_DATA)
assert r2.status_code == 201
charmander = r2.json()
run = NuzlockeRun(game_id=game.id, name="Poke Run", status="active", rules={})
db_session.add(run)
await db_session.flush()
# Nuzlocke encounter on pikachu — prevents pokemon deletion (409)
enc = Encounter(
run_id=run.id,
route_id=route.id,
pokemon_id=pikachu["id"],
status="caught",
)
db_session.add(enc)
await db_session.commit()
return {
"game_id": game.id,
"route_id": route.id,
"pikachu_id": pikachu["id"],
"charmander_id": charmander["id"],
}
# ---------------------------------------------------------------------------
# Pokemon — list
# ---------------------------------------------------------------------------
class TestListPokemon:
async def test_empty_returns_paginated_response(self, client: AsyncClient):
response = await client.get(POKEMON_BASE)
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
async def test_returns_created_pokemon(self, client: AsyncClient, pikachu: dict):
response = await client.get(POKEMON_BASE)
assert response.status_code == 200
names = [p["name"] for p in response.json()["items"]]
assert "pikachu" in names
async def test_search_by_name(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.get(POKEMON_BASE, params={"search": "pika"})
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["name"] == "pikachu"
async def test_filter_by_type(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.get(POKEMON_BASE, params={"type": "electric"})
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["name"] == "pikachu"
async def test_pagination(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.get(POKEMON_BASE, params={"limit": 1, "offset": 0})
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["total"] == 2
# ---------------------------------------------------------------------------
# Pokemon — create
# ---------------------------------------------------------------------------
class TestCreatePokemon:
async def test_creates_pokemon(self, client: AsyncClient):
response = await client.post(POKEMON_BASE, json=PIKACHU_DATA)
assert response.status_code == 201
data = response.json()
assert data["name"] == "pikachu"
assert data["pokeapiId"] == 25
assert data["types"] == ["electric"]
assert isinstance(data["id"], int)
async def test_duplicate_pokeapi_id_returns_409(
self, client: AsyncClient, pikachu: dict
):
response = await client.post(
POKEMON_BASE,
json={**PIKACHU_DATA, "name": "pikachu-copy"},
)
assert response.status_code == 409
async def test_missing_required_returns_422(self, client: AsyncClient):
response = await client.post(POKEMON_BASE, json={"name": "pikachu"})
assert response.status_code == 422
# ---------------------------------------------------------------------------
# Pokemon — get
# ---------------------------------------------------------------------------
class TestGetPokemon:
async def test_returns_pokemon(self, client: AsyncClient, pikachu: dict):
response = await client.get(f"{POKEMON_BASE}/{pikachu['id']}")
assert response.status_code == 200
assert response.json()["name"] == "pikachu"
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{POKEMON_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Pokemon — update
# ---------------------------------------------------------------------------
class TestUpdatePokemon:
async def test_updates_name(self, client: AsyncClient, pikachu: dict):
response = await client.put(
f"{POKEMON_BASE}/{pikachu['id']}", json={"name": "Pikachu"}
)
assert response.status_code == 200
assert response.json()["name"] == "Pikachu"
async def test_duplicate_pokeapi_id_returns_409(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.put(
f"{POKEMON_BASE}/{pikachu['id']}",
json={"pokeapiId": charmander["pokeapiId"]},
)
assert response.status_code == 409
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.put(f"{POKEMON_BASE}/9999", json={"name": "x"})
).status_code == 404
# ---------------------------------------------------------------------------
# Pokemon — delete
# ---------------------------------------------------------------------------
class TestDeletePokemon:
async def test_deletes_pokemon(self, client: AsyncClient, charmander: dict):
assert (
await client.delete(f"{POKEMON_BASE}/{charmander['id']}")
).status_code == 204
assert (
await client.get(f"{POKEMON_BASE}/{charmander['id']}")
).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{POKEMON_BASE}/9999")).status_code == 404
async def test_pokemon_with_encounters_returns_409(
self, client: AsyncClient, ctx: dict
):
"""Pokemon referenced by a nuzlocke encounter cannot be deleted."""
response = await client.delete(f"{POKEMON_BASE}/{ctx['pikachu_id']}")
assert response.status_code == 409
# ---------------------------------------------------------------------------
# Pokemon — families
# ---------------------------------------------------------------------------
class TestPokemonFamilies:
async def test_empty_when_no_evolutions(self, client: AsyncClient):
response = await client.get(f"{POKEMON_BASE}/families")
assert response.status_code == 200
assert response.json()["families"] == []
async def test_returns_family_grouping(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
response = await client.get(f"{POKEMON_BASE}/families")
assert response.status_code == 200
families = response.json()["families"]
assert len(families) == 1
assert set(families[0]) == {pikachu["id"], charmander["id"]}
# ---------------------------------------------------------------------------
# Pokemon — evolution chain
# ---------------------------------------------------------------------------
class TestPokemonEvolutionChain:
async def test_empty_for_unevolved_pokemon(
self, client: AsyncClient, pikachu: dict
):
response = await client.get(f"{POKEMON_BASE}/{pikachu['id']}/evolution-chain")
assert response.status_code == 200
assert response.json() == []
async def test_returns_chain_for_multi_stage(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
response = await client.get(f"{POKEMON_BASE}/{pikachu['id']}/evolution-chain")
assert response.status_code == 200
chain = response.json()
assert len(chain) == 1
assert chain[0]["fromPokemonId"] == pikachu["id"]
assert chain[0]["toPokemonId"] == charmander["id"]
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.get(f"{POKEMON_BASE}/9999/evolution-chain")
).status_code == 404
# ---------------------------------------------------------------------------
# Evolutions — list
# ---------------------------------------------------------------------------
class TestListEvolutions:
async def test_empty_returns_paginated_response(self, client: AsyncClient):
response = await client.get(EVO_BASE)
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
async def test_returns_created_evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
response = await client.get(EVO_BASE)
assert response.status_code == 200
assert response.json()["total"] == 1
async def test_filter_by_trigger(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "use-item",
},
)
hit = await client.get(EVO_BASE, params={"trigger": "use-item"})
assert hit.json()["total"] == 1
miss = await client.get(EVO_BASE, params={"trigger": "level-up"})
assert miss.json()["total"] == 0
# ---------------------------------------------------------------------------
# Evolutions — create
# ---------------------------------------------------------------------------
class TestCreateEvolution:
async def test_creates_evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
):
response = await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
assert response.status_code == 201
data = response.json()
assert data["fromPokemonId"] == pikachu["id"]
assert data["toPokemonId"] == charmander["id"]
assert data["trigger"] == "level-up"
assert data["fromPokemon"]["name"] == "pikachu"
assert data["toPokemon"]["name"] == "charmander"
async def test_invalid_from_pokemon_returns_404(
self, client: AsyncClient, charmander: dict
):
response = await client.post(
EVO_BASE,
json={
"fromPokemonId": 9999,
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
assert response.status_code == 404
async def test_invalid_to_pokemon_returns_404(
self, client: AsyncClient, pikachu: dict
):
response = await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": 9999,
"trigger": "level-up",
},
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Evolutions — update
# ---------------------------------------------------------------------------
class TestUpdateEvolution:
@pytest.fixture
async def evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
) -> dict:
response = await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
return response.json()
async def test_updates_trigger(self, client: AsyncClient, evolution: dict):
response = await client.put(
f"{EVO_BASE}/{evolution['id']}", json={"trigger": "use-item"}
)
assert response.status_code == 200
assert response.json()["trigger"] == "use-item"
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.put(f"{EVO_BASE}/9999", json={"trigger": "level-up"})
).status_code == 404
# ---------------------------------------------------------------------------
# Evolutions — delete
# ---------------------------------------------------------------------------
class TestDeleteEvolution:
@pytest.fixture
async def evolution(
self, client: AsyncClient, pikachu: dict, charmander: dict
) -> dict:
response = await client.post(
EVO_BASE,
json={
"fromPokemonId": pikachu["id"],
"toPokemonId": charmander["id"],
"trigger": "level-up",
},
)
return response.json()
async def test_deletes_evolution(self, client: AsyncClient, evolution: dict):
assert (await client.delete(f"{EVO_BASE}/{evolution['id']}")).status_code == 204
assert (await client.get(EVO_BASE)).json()["total"] == 0
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{EVO_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Route Encounters — list / create / update / delete
# ---------------------------------------------------------------------------
class TestRouteEncounters:
async def test_empty_list_for_route(self, client: AsyncClient, ctx: dict):
response = await client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon")
assert response.status_code == 200
assert response.json() == []
async def test_creates_route_encounter(self, client: AsyncClient, ctx: dict):
response = await client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": ctx["charmander_id"],
"gameId": ctx["game_id"],
"encounterMethod": "grass",
"encounterRate": 10,
"minLevel": 5,
"maxLevel": 10,
},
)
assert response.status_code == 201
data = response.json()
assert data["pokemonId"] == ctx["charmander_id"]
assert data["encounterRate"] == 10
assert data["pokemon"]["name"] == "charmander"
async def test_invalid_route_returns_404(self, client: AsyncClient, ctx: dict):
response = await client.post(
f"{ROUTE_BASE}/9999/pokemon",
json={
"pokemonId": ctx["charmander_id"],
"gameId": ctx["game_id"],
"encounterMethod": "grass",
"encounterRate": 10,
"minLevel": 5,
"maxLevel": 10,
},
)
assert response.status_code == 404
async def test_invalid_pokemon_returns_404(self, client: AsyncClient, ctx: dict):
response = await client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": 9999,
"gameId": ctx["game_id"],
"encounterMethod": "grass",
"encounterRate": 10,
"minLevel": 5,
"maxLevel": 10,
},
)
assert response.status_code == 404
async def test_updates_route_encounter(self, client: AsyncClient, ctx: dict):
r = await client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": ctx["charmander_id"],
"gameId": ctx["game_id"],
"encounterMethod": "grass",
"encounterRate": 10,
"minLevel": 5,
"maxLevel": 10,
},
)
enc = r.json()
response = await client.put(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}",
json={"encounterRate": 25},
)
assert response.status_code == 200
assert response.json()["encounterRate"] == 25
async def test_update_not_found_returns_404(self, client: AsyncClient, ctx: dict):
assert (
await client.put(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999",
json={"encounterRate": 5},
)
).status_code == 404
async def test_deletes_route_encounter(self, client: AsyncClient, ctx: dict):
r = await client.post(
f"{ROUTE_BASE}/{ctx['route_id']}/pokemon",
json={
"pokemonId": ctx["charmander_id"],
"gameId": ctx["game_id"],
"encounterMethod": "grass",
"encounterRate": 10,
"minLevel": 5,
"maxLevel": 10,
},
)
enc = r.json()
assert (
await client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/{enc['id']}")
).status_code == 204
assert (
await client.get(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon")
).json() == []
async def test_delete_not_found_returns_404(self, client: AsyncClient, ctx: dict):
assert (
await client.delete(f"{ROUTE_BASE}/{ctx['route_id']}/pokemon/9999")
).status_code == 404

454
backend/tests/test_runs.py Normal file
View File

@@ -0,0 +1,454 @@
"""Integration tests for the Runs & Encounters API."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.game import Game
from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.version_group import VersionGroup
RUNS_BASE = "/api/v1/runs"
ENC_BASE = "/api/v1/encounters"
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def game_id(db_session: AsyncSession) -> int:
"""A minimal game (no version_group_id needed for run CRUD)."""
game = Game(name="Test Game", slug="test-game", generation=1, region="kanto")
db_session.add(game)
await db_session.commit()
await db_session.refresh(game)
return game.id
@pytest.fixture
async def run(client: AsyncClient, game_id: int) -> dict:
"""An active run created via the API."""
response = await client.post(RUNS_BASE, json={"gameId": game_id, "name": "My Run"})
assert response.status_code == 201
return response.json()
@pytest.fixture
async def enc_ctx(db_session: AsyncSession) -> dict:
"""Full context for encounter tests: game, run, pokemon, standalone and grouped routes."""
vg = VersionGroup(name="Enc Test VG", slug="enc-test-vg")
db_session.add(vg)
await db_session.flush()
game = Game(
name="Enc Game",
slug="enc-game",
generation=1,
region="kanto",
version_group_id=vg.id,
)
db_session.add(game)
await db_session.flush()
pikachu = Pokemon(
pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"]
)
charmander = Pokemon(
pokeapi_id=4, national_dex=4, name="charmander", types=["fire"]
)
db_session.add_all([pikachu, charmander])
await db_session.flush()
# A standalone route (no parent — no route-lock applies)
standalone = Route(name="Standalone Route", version_group_id=vg.id, order=1)
# A parent route with two children (route-lock applies to children)
parent = Route(name="Route Group", version_group_id=vg.id, order=2)
db_session.add_all([standalone, parent])
await db_session.flush()
child1 = Route(
name="Child A", version_group_id=vg.id, order=1, parent_route_id=parent.id
)
child2 = Route(
name="Child B", version_group_id=vg.id, order=2, parent_route_id=parent.id
)
db_session.add_all([child1, child2])
await db_session.flush()
run = NuzlockeRun(
game_id=game.id,
name="Enc Run",
status="active",
rules={"shinyClause": True, "giftClause": False},
)
db_session.add(run)
await db_session.commit()
for obj in [standalone, parent, child1, child2, pikachu, charmander, run]:
await db_session.refresh(obj)
return {
"run_id": run.id,
"game_id": game.id,
"pikachu_id": pikachu.id,
"charmander_id": charmander.id,
"standalone_id": standalone.id,
"parent_id": parent.id,
"child1_id": child1.id,
"child2_id": child2.id,
}
# ---------------------------------------------------------------------------
# Runs — list
# ---------------------------------------------------------------------------
class TestListRuns:
async def test_empty_returns_empty_list(self, client: AsyncClient):
response = await client.get(RUNS_BASE)
assert response.status_code == 200
assert response.json() == []
async def test_returns_created_run(self, client: AsyncClient, run: dict):
response = await client.get(RUNS_BASE)
assert response.status_code == 200
ids = [r["id"] for r in response.json()]
assert run["id"] in ids
# ---------------------------------------------------------------------------
# Runs — create
# ---------------------------------------------------------------------------
class TestCreateRun:
async def test_creates_active_run(self, client: AsyncClient, game_id: int):
response = await client.post(
RUNS_BASE, json={"gameId": game_id, "name": "New Run"}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "New Run"
assert data["status"] == "active"
assert data["gameId"] == game_id
assert isinstance(data["id"], int)
async def test_rules_stored(self, client: AsyncClient, game_id: int):
rules = {"duplicatesClause": True, "shinyClause": False}
response = await client.post(
RUNS_BASE, json={"gameId": game_id, "name": "Run", "rules": rules}
)
assert response.status_code == 201
assert response.json()["rules"]["duplicatesClause"] is True
async def test_invalid_game_returns_404(self, client: AsyncClient):
response = await client.post(RUNS_BASE, json={"gameId": 9999, "name": "Run"})
assert response.status_code == 404
async def test_missing_required_returns_422(self, client: AsyncClient):
response = await client.post(RUNS_BASE, json={"name": "Run"})
assert response.status_code == 422
# ---------------------------------------------------------------------------
# Runs — get
# ---------------------------------------------------------------------------
class TestGetRun:
async def test_returns_run_with_game_and_encounters(
self, client: AsyncClient, run: dict
):
response = await client.get(f"{RUNS_BASE}/{run['id']}")
assert response.status_code == 200
data = response.json()
assert data["id"] == run["id"]
assert "game" in data
assert data["encounters"] == []
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.get(f"{RUNS_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Runs — update
# ---------------------------------------------------------------------------
class TestUpdateRun:
async def test_updates_name(self, client: AsyncClient, run: dict):
response = await client.patch(
f"{RUNS_BASE}/{run['id']}", json={"name": "Renamed"}
)
assert response.status_code == 200
assert response.json()["name"] == "Renamed"
async def test_complete_run_sets_completed_at(self, client: AsyncClient, run: dict):
response = await client.patch(
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "completed"
assert data["completedAt"] is not None
async def test_fail_run(self, client: AsyncClient, run: dict):
response = await client.patch(
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
)
assert response.status_code == 200
assert response.json()["status"] == "failed"
async def test_ending_already_ended_run_returns_400(
self, client: AsyncClient, run: dict
):
await client.patch(f"{RUNS_BASE}/{run['id']}", json={"status": "completed"})
response = await client.patch(
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
)
assert response.status_code == 400
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
).status_code == 404
# ---------------------------------------------------------------------------
# Runs — delete
# ---------------------------------------------------------------------------
class TestDeleteRun:
async def test_deletes_run(self, client: AsyncClient, run: dict):
assert (await client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
assert (await client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{RUNS_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
# Encounters — create
# ---------------------------------------------------------------------------
class TestCreateEncounter:
async def test_creates_encounter(self, client: AsyncClient, enc_ctx: dict):
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
assert response.status_code == 201
data = response.json()
assert data["runId"] == enc_ctx["run_id"]
assert data["pokemonId"] == enc_ctx["pikachu_id"]
assert data["status"] == "caught"
assert data["isShiny"] is False
async def test_invalid_run_returns_404(self, client: AsyncClient, enc_ctx: dict):
response = await client.post(
f"{RUNS_BASE}/9999/encounters",
json={
"routeId": enc_ctx["standalone_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
assert response.status_code == 404
async def test_invalid_route_returns_404(self, client: AsyncClient, enc_ctx: dict):
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": 9999,
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
assert response.status_code == 404
async def test_invalid_pokemon_returns_404(
self, client: AsyncClient, enc_ctx: dict
):
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
"pokemonId": 9999,
"status": "caught",
},
)
assert response.status_code == 404
async def test_parent_route_rejected_400(self, client: AsyncClient, enc_ctx: dict):
"""Cannot create an encounter directly on a parent route (use child routes)."""
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["parent_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
assert response.status_code == 400
async def test_route_lock_prevents_second_sibling_encounter(
self, client: AsyncClient, enc_ctx: dict
):
"""Once a sibling child has an encounter, other siblings in the group return 409."""
await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child1_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child2_id"],
"pokemonId": enc_ctx["charmander_id"],
"status": "caught",
},
)
assert response.status_code == 409
async def test_shiny_bypasses_route_lock(
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
):
"""A shiny encounter bypasses the route-lock when shinyClause is enabled."""
# First encounter occupies the group
await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child1_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
# Shiny encounter on sibling should succeed
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child2_id"],
"pokemonId": enc_ctx["charmander_id"],
"status": "caught",
"isShiny": True,
},
)
assert response.status_code == 201
assert response.json()["isShiny"] is True
async def test_gift_bypasses_route_lock_when_clause_on(
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
):
"""A gift encounter bypasses route-lock when giftClause is enabled."""
# Enable giftClause on the run
run = await db_session.get(NuzlockeRun, enc_ctx["run_id"])
run.rules = {"shinyClause": True, "giftClause": True}
await db_session.commit()
await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child1_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child2_id"],
"pokemonId": enc_ctx["charmander_id"],
"status": "caught",
"origin": "gift",
},
)
assert response.status_code == 201
assert response.json()["origin"] == "gift"
# ---------------------------------------------------------------------------
# Encounters — update
# ---------------------------------------------------------------------------
class TestUpdateEncounter:
@pytest.fixture
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
return response.json()
async def test_updates_nickname(self, client: AsyncClient, encounter: dict):
response = await client.patch(
f"{ENC_BASE}/{encounter['id']}", json={"nickname": "Sparky"}
)
assert response.status_code == 200
assert response.json()["nickname"] == "Sparky"
async def test_updates_status_to_fainted(
self, client: AsyncClient, encounter: dict
):
response = await client.patch(
f"{ENC_BASE}/{encounter['id']}",
json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "fainted"
assert data["faintLevel"] == 12
assert data["deathCause"] == "wild battle"
async def test_not_found_returns_404(self, client: AsyncClient):
assert (
await client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
).status_code == 404
# ---------------------------------------------------------------------------
# Encounters — delete
# ---------------------------------------------------------------------------
class TestDeleteEncounter:
@pytest.fixture
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
response = await client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
"pokemonId": enc_ctx["pikachu_id"],
"status": "caught",
},
)
return response.json()
async def test_deletes_encounter(
self, client: AsyncClient, encounter: dict, enc_ctx: dict
):
assert (await client.delete(f"{ENC_BASE}/{encounter['id']}")).status_code == 204
# Run detail should no longer include it
detail = (await client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json()
assert all(e["id"] != encounter["id"] for e in detail["encounters"])
async def test_not_found_returns_404(self, client: AsyncClient):
assert (await client.delete(f"{ENC_BASE}/9999")).status_code == 404

View File

@@ -0,0 +1,306 @@
"""Unit tests for Pydantic schemas."""
import pytest
from pydantic import ValidationError
from app.schemas.base import CamelModel
from app.schemas.boss import BossReorderItem, BossReorderRequest, BossResultCreate
from app.schemas.encounter import EncounterCreate, EncounterUpdate
from app.schemas.game import (
GameCreate,
GameUpdate,
RouteReorderItem,
RouteReorderRequest,
)
from app.schemas.genlocke import GenlockeCreate
from app.schemas.pokemon import EvolutionCreate, PokemonCreate
from app.schemas.run import RunCreate, RunUpdate
class TestCamelModel:
def test_snake_case_field_name_accepted(self):
class M(CamelModel):
game_id: int
assert M(game_id=1).game_id == 1
def test_camel_case_alias_accepted(self):
class M(CamelModel):
game_id: int
assert M(**{"gameId": 1}).game_id == 1
def test_serializes_to_camel_case(self):
class M(CamelModel):
game_id: int
is_shiny: bool
data = M(game_id=1, is_shiny=True).model_dump(by_alias=True)
assert data == {"gameId": 1, "isShiny": True}
def test_snake_case_not_in_serialized_output(self):
class M(CamelModel):
version_group_id: int
data = M(version_group_id=5).model_dump(by_alias=True)
assert "version_group_id" not in data
assert "versionGroupId" in data
def test_from_attributes(self):
class FakeOrm:
game_id = 42
class M(CamelModel):
game_id: int
assert M.model_validate(FakeOrm()).game_id == 42
class TestRunCreate:
def test_valid_minimum(self):
run = RunCreate(game_id=1, name="Nuzlocke #1")
assert run.game_id == 1
assert run.name == "Nuzlocke #1"
assert run.rules == {}
assert run.naming_scheme is None
def test_camel_case_input(self):
run = RunCreate(**{"gameId": 5, "name": "Run"})
assert run.game_id == 5
def test_missing_game_id_raises(self):
with pytest.raises(ValidationError):
RunCreate(name="Run")
def test_missing_name_raises(self):
with pytest.raises(ValidationError):
RunCreate(game_id=1)
def test_rules_accepts_arbitrary_data(self):
run = RunCreate(game_id=1, name="x", rules={"duplicatesClause": True})
assert run.rules["duplicatesClause"] is True
def test_naming_scheme_accepted(self):
run = RunCreate(game_id=1, name="x", naming_scheme="nature")
assert run.naming_scheme == "nature"
class TestRunUpdate:
def test_all_fields_optional(self):
update = RunUpdate()
assert update.name is None
assert update.status is None
assert update.rules is None
assert update.naming_scheme is None
def test_partial_update(self):
update = RunUpdate(name="New Name")
assert update.name == "New Name"
assert update.status is None
def test_hof_encounter_ids(self):
update = RunUpdate(hof_encounter_ids=[1, 2, 3])
assert update.hof_encounter_ids == [1, 2, 3]
class TestGameCreate:
def test_valid_minimum(self):
game = GameCreate(name="Pokemon Red", slug="red", generation=1, region="Kanto")
assert game.name == "Pokemon Red"
assert game.slug == "red"
assert game.generation == 1
assert game.region == "Kanto"
def test_optional_fields_default_none(self):
game = GameCreate(name="Pokemon Red", slug="red", generation=1, region="Kanto")
assert game.category is None
assert game.box_art_url is None
assert game.release_year is None
assert game.color is None
def test_missing_required_field_raises(self):
with pytest.raises(ValidationError):
GameCreate(name="Pokemon Red", slug="red", generation=1) # missing region
def test_camel_case_input(self):
game = GameCreate(
**{
"name": "Gold",
"slug": "gold",
"generation": 2,
"region": "Johto",
"boxArtUrl": "/art.png",
}
)
assert game.box_art_url == "/art.png"
class TestGameUpdate:
def test_all_fields_optional(self):
assert GameUpdate().name is None
def test_partial_update(self):
update = GameUpdate(name="New Name", generation=3)
assert update.name == "New Name"
assert update.generation == 3
assert update.region is None
class TestEncounterCreate:
def test_valid_minimum(self):
enc = EncounterCreate(route_id=1, pokemon_id=25, status="caught")
assert enc.route_id == 1
assert enc.pokemon_id == 25
assert enc.status == "caught"
assert enc.is_shiny is False
assert enc.nickname is None
def test_camel_case_input(self):
enc = EncounterCreate(
**{"routeId": 1, "pokemonId": 25, "status": "caught", "isShiny": True}
)
assert enc.route_id == 1
assert enc.is_shiny is True
def test_missing_pokemon_id_raises(self):
with pytest.raises(ValidationError):
EncounterCreate(route_id=1, status="caught")
def test_missing_status_raises(self):
with pytest.raises(ValidationError):
EncounterCreate(route_id=1, pokemon_id=25)
def test_origin_accepted(self):
enc = EncounterCreate(route_id=1, pokemon_id=1, status="caught", origin="gift")
assert enc.origin == "gift"
class TestEncounterUpdate:
def test_all_fields_optional(self):
update = EncounterUpdate()
assert update.nickname is None
assert update.status is None
assert update.faint_level is None
assert update.death_cause is None
assert update.current_pokemon_id is None
class TestBossResultCreate:
def test_valid_minimum(self):
result = BossResultCreate(boss_battle_id=1, result="win")
assert result.boss_battle_id == 1
assert result.result == "win"
assert result.attempts == 1
def test_attempts_default_one(self):
assert BossResultCreate(boss_battle_id=1, result="loss").attempts == 1
def test_custom_attempts(self):
assert (
BossResultCreate(boss_battle_id=1, result="win", attempts=3).attempts == 3
)
def test_missing_boss_battle_id_raises(self):
with pytest.raises(ValidationError):
BossResultCreate(result="win")
class TestBossReorderRequest:
def test_nested_items_accepted(self):
req = BossReorderRequest(bosses=[BossReorderItem(id=1, order=2)])
assert req.bosses[0].id == 1
assert req.bosses[0].order == 2
def test_dict_input_coerced(self):
req = BossReorderRequest(**{"bosses": [{"id": 3, "order": 1}]})
assert req.bosses[0].id == 3
def test_empty_list_accepted(self):
assert BossReorderRequest(bosses=[]).bosses == []
class TestRouteReorderRequest:
def test_nested_items_accepted(self):
req = RouteReorderRequest(routes=[RouteReorderItem(id=10, order=1)])
assert req.routes[0].id == 10
def test_dict_input_coerced(self):
req = RouteReorderRequest(**{"routes": [{"id": 5, "order": 3}]})
assert req.routes[0].order == 3
class TestGenlockeCreate:
def test_valid_minimum(self):
gc = GenlockeCreate(name="My Genlocke", game_ids=[1, 2, 3])
assert gc.name == "My Genlocke"
assert gc.game_ids == [1, 2, 3]
assert gc.genlocke_rules == {}
assert gc.nuzlocke_rules == {}
assert gc.naming_scheme is None
def test_missing_name_raises(self):
with pytest.raises(ValidationError):
GenlockeCreate(game_ids=[1, 2])
def test_missing_game_ids_raises(self):
with pytest.raises(ValidationError):
GenlockeCreate(name="My Genlocke")
def test_camel_case_input(self):
gc = GenlockeCreate(**{"name": "x", "gameIds": [1], "namingScheme": "types"})
assert gc.naming_scheme == "types"
class TestPokemonCreate:
def test_valid_minimum(self):
p = PokemonCreate(
pokeapi_id=25, national_dex=25, name="Pikachu", types=["electric"]
)
assert p.name == "Pikachu"
assert p.types == ["electric"]
assert p.sprite_url is None
def test_multi_type(self):
p = PokemonCreate(
pokeapi_id=6, national_dex=6, name="Charizard", types=["fire", "flying"]
)
assert p.types == ["fire", "flying"]
def test_missing_required_raises(self):
with pytest.raises(ValidationError):
PokemonCreate(pokeapi_id=1, national_dex=1, name="x") # missing types
class TestEvolutionCreate:
def test_valid_minimum(self):
evo = EvolutionCreate(from_pokemon_id=1, to_pokemon_id=2, trigger="level-up")
assert evo.from_pokemon_id == 1
assert evo.to_pokemon_id == 2
assert evo.trigger == "level-up"
assert evo.min_level is None
assert evo.item is None
def test_all_optional_fields(self):
evo = EvolutionCreate(
from_pokemon_id=1,
to_pokemon_id=2,
trigger="use-item",
min_level=16,
item="fire-stone",
held_item=None,
condition="day",
region="Kanto",
)
assert evo.min_level == 16
assert evo.item == "fire-stone"
assert evo.region == "Kanto"
def test_missing_trigger_raises(self):
with pytest.raises(ValidationError):
EvolutionCreate(from_pokemon_id=1, to_pokemon_id=2)
def test_camel_case_input(self):
evo = EvolutionCreate(
**{"fromPokemonId": 1, "toPokemonId": 2, "trigger": "level-up"}
)
assert evo.from_pokemon_id == 1

View File

@@ -0,0 +1,174 @@
"""Unit tests for the services layer (families, naming utilities)."""
import pytest
from app.services.families import build_families, resolve_base_form
from app.services.naming import strip_roman_suffix, to_roman
# ---------------------------------------------------------------------------
# Minimal Evolution stand-in — only the two fields the services touch
# ---------------------------------------------------------------------------
class Evo:
"""Lightweight stand-in for app.models.evolution.Evolution."""
def __init__(self, from_id: int, to_id: int) -> None:
self.from_pokemon_id = from_id
self.to_pokemon_id = to_id
# ---------------------------------------------------------------------------
# build_families
# ---------------------------------------------------------------------------
class TestBuildFamilies:
def test_empty_evolutions_returns_empty_dict(self):
assert build_families([]) == {}
def test_linear_chain(self):
# A(1) → B(2) → C(3)
evos = [Evo(1, 2), Evo(2, 3)]
families = build_families(evos)
assert set(families[1]) == {1, 2, 3}
assert set(families[2]) == {1, 2, 3}
assert set(families[3]) == {1, 2, 3}
def test_branching_evolutions(self):
# Eevee-like: 1 → 2, 1 → 3, 1 → 4
evos = [Evo(1, 2), Evo(1, 3), Evo(1, 4)]
families = build_families(evos)
assert set(families[1]) == {1, 2, 3, 4}
assert set(families[2]) == {1, 2, 3, 4}
assert set(families[4]) == {1, 2, 3, 4}
def test_disjoint_chains_are_separate_families(self):
# Chain 1→2 and independent chain 3→4
evos = [Evo(1, 2), Evo(3, 4)]
families = build_families(evos)
assert set(families[1]) == {1, 2}
assert set(families[3]) == {3, 4}
assert 3 not in set(families[1])
assert 1 not in set(families[3])
def test_shedinja_case(self):
# Nincada(1) → Ninjask(2) and Nincada(1) → Shedinja(3)
evos = [Evo(1, 2), Evo(1, 3)]
families = build_families(evos)
assert set(families[1]) == {1, 2, 3}
assert set(families[3]) == {1, 2, 3}
def test_pokemon_not_in_any_evolution_not_in_result(self):
evos = [Evo(1, 2)]
families = build_families(evos)
assert 99 not in families
def test_all_family_members_have_identical_family_list(self):
evos = [Evo(10, 11), Evo(11, 12)]
families = build_families(evos)
assert set(families[10]) == set(families[11]) == set(families[12])
# ---------------------------------------------------------------------------
# resolve_base_form
# ---------------------------------------------------------------------------
class TestResolveBaseForm:
def test_pokemon_not_in_any_evolution_returns_itself(self):
assert resolve_base_form(99, []) == 99
def test_base_form_returns_itself(self):
# A(1) → B(2): base of 1 is still 1
evos = [Evo(1, 2)]
assert resolve_base_form(1, evos) == 1
def test_final_form_returns_base(self):
# A(1) → B(2) → C(3): base of 3 is 1
evos = [Evo(1, 2), Evo(2, 3)]
assert resolve_base_form(3, evos) == 1
def test_middle_form_returns_base(self):
# A(1) → B(2) → C(3): base of 2 is 1
evos = [Evo(1, 2), Evo(2, 3)]
assert resolve_base_form(2, evos) == 1
def test_branching_evolution_base(self):
# 1 → 2, 1 → 3: base of both 2 and 3 is 1
evos = [Evo(1, 2), Evo(1, 3)]
assert resolve_base_form(2, evos) == 1
assert resolve_base_form(3, evos) == 1
def test_shedinja_resolves_to_nincada(self):
# Nincada(1) → Ninjask(2), Nincada(1) → Shedinja(3)
evos = [Evo(1, 2), Evo(1, 3)]
assert resolve_base_form(3, evos) == 1
def test_empty_evolutions_returns_self(self):
assert resolve_base_form(42, []) == 42
# ---------------------------------------------------------------------------
# to_roman
# ---------------------------------------------------------------------------
class TestToRoman:
@pytest.mark.parametrize(
"n, expected",
[
(1, "I"),
(2, "II"),
(3, "III"),
(4, "IV"),
(5, "V"),
(6, "VI"),
(9, "IX"),
(10, "X"),
(11, "XI"),
(14, "XIV"),
(40, "XL"),
(50, "L"),
(90, "XC"),
(100, "C"),
],
)
def test_converts_integer_to_roman(self, n: int, expected: str):
assert to_roman(n) == expected
def test_typical_genlocke_sequence(self):
# Lineage names: Heracles I, II, III, IV, V
assert [to_roman(i) for i in range(1, 6)] == ["I", "II", "III", "IV", "V"]
# ---------------------------------------------------------------------------
# strip_roman_suffix
# ---------------------------------------------------------------------------
class TestStripRomanSuffix:
def test_strips_roman_numeral_ii(self):
assert strip_roman_suffix("Heracles II") == "Heracles"
def test_strips_roman_numeral_iii(self):
assert strip_roman_suffix("Athena III") == "Athena"
def test_strips_roman_numeral_iv(self):
assert strip_roman_suffix("Nova IV") == "Nova"
def test_strips_roman_numeral_x(self):
assert strip_roman_suffix("Zeus X") == "Zeus"
def test_no_suffix_returns_unchanged(self):
assert strip_roman_suffix("Apollo") == "Apollo"
def test_name_with_i_suffix(self):
# Single "I" at end is a valid roman numeral suffix
assert strip_roman_suffix("Heracles I") == "Heracles"
def test_round_trip_with_to_roman(self):
base = "Heracles"
for n in range(1, 6):
suffixed = f"{base} {to_roman(n)}"
assert strip_roman_suffix(suffixed) == base

View File

@@ -0,0 +1,31 @@
"""Smoke tests that verify the test infrastructure is working correctly."""
async def test_games_endpoint_returns_empty_list(client):
"""Games endpoint returns an empty list on a clean database."""
response = await client.get("/api/v1/games")
assert response.status_code == 200
assert response.json() == []
async def test_runs_endpoint_returns_empty_list(client):
"""Runs endpoint returns an empty list on a clean database."""
response = await client.get("/api/v1/runs")
assert response.status_code == 200
assert response.json() == []
async def test_pokemon_endpoint_returns_empty_list(client):
"""Pokemon endpoint returns paginated empty result on a clean database."""
response = await client.get("/api/v1/pokemon")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
async def test_database_isolation_between_tests(client):
"""Confirm state from previous tests does not leak into this one."""
response = await client.get("/api/v1/games")
assert response.status_code == 200
assert response.json() == []

670
backend/uv.lock generated Normal file
View File

@@ -0,0 +1,670 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "alembic"
version = "1.18.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/41/ab8f624929847b49f84955c594b165855efd829b0c271e1a8cac694138e5/alembic-1.18.3.tar.gz", hash = "sha256:1212aa3778626f2b0f0aa6dd4e99a5f99b94bd25a0c1ac0bba3be65e081e50b0", size = 2052564, upload-time = "2026-01-29T20:24:15.124Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/8e/d79281f323e7469b060f15bd229e48d7cdd219559e67e71c013720a88340/alembic-1.18.3-py3-none-any.whl", hash = "sha256:12a0359bfc068a4ecbb9b3b02cf77856033abfdb59e4a5aca08b7eacd7b74ddd", size = 262282, upload-time = "2026-01-29T20:24:17.488Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "another-nuzlocke-tracker-api"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "ty" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = "==1.18.3" },
{ name = "asyncpg", specifier = "==0.31.0" },
{ name = "fastapi", specifier = "==0.128.4" },
{ name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" },
{ name = "pydantic", specifier = "==2.12.5" },
{ name = "pydantic-settings", specifier = "==2.12.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
{ name = "python-dotenv", specifier = "==1.2.1" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.46" },
{ name = "ty", marker = "extra == 'dev'", specifier = "==0.0.17" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.40.0" },
]
provides-extras = ["dev"]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.128.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/b7/21bf3d694cbff0b7cf5f459981d996c2c15e072bd5ca5609806383947f1e/fastapi-0.128.4.tar.gz", hash = "sha256:d6a2cc4c0edfbb2499f3fdec55ba62e751ee58a6354c50f85ed0dabdfbcfeb60", size = 375898, upload-time = "2026-02-07T08:14:09.616Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/8b/c8050e556f5d7a1f33a93c2c94379a0bae23c58a79ad9709d7e052d0c3b8/fastapi-0.128.4-py3-none-any.whl", hash = "sha256:9321282cee605fd2075ccbc95c0f2e549d675c59de4a952bba202cd1730ac66b", size = 103684, upload-time = "2026-02-07T08:14:07.939Z" },
]
[[package]]
name = "greenlet"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" },
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.46"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" },
{ url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" },
{ url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" },
{ url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" },
{ url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" },
{ url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" },
{ url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" },
{ url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" },
]
[package.optional-dependencies]
asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "starlette"
version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
name = "ty"
version = "0.0.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" },
{ url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" },
{ url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" },
{ url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" },
{ url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" },
{ url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" },
{ url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" },
{ url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" },
{ url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" },
{ url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" },
{ url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]

36
docker-compose.test.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
test-db:
image: postgres:16-alpine
ports:
- "5433:5432"
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=nuzlocke_test
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
timeout: 5s
retries: 10
restart: "no"
test-api:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8100:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@test-db:5432/nuzlocke_test
- DEBUG=true
depends_on:
test-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/ || exit 1"]
interval: 3s
timeout: 5s
retries: 15
restart: "no"

6
frontend/.gitignore vendored
View File

@@ -12,6 +12,12 @@ dist
dist-ssr dist-ssr
*.local *.local
# Playwright
e2e/.fixtures.json
e2e/screenshots/
test-results/
playwright-report/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

View File

@@ -0,0 +1,61 @@
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import { loadFixtures } from './fixtures'
const fixtures = loadFixtures()
const pages = [
{ name: 'Home', path: '/' },
{ name: 'RunList', path: '/runs' },
{ name: 'NewRun', path: '/runs/new' },
{ name: 'RunEncounters', path: `/runs/${fixtures.runId}` },
{ name: 'GenlockeList', path: '/genlockes' },
{ name: 'NewGenlocke', path: '/genlockes/new' },
{ name: 'GenlockeDetail', path: `/genlockes/${fixtures.genlockeId}` },
{ name: 'Stats', path: '/stats' },
{ name: 'AdminGames', path: '/admin/games' },
{ name: 'AdminPokemon', path: '/admin/pokemon' },
{ name: 'AdminEvolutions', path: '/admin/evolutions' },
]
const themes = ['dark', 'light'] as const
for (const theme of themes) {
test.describe(`Accessibility — ${theme} mode`, () => {
test.use({
storageState: undefined,
})
for (const { name, path } of pages) {
test(`${name} (${path}) has no WCAG violations`, async ({ page }) => {
// Set theme before navigation
await page.addInitScript((t) => {
localStorage.setItem('ant-theme', t)
}, theme)
await page.goto(path, { waitUntil: 'networkidle' })
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
const violations = results.violations.map((v) => ({
id: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.map((n) => ({
html: n.html,
target: n.target,
failureSummary: n.failureSummary,
})),
}))
expect(
violations,
`${name} (${theme}): ${violations.length} violation(s):\n${JSON.stringify(violations, null, 2)}`,
).toHaveLength(0)
})
}
})
}

20
frontend/e2e/fixtures.ts Normal file
View File

@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
interface Fixtures {
gameId: number
runId: number
genlockeId: number
}
let cached: Fixtures | null = null
export function loadFixtures(): Fixtures {
if (cached) return cached
const raw = readFileSync(resolve(__dirname, '.fixtures.json'), 'utf-8')
cached = JSON.parse(raw) as Fixtures
return cached
}

View File

@@ -0,0 +1,124 @@
import { execSync } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const API_HOST = process.env.E2E_API_URL || 'http://localhost:8100'
const API_BASE = `${API_HOST}/api/v1`
const COMPOSE_FILE = resolve(__dirname, '../../docker-compose.test.yml')
const COMPOSE = `docker compose -p nuzlocke-test -f ${COMPOSE_FILE}`
const FIXTURES_PATH = resolve(__dirname, '.fixtures.json')
function run(cmd: string): string {
console.log(`[setup] ${cmd}`)
return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'inherit'] })
}
async function waitForApi(url: string, maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
try {
const res = await fetch(url)
if (res.ok) return
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 2000))
}
throw new Error(`API at ${url} not ready after ${maxAttempts} attempts`)
}
async function api<T>(
path: string,
options?: RequestInit,
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`API ${options?.method ?? 'GET'} ${path}${res.status}: ${body}`)
}
return res.json() as Promise<T>
}
export default async function globalSetup() {
// 1. Start test DB + API
run(`${COMPOSE} up -d --build`)
// 2. Wait for API to be healthy
console.log('[setup] Waiting for API to be ready...')
await waitForApi(`${API_HOST}/`)
// 3. Run migrations
run(`${COMPOSE} exec -T test-api alembic -c /app/alembic.ini upgrade head`)
// 4. Seed reference data (run from /app/src where the app package lives)
run(`${COMPOSE} exec -T -w /app/src test-api python -m app.seeds`)
// 5. Create test fixtures via API
const games = await api<Array<{ id: number; name: string }>>('/games')
const game = games[0]
if (!game) throw new Error('No games found after seeding')
const routes = await api<Array<{ id: number; name: string; parentRouteId: number | null }>>(
`/games/${game.id}/routes?flat=true`,
)
// Pick leaf routes (no children — a route is a leaf if no other route has it as parent)
const parentIds = new Set(routes.map((r) => r.parentRouteId).filter(Boolean))
const leafRoutes = routes.filter((r) => !parentIds.has(r.id))
if (leafRoutes.length < 3) throw new Error(`Need ≥3 leaf routes, found ${leafRoutes.length}`)
const pokemonRes = await api<{ items: Array<{ id: number; name: string }> }>(
'/pokemon?limit=10',
)
const pokemon = pokemonRes.items
if (pokemon.length < 3) throw new Error(`Need ≥3 pokemon, found ${pokemon.length}`)
// Create a test run
const testRun = await api<{ id: number }>('/runs', {
method: 'POST',
body: JSON.stringify({
gameId: game.id,
name: 'E2E Test Run',
rules: { duplicatesClause: true, shinyClause: true },
}),
})
// Create encounters: caught, fainted, missed
const statuses = ['caught', 'fainted', 'missed'] as const
for (let i = 0; i < 3; i++) {
await api(`/runs/${testRun.id}/encounters`, {
method: 'POST',
body: JSON.stringify({
routeId: leafRoutes[i]!.id,
pokemonId: pokemon[i]!.id,
nickname: `Test ${statuses[i]}`,
status: statuses[i],
catchLevel: statuses[i] === 'missed' ? null : 5 + i * 10,
}),
})
}
// Create a genlocke with 2 game legs
const secondGame = games[1] ?? game
const genlocke = await api<{ id: number }>('/genlockes', {
method: 'POST',
body: JSON.stringify({
name: 'E2E Test Genlocke',
gameIds: [game.id, secondGame.id],
genlockeRules: {},
nuzlockeRules: { duplicatesClause: true },
}),
})
// 6. Write fixtures file
const fixtures = {
gameId: game.id,
runId: testRun.id,
genlockeId: genlocke.id,
}
writeFileSync(FIXTURES_PATH, JSON.stringify(fixtures, null, 2))
console.log('[setup] Fixtures written:', fixtures)
}

View File

@@ -0,0 +1,24 @@
import { execSync } from 'node:child_process'
import { rmSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const COMPOSE_FILE = resolve(__dirname, '../../docker-compose.test.yml')
const COMPOSE = `docker compose -p nuzlocke-test -f ${COMPOSE_FILE}`
const FIXTURES_PATH = resolve(__dirname, '.fixtures.json')
export default async function globalTeardown() {
console.log('[teardown] Stopping test containers...')
execSync(`${COMPOSE} down -v --remove-orphans`, {
encoding: 'utf-8',
stdio: 'inherit',
})
try {
rmSync(FIXTURES_PATH)
console.log('[teardown] Removed fixtures file')
} catch {
// File may not exist if setup failed
}
}

View File

@@ -0,0 +1,71 @@
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import { loadFixtures } from './fixtures'
const fixtures = loadFixtures()
const pages = [
{ name: 'Home', path: '/' },
{ name: 'RunList', path: '/runs' },
{ name: 'NewRun', path: '/runs/new' },
{ name: 'RunEncounters', path: `/runs/${fixtures.runId}` },
{ name: 'GenlockeList', path: '/genlockes' },
{ name: 'NewGenlocke', path: '/genlockes/new' },
{ name: 'GenlockeDetail', path: `/genlockes/${fixtures.genlockeId}` },
{ name: 'Stats', path: '/stats' },
{ name: 'AdminGames', path: '/admin/games' },
{ name: 'AdminPokemon', path: '/admin/pokemon' },
{ name: 'AdminEvolutions', path: '/admin/evolutions' },
]
const viewports = [
{ name: 'mobile', width: 375, height: 667 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 800 },
] as const
for (const viewport of viewports) {
test.describe(`Mobile layout — ${viewport.name} (${viewport.width}x${viewport.height})`, () => {
test.use({
viewport: { width: viewport.width, height: viewport.height },
})
for (const { name, path } of pages) {
test(`${name} (${path}) has no overflow or touch target issues`, async ({ page }) => {
await page.goto(path, { waitUntil: 'networkidle' })
// Assert no horizontal overflow
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}))
expect(
overflow.scrollWidth,
`${name} at ${viewport.name}: horizontal overflow detected (scrollWidth=${overflow.scrollWidth}, innerWidth=${overflow.innerWidth})`,
).toBeLessThanOrEqual(overflow.innerWidth)
// Run axe-core target-size rule for touch target validation
const axeResults = await new AxeBuilder({ page })
.withRules(['target-size'])
.analyze()
const violations = axeResults.violations.map((v) => ({
id: v.id,
impact: v.impact,
nodes: v.nodes.length,
}))
expect(
violations,
`${name} at ${viewport.name}: ${violations.length} touch target violations:\n${JSON.stringify(violations, null, 2)}`,
).toHaveLength(0)
// Capture full-page screenshot
await page.screenshot({
path: `e2e/screenshots/${viewport.name}/${name.toLowerCase()}.png`,
fullPage: true,
})
})
}
})
}

View File

@@ -17,6 +17,15 @@
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
</head> </head>
<body> <body>
<script>
(function() {
var t = localStorage.getItem('ant-theme');
if (t === 'light' || (!t && window.matchMedia('(prefers-color-scheme: light)').matches)) {
document.documentElement.setAttribute('data-theme', 'light');
document.documentElement.style.colorScheme = 'light';
}
})();
</script>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,9 @@
"format:check": "oxfmt --check src/", "format:check": "oxfmt --check src/",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
@@ -24,11 +26,17 @@
"sonner": "2.0.7" "sonner": "2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.11.1",
"@playwright/test": "1.58.2",
"@tailwindcss/vite": "4.1.18", "@tailwindcss/vite": "4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "24.10.10", "@types/node": "24.10.10",
"@types/react": "19.2.11", "@types/react": "19.2.11",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.3", "@vitejs/plugin-react": "5.1.3",
"jsdom": "^28.1.0",
"oxfmt": "0.33.0", "oxfmt": "0.33.0",
"oxlint": "1.48.0", "oxlint": "1.48.0",
"tailwindcss": "4.1.18", "tailwindcss": "4.1.18",

View File

@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
globalTeardown: './e2e/global-teardown.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 905 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 754 B

View File

@@ -13,10 +13,12 @@ export function getGame(id: number): Promise<GameDetail> {
return api.get(`/games/${id}`) return api.get(`/games/${id}`)
} }
export function getGameRoutes(gameId: number): Promise<Route[]> { export function getGameRoutes(gameId: number, allowedTypes?: string[]): Promise<Route[]> {
// Use flat=true to get all routes in a flat list // Use flat=true to get all routes in a flat list
// The frontend organizes them into hierarchy based on parentRouteId // The frontend organizes them into hierarchy based on parentRouteId
return api.get(`/games/${gameId}/routes?flat=true`) const params = new URLSearchParams({ flat: 'true' })
for (const t of allowedTypes ?? []) params.append('allowed_types', t)
return api.get(`/games/${gameId}/routes?${params}`)
} }
export function getRoutePokemon(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> { export function getRoutePokemon(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> {

View File

@@ -1,8 +1,20 @@
const CONDITION_CONFIG: Record<string, { label: string; color: string }> = { const CONDITION_CONFIG: Record<string, { label: string; color: string }> = {
'Mega Evolution': { label: 'Mega', color: 'bg-fuchsia-900/40 text-fuchsia-300' }, 'Mega Evolution': {
Gigantamax: { label: 'G-Max', color: 'bg-red-900/40 text-red-300' }, label: 'Mega',
Dynamax: { label: 'D-Max', color: 'bg-rose-900/40 text-rose-300' }, color: 'bg-fuchsia-900/40 text-fuchsia-300 light:bg-fuchsia-100 light:text-fuchsia-700',
Terastallize: { label: 'Tera', color: 'bg-teal-900/40 text-teal-300' }, },
Gigantamax: {
label: 'G-Max',
color: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-700',
},
Dynamax: {
label: 'D-Max',
color: 'bg-rose-900/40 text-rose-300 light:bg-rose-100 light:text-rose-700',
},
Terastallize: {
label: 'Tera',
color: 'bg-teal-900/40 text-teal-300 light:bg-teal-100 light:text-teal-700',
},
} }
export function ConditionBadge({ export function ConditionBadge({

View File

@@ -1,17 +1,60 @@
export const METHOD_CONFIG: Record<string, { label: string; color: string }> = { export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
starter: { label: 'Starter', color: 'bg-yellow-900/40 text-yellow-300' }, starter: {
gift: { label: 'Gift', color: 'bg-pink-900/40 text-pink-300' }, label: 'Starter',
fossil: { label: 'Fossil', color: 'bg-amber-900/40 text-amber-300' }, color: 'bg-yellow-900/40 text-yellow-300 light:bg-yellow-100 light:text-yellow-800',
trade: { label: 'Trade', color: 'bg-emerald-900/40 text-emerald-300' }, },
walk: { label: 'Grass', color: 'bg-green-900/40 text-green-300' }, gift: {
headbutt: { label: 'Headbutt', color: 'bg-lime-900/40 text-lime-300' }, label: 'Gift',
surf: { label: 'Surfing', color: 'bg-blue-900/40 text-blue-300' }, color: 'bg-pink-900/40 text-pink-300 light:bg-pink-100 light:text-pink-700',
'rock-smash': { label: 'Rock Smash', color: 'bg-orange-900/40 text-orange-300' }, },
'old-rod': { label: 'Old Rod', color: 'bg-cyan-900/40 text-cyan-300' }, fossil: {
'good-rod': { label: 'Good Rod', color: 'bg-sky-900/40 text-sky-300' }, label: 'Fossil',
'super-rod': { label: 'Super Rod', color: 'bg-indigo-900/40 text-indigo-300' }, color: 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-800',
horde: { label: 'Horde', color: 'bg-rose-900/40 text-rose-300' }, },
sos: { label: 'SOS', color: 'bg-violet-900/40 text-violet-300' }, trade: {
label: 'Trade',
color: 'bg-emerald-900/40 text-emerald-300 light:bg-emerald-100 light:text-emerald-700',
},
static: {
label: 'Static',
color: 'bg-teal-900/40 text-teal-300 light:bg-teal-100 light:text-teal-700',
},
walk: {
label: 'Grass',
color: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-700',
},
headbutt: {
label: 'Headbutt',
color: 'bg-lime-900/40 text-lime-300 light:bg-lime-100 light:text-lime-800',
},
surf: {
label: 'Surfing',
color: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700',
},
'rock-smash': {
label: 'Rock Smash',
color: 'bg-orange-900/40 text-orange-300 light:bg-orange-100 light:text-orange-800',
},
'old-rod': {
label: 'Old Rod',
color: 'bg-cyan-900/40 text-cyan-300 light:bg-cyan-100 light:text-cyan-700',
},
'good-rod': {
label: 'Good Rod',
color: 'bg-sky-900/40 text-sky-300 light:bg-sky-100 light:text-sky-700',
},
'super-rod': {
label: 'Super Rod',
color: 'bg-indigo-900/40 text-indigo-300 light:bg-indigo-100 light:text-indigo-700',
},
horde: {
label: 'Horde',
color: 'bg-rose-900/40 text-rose-300 light:bg-rose-100 light:text-rose-700',
},
sos: {
label: 'SOS',
color: 'bg-violet-900/40 text-violet-300 light:bg-violet-100 light:text-violet-700',
},
} }
/** Display order for encounter method groups */ /** Display order for encounter method groups */
@@ -20,6 +63,7 @@ export const METHOD_ORDER = [
'gift', 'gift',
'fossil', 'fossil',
'trade', 'trade',
'static',
'walk', 'walk',
'headbutt', 'headbutt',
'surf', 'surf',
@@ -51,7 +95,7 @@ export function EncounterMethodBadge({
}) { }) {
const config = METHOD_CONFIG[method] const config = METHOD_CONFIG[method]
if (!config) return null if (!config) return null
const sizeClass = size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5' const sizeClass = size === 'xs' ? 'text-[10px] px-1.5 py-0.5' : 'text-xs px-2 py-0.5'
return ( return (
<span className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}> <span className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}>
{config.label} {config.label}

View File

@@ -1,8 +1,15 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { api } from '../api/client'
import { useRoutePokemon } from '../hooks/useGames' import { useRoutePokemon } from '../hooks/useGames'
import { useNameSuggestions } from '../hooks/useRuns' import { useNameSuggestions } from '../hooks/useRuns'
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge' import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
import type { Route, EncounterDetail, EncounterStatus, RouteEncounterDetail } from '../types' import type {
Route,
EncounterDetail,
EncounterStatus,
RouteEncounterDetail,
Pokemon,
} from '../types'
interface EncounterModalProps { interface EncounterModalProps {
route: Route route: Route
@@ -19,6 +26,7 @@ interface EncounterModalProps {
nickname?: string | undefined nickname?: string | undefined
status: EncounterStatus status: EncounterStatus
catchLevel?: number | undefined catchLevel?: number | undefined
origin?: string | undefined
}) => void }) => void
onUpdate?: onUpdate?:
| ((data: { | ((data: {
@@ -33,6 +41,9 @@ interface EncounterModalProps {
| undefined | undefined
onClose: () => void onClose: () => void
isPending: boolean isPending: boolean
useAllPokemon?: boolean | undefined
staticClause?: boolean | undefined
allowedTypes?: string[] | undefined
} }
const statusOptions: { const statusOptions: {
@@ -43,12 +54,14 @@ const statusOptions: {
{ {
value: 'caught', value: 'caught',
label: 'Caught', label: 'Caught',
color: 'bg-green-900/40 text-green-300 border-green-700', color:
'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800 border-green-700 light:border-green-300',
}, },
{ {
value: 'fainted', value: 'fainted',
label: 'Fainted', label: 'Fainted',
color: 'bg-red-900/40 text-red-300 border-red-700', color:
'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800 border-red-700 light:border-red-300',
}, },
{ {
value: 'missed', value: 'missed',
@@ -121,7 +134,8 @@ function groupByMethod(
} else { } else {
// Determine the display rate // Determine the display rate
let displayRate: number | null = null let displayRate: number | null = null
const isSpecial = SPECIAL_METHODS.includes(rp.encounterMethod) const isSpecial =
SPECIAL_METHODS.includes(rp.encounterMethod) || rp.encounterMethod === 'static'
if (!isSpecial) { if (!isSpecial) {
if (selectedCondition) { if (selectedCondition) {
const key = `${rp.pokemonId}:${rp.encounterMethod}` const key = `${rp.pokemonId}:${rp.encounterMethod}`
@@ -186,8 +200,14 @@ export function EncounterModal({
onUpdate, onUpdate,
onClose, onClose,
isPending, isPending,
useAllPokemon,
staticClause = true,
allowedTypes,
}: EncounterModalProps) { }: EncounterModalProps) {
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(route.id, gameId) const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
useAllPokemon ? null : route.id,
useAllPokemon ? undefined : gameId
)
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null) const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught') const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught')
@@ -197,6 +217,8 @@ export function EncounterModal({
const [deathCause, setDeathCause] = useState('') const [deathCause, setDeathCause] = useState('')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selectedCondition, setSelectedCondition] = useState<string | null>(null) const [selectedCondition, setSelectedCondition] = useState<string | null>(null)
const [allPokemonResults, setAllPokemonResults] = useState<Pokemon[]>([])
const [isSearchingAll, setIsSearchingAll] = useState(false)
const isEditing = !!existing const isEditing = !!existing
@@ -216,13 +238,41 @@ export function EncounterModal({
} }
}, [existing, routePokemon]) }, [existing, routePokemon])
// Debounced all-Pokemon search (variant rules)
useEffect(() => {
if (!useAllPokemon) return
if (search.length < 2) {
setAllPokemonResults([])
return
}
const timer = setTimeout(async () => {
setIsSearchingAll(true)
try {
const data = await api.get<{ items: Pokemon[] }>(
`/pokemon?search=${encodeURIComponent(search)}&limit=20`
)
setAllPokemonResults(data.items)
} catch {
setAllPokemonResults([])
} finally {
setIsSearchingAll(false)
}
}, 300)
return () => clearTimeout(timer)
}, [search, useAllPokemon])
const availableConditions = useMemo( const availableConditions = useMemo(
() => (routePokemon ? getUniqueConditions(routePokemon) : []), () => (routePokemon ? getUniqueConditions(routePokemon) : []),
[routePokemon] [routePokemon]
) )
const filteredPokemon = routePokemon?.filter((rp) => const filteredPokemon = routePokemon?.filter(
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()) (rp) =>
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()) &&
(!allowedTypes?.length || rp.pokemon.types.some((t) => allowedTypes.includes(t)))
) )
const groupedPokemon = useMemo( const groupedPokemon = useMemo(
@@ -249,6 +299,7 @@ export function EncounterModal({
nickname: nickname || undefined, nickname: nickname || undefined,
status, status,
catchLevel: catchLevel ? Number(catchLevel) : undefined, catchLevel: catchLevel ? Number(catchLevel) : undefined,
origin: SPECIAL_METHODS.includes(selectedPokemon.encounterMethod) ? 'gift' : undefined,
}) })
} }
} }
@@ -280,7 +331,110 @@ export function EncounterModal({
<div className="px-6 py-4 space-y-4"> <div className="px-6 py-4 space-y-4">
{/* Pokemon Selection (only for new encounters) */} {/* Pokemon Selection (only for new encounters) */}
{!isEditing && ( {!isEditing && useAllPokemon && (
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Pokemon</label>
{selectedPokemon ? (
<div className="flex items-center gap-3 p-3 rounded-lg border border-accent-400 bg-accent-900/20">
{selectedPokemon.pokemon.spriteUrl ? (
<img
src={selectedPokemon.pokemon.spriteUrl}
alt={selectedPokemon.pokemon.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{selectedPokemon.pokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="font-medium text-text-primary capitalize">
{selectedPokemon.pokemon.name}
</span>
<button
onClick={() => {
setSelectedPokemon(null)
setSearch('')
setAllPokemonResults([])
}}
className="ml-auto text-sm text-text-tertiary hover:text-text-secondary"
>
Change
</button>
</div>
) : (
<>
<input
type="text"
placeholder="Search all pokemon by name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-400"
/>
{isSearchingAll && (
<div className="flex items-center justify-center py-4">
<div className="w-6 h-6 border-2 border-accent-400 border-t-transparent rounded-full animate-spin" />
</div>
)}
{allPokemonResults.length > 0 && (
<div className="mt-2 max-h-64 overflow-y-auto grid grid-cols-3 gap-2">
{allPokemonResults.map((p) => {
const isDuped = dupedPokemonIds?.has(p.id) ?? false
return (
<button
key={p.id}
type="button"
onClick={() => {
if (!isDuped) {
setSelectedPokemon({
id: 0,
routeId: 0,
gameId: 0,
pokemonId: p.id,
pokemon: p,
encounterMethod: 'walking',
encounterRate: 0,
condition: '',
minLevel: 1,
maxLevel: 100,
})
}
}}
disabled={isDuped}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
isDuped
? 'opacity-40 cursor-not-allowed border-border-default'
: 'border-border-default hover:border-accent-400'
}`}
>
{p.spriteUrl ? (
<img src={p.spriteUrl} alt={p.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
{p.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-text-secondary mt-1 capitalize">
{p.name}
</span>
{isDuped && (
<span className="text-[10px] text-text-tertiary italic">
{retiredPokemonIds?.has(p.id) ? 'retired (HoF)' : 'already caught'}
</span>
)}
</button>
)
})}
</div>
)}
{search.length >= 2 && !isSearchingAll && allPokemonResults.length === 0 && (
<p className="text-sm text-text-tertiary py-2">No pokemon found</p>
)}
</>
)}
</div>
)}
{!isEditing && !useAllPokemon && (
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-text-secondary">Pokemon</label> <label className="block text-sm font-medium text-text-secondary">Pokemon</label>
@@ -296,10 +450,17 @@ export function EncounterModal({
} }
onClick={() => { onClick={() => {
if (routePokemon) { if (routePokemon) {
setSelectedPokemon(pickRandomPokemon(routePokemon, dupedPokemonIds)) const eligible = routePokemon
.filter((rp) => staticClause || rp.encounterMethod !== 'static')
.filter(
(rp) =>
!allowedTypes?.length ||
rp.pokemon.types.some((t) => allowedTypes.includes(t))
)
setSelectedPokemon(pickRandomPokemon(eligible, dupedPokemonIds))
} }
}} }}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
{selectedPokemon ? 'Re-roll' : 'Randomize'} {selectedPokemon ? 'Re-roll' : 'Randomize'}
</button> </button>
@@ -361,6 +522,9 @@ export function EncounterModal({
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{pokemon.map(({ encounter: rp, conditions, displayRate }) => { {pokemon.map(({ encounter: rp, conditions, displayRate }) => {
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
const isStaticDisabled =
!staticClause && rp.encounterMethod === 'static'
const isDisabled = isDuped || isStaticDisabled
const isSelected = const isSelected =
selectedPokemon?.pokemonId === rp.pokemonId && selectedPokemon?.pokemonId === rp.pokemonId &&
selectedPokemon?.encounterMethod === rp.encounterMethod selectedPokemon?.encounterMethod === rp.encounterMethod
@@ -368,10 +532,10 @@ export function EncounterModal({
<button <button
key={`${rp.encounterMethod}-${rp.pokemonId}`} key={`${rp.encounterMethod}-${rp.pokemonId}`}
type="button" type="button"
onClick={() => !isDuped && setSelectedPokemon(rp)} onClick={() => !isDisabled && setSelectedPokemon(rp)}
disabled={isDuped} disabled={isDisabled}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${ className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
isDuped isDisabled
? 'opacity-40 cursor-not-allowed border-border-default' ? 'opacity-40 cursor-not-allowed border-border-default'
: isSelected : isSelected
? 'border-accent-400 bg-accent-900/30' ? 'border-accent-400 bg-accent-900/30'
@@ -399,22 +563,31 @@ export function EncounterModal({
: 'already caught'} : 'already caught'}
</span> </span>
)} )}
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && ( {isStaticDisabled && (
<EncounterMethodBadge method={rp.encounterMethod} /> <span className="text-[10px] text-text-tertiary italic">
)} static clause off
{!isDuped && displayRate !== null && displayRate !== undefined && (
<span className="text-[10px] text-purple-400 font-medium">
{displayRate}%
</span> </span>
)} )}
{!isDuped && {!isDisabled &&
(SPECIAL_METHODS.includes(rp.encounterMethod) ||
rp.encounterMethod === 'static') && (
<EncounterMethodBadge method={rp.encounterMethod} />
)}
{!isDisabled &&
displayRate !== null &&
displayRate !== undefined && (
<span className="text-[10px] text-purple-400 light:text-purple-700 font-medium">
{displayRate}%
</span>
)}
{!isDisabled &&
selectedCondition === null && selectedCondition === null &&
conditions.length > 0 && ( conditions.length > 0 && (
<span className="text-[10px] text-purple-400"> <span className="text-[10px] text-purple-400 light:text-purple-700">
{conditions.join(', ')} {conditions.join(', ')}
</span> </span>
)} )}
{!isDuped && ( {!isDisabled && (
<span className="text-[10px] text-text-tertiary"> <span className="text-[10px] text-text-tertiary">
Lv. {rp.minLevel} Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`} {rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
@@ -518,7 +691,7 @@ export function EncounterModal({
onClick={() => setNickname(name)} onClick={() => setNickname(name)}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${ className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
nickname === name nickname === name
? 'bg-accent-900/40 border-accent-600 text-accent-300' ? 'bg-accent-900/40 border-accent-600 text-accent-300 light:bg-accent-100 light:text-accent-700'
: 'border-border-default text-text-secondary hover:border-accent-600 hover:bg-accent-900/20' : 'border-border-default text-text-secondary hover:border-accent-600 hover:bg-accent-900/20'
}`} }`}
> >

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

@@ -48,7 +48,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
<div className="text-xs text-text-muted mt-0.5">{entry.routeName}</div> <div className="text-xs text-text-muted mt-0.5">{entry.routeName}</div>
<div className="text-[10px] text-purple-400 mt-0.5 font-medium"> <div className="text-[10px] text-purple-400 light:text-purple-700 mt-0.5 font-medium">
Leg {entry.legOrder} &mdash; {entry.gameName} Leg {entry.legOrder} &mdash; {entry.gameName}
</div> </div>

View File

@@ -48,18 +48,18 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
<div <div
className={`font-medium ${ className={`font-medium ${
leg.faintLevel !== null leg.faintLevel !== null
? 'text-red-300' ? 'text-red-300 light:text-red-700'
: leg.wasTransferred : leg.wasTransferred
? 'text-blue-300' ? 'text-blue-300 light:text-blue-700'
: leg.enteredHof : leg.enteredHof
? 'text-yellow-300' ? 'text-yellow-300 light:text-amber-700'
: 'text-green-300' : 'text-green-300 light:text-green-700'
}`} }`}
> >
{label} {label}
</div> </div>
{leg.enteredHof && leg.faintLevel === null && ( {leg.enteredHof && leg.faintLevel === null && (
<div className="text-yellow-300">Hall of Fame</div> <div className="text-yellow-300 light:text-amber-700">Hall of Fame</div>
)} )}
</div> </div>
<div className="w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45 -mt-1" /> <div className="w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45 -mt-1" />
@@ -156,8 +156,8 @@ function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegO
<span <span
className={`px-2.5 py-1 rounded-full text-xs font-medium ${ className={`px-2.5 py-1 rounded-full text-xs font-medium ${
lineage.status === 'alive' lineage.status === 'alive'
? 'bg-green-900/40 text-green-300' ? 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800'
: 'bg-red-900/40 text-red-300' : 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800'
}`} }`}
> >
{lineage.status === 'alive' ? 'Alive' : 'Dead'} {lineage.status === 'alive' ? 'Alive' : 'Dead'}

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

@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, Outlet, useLocation } from 'react-router-dom' import { Link, Outlet, useLocation } from 'react-router-dom'
import { useTheme } from '../hooks/useTheme'
const navLinks = [ const navLinks = [
{ to: '/runs/new', label: 'New Run' }, { to: '/runs/new', label: 'New Run' },
@@ -37,6 +38,39 @@ function NavLink({
) )
} }
function ThemeToggle() {
const { theme, toggle } = useTheme()
return (
<button
type="button"
onClick={toggle}
className="p-2 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m8.66-13.66l-.71.71M4.05 19.95l-.71.71M21 12h-1M4 12H3m16.66 7.66l-.71-.71M4.05 4.05l-.71-.71M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
)}
</button>
)
}
export function Layout() { export function Layout() {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const location = useLocation() const location = useLocation()
@@ -68,9 +102,11 @@ export function Layout() {
{link.label} {link.label}
</NavLink> </NavLink>
))} ))}
<ThemeToggle />
</div> </div>
{/* Mobile hamburger */} {/* Mobile hamburger */}
<div className="flex items-center sm:hidden"> <div className="flex items-center gap-1 sm:hidden">
<ThemeToggle />
<button <button
type="button" type="button"
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}

View File

@@ -1,5 +1,6 @@
import type { NuzlockeRules } from '../types' import type { NuzlockeRules } from '../types'
import { RULE_DEFINITIONS } from '../types/rules' import { RULE_DEFINITIONS } from '../types/rules'
import { TypeBadge } from './TypeBadge'
interface RuleBadgesProps { interface RuleBadgesProps {
rules: NuzlockeRules rules: NuzlockeRules
@@ -7,8 +8,9 @@ interface RuleBadgesProps {
export function RuleBadges({ rules }: RuleBadgesProps) { export function RuleBadges({ rules }: RuleBadgesProps) {
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key]) const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
const allowedTypes = rules.allowedTypes ?? []
if (enabledRules.length === 0) { if (enabledRules.length === 0 && allowedTypes.length === 0) {
return <span className="text-sm text-text-tertiary">No rules enabled</span> return <span className="text-sm text-text-tertiary">No rules enabled</span>
} }
@@ -20,15 +22,26 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
title={def.description} title={def.description}
className={`px-2 py-0.5 rounded-full text-xs font-medium ${ className={`px-2 py-0.5 rounded-full text-xs font-medium ${
def.category === 'core' def.category === 'core'
? 'bg-blue-900/40 text-blue-300' ? 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700'
: def.category === 'completion' : def.category === 'variant'
? 'bg-green-900/40 text-green-300' ? 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700'
: 'bg-amber-900/40 text-amber-300' : 'bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-700'
}`} }`}
> >
{def.name} {def.name}
</span> </span>
))} ))}
{allowedTypes.length > 0 && (
<span
title={`Type restriction: ${allowedTypes.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(', ')}`}
className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700 flex items-center gap-1"
>
<span>Type Restriction</span>
{allowedTypes.map((t) => (
<TypeBadge key={t} type={t} size="sm" />
))}
</span>
)}
</div> </div>
) )
} }

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,6 +1,28 @@
import type { NuzlockeRules } from '../types/rules' import type { NuzlockeRules } from '../types/rules'
import { RULE_DEFINITIONS, DEFAULT_RULES } from '../types/rules' import { RULE_DEFINITIONS, DEFAULT_RULES } from '../types/rules'
import { RuleToggle } from './RuleToggle' import { RuleToggle } from './RuleToggle'
import { TypeBadge } from './TypeBadge'
const POKEMON_TYPES = [
'bug',
'dark',
'dragon',
'electric',
'fairy',
'fighting',
'fire',
'flying',
'ghost',
'grass',
'ground',
'ice',
'normal',
'poison',
'psychic',
'rock',
'steel',
'water',
] as const
interface RulesConfigurationProps { interface RulesConfigurationProps {
rules: NuzlockeRules rules: NuzlockeRules
@@ -19,8 +41,8 @@ export function RulesConfiguration({
? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key)) ? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key))
: RULE_DEFINITIONS : RULE_DEFINITIONS
const coreRules = visibleRules.filter((r) => r.category === 'core') const coreRules = visibleRules.filter((r) => r.category === 'core')
const difficultyRules = visibleRules.filter((r) => r.category === 'difficulty') const playstyleRules = visibleRules.filter((r) => r.category === 'playstyle')
const completionRules = visibleRules.filter((r) => r.category === 'completion') const variantRules = visibleRules.filter((r) => r.category === 'variant')
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => { const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
onChange({ ...rules, [key]: value }) onChange({ ...rules, [key]: value })
@@ -31,8 +53,18 @@ export function RulesConfiguration({
onReset?.() onReset?.()
} }
const enabledCount = visibleRules.filter((r) => rules[r.key]).length const allowedTypes = rules.allowedTypes ?? []
const totalCount = visibleRules.length
const toggleType = (type: string) => {
const next = allowedTypes.includes(type)
? allowedTypes.filter((t) => t !== type)
: [...allowedTypes, type]
onChange({ ...rules, allowedTypes: next })
}
const enabledCount =
visibleRules.filter((r) => rules[r.key]).length + (allowedTypes.length > 0 ? 1 : 0)
const totalCount = visibleRules.length + 1 // +1 for type restriction
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -74,11 +106,13 @@ export function RulesConfiguration({
<div className="bg-surface-1 rounded-lg shadow"> <div className="bg-surface-1 rounded-lg shadow">
<div className="px-4 py-3 border-b border-border-default"> <div className="px-4 py-3 border-b border-border-default">
<h3 className="text-lg font-medium text-text-primary">Difficulty Modifiers</h3> <h3 className="text-lg font-medium text-text-primary">Playstyle</h3>
<p className="text-sm text-text-tertiary">Optional rules to increase the challenge</p> <p className="text-sm text-text-tertiary">
Describe how you're playing — doesn't affect tracker behavior
</p>
</div> </div>
<div className="px-4"> <div className="px-4">
{difficultyRules.map((rule) => ( {playstyleRules.map((rule) => (
<RuleToggle <RuleToggle
key={rule.key} key={rule.key}
name={rule.name} name={rule.name}
@@ -90,25 +124,63 @@ export function RulesConfiguration({
</div> </div>
</div> </div>
{completionRules.length > 0 && ( <div className="bg-surface-1 rounded-lg shadow">
<div className="bg-surface-1 rounded-lg shadow"> <div className="px-4 py-3 border-b border-border-default">
<div className="px-4 py-3 border-b border-border-default"> <h3 className="text-lg font-medium text-text-primary">Run Variant</h3>
<h3 className="text-lg font-medium text-text-primary">Completion</h3> <p className="text-sm text-text-tertiary">
<p className="text-sm text-text-tertiary">When is the run considered complete</p> Changes which Pokémon can appear affects the encounter selector
</div> </p>
<div className="px-4"> </div>
{completionRules.map((rule) => ( <div className="px-4">
<RuleToggle {variantRules.map((rule) => (
key={rule.key} <RuleToggle
name={rule.name} key={rule.key}
description={rule.description} name={rule.name}
enabled={rules[rule.key]} description={rule.description}
onChange={(value) => handleRuleChange(rule.key, value)} enabled={rules[rule.key]}
/> onChange={(value) => handleRuleChange(rule.key, value)}
/>
))}
</div>
</div>
<div className="bg-surface-1 rounded-lg shadow">
<div className="px-4 py-3 border-b border-border-default">
<h3 className="text-lg font-medium text-text-primary">Type Restriction</h3>
<p className="text-sm text-text-tertiary">
Monolocke and variants. Select allowed types a Pokémon qualifies if it shares at least
one type. Leave all deselected to disable.
</p>
</div>
<div className="px-4 py-4">
<div className="flex flex-wrap gap-2">
{POKEMON_TYPES.map((type) => (
<button
key={type}
type="button"
onClick={() => toggleType(type)}
title={type.charAt(0).toUpperCase() + type.slice(1)}
className={`p-1.5 rounded-lg border-2 transition-colors ${
allowedTypes.includes(type)
? 'border-accent-400 bg-accent-900/20'
: 'border-transparent opacity-40 hover:opacity-70'
}`}
>
<TypeBadge type={type} size="md" />
</button>
))} ))}
</div> </div>
{allowedTypes.length > 0 && (
<button
type="button"
onClick={() => onChange({ ...rules, allowedTypes: [] })}
className="mt-3 text-xs text-text-tertiary hover:text-text-secondary"
>
Clear selection
</button>
)}
</div> </div>
)} </div>
</div> </div>
) )
} }

View File

@@ -9,7 +9,7 @@ interface ShinyBoxProps {
export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) { export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
return ( return (
<div className="border-2 border-yellow-600 rounded-lg p-4"> <div className="border-2 border-yellow-600 rounded-lg p-4">
<h3 className="text-sm font-semibold text-yellow-400 mb-3 flex items-center gap-1.5"> <h3 className="text-sm font-semibold text-yellow-400 light:text-amber-700 mb-3 flex items-center gap-1.5">
<span>&#10022;</span> <span>&#10022;</span>
Shiny Box Shiny Box
<span className="text-xs font-normal text-text-muted ml-1"> <span className="text-xs font-normal text-text-muted ml-1">

View File

@@ -110,7 +110,7 @@ export function ShinyEncounterModal({
</svg> </svg>
</button> </button>
</div> </div>
<p className="text-sm text-yellow-400 mt-1"> <p className="text-sm text-yellow-400 light:text-amber-700 mt-1">
Shiny catches bypass the one-per-route rule Shiny catches bypass the one-per-route rule
</p> </p>
</div> </div>

View File

@@ -21,7 +21,9 @@ export function AdminLayout() {
to={item.to} to={item.to}
className={({ isActive }) => className={({ isActive }) =>
`block px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap ${ `block px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap ${
isActive ? 'bg-accent-900/40 text-accent-300' : 'hover:bg-surface-2' isActive
? 'bg-accent-900/40 text-accent-300 light:bg-accent-100 light:text-accent-700'
: 'hover:bg-surface-2'
}` }`
} }
> >

View File

@@ -77,7 +77,7 @@ export function BulkImportModal({
)} )}
{result && ( {result && (
<div className="p-3 bg-green-900/30 text-green-300 rounded-md text-sm"> <div className="p-3 bg-green-900/30 text-green-300 light:bg-green-100 light:text-green-800 rounded-md text-sm">
<p> <p>
{createdLabel}: {result.created}, {updatedLabel}: {result.updated} {createdLabel}: {result.created}, {updatedLabel}: {result.updated}
</p> </p>

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

@@ -15,10 +15,10 @@ export function useGame(id: number) {
}) })
} }
export function useGameRoutes(gameId: number | null) { export function useGameRoutes(gameId: number | null, allowedTypes?: string[]) {
return useQuery({ return useQuery({
queryKey: ['games', gameId, 'routes'], queryKey: ['games', gameId, 'routes', allowedTypes],
queryFn: () => getGameRoutes(gameId!), queryFn: () => getGameRoutes(gameId!, allowedTypes),
enabled: gameId !== null, enabled: gameId !== null,
}) })
} }

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,63 @@
import { useCallback, useSyncExternalStore } from 'react'
type Theme = 'dark' | 'light'
const STORAGE_KEY = 'ant-theme'
function getSystemTheme(): Theme {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
}
function getStoredTheme(): Theme | null {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'dark' || stored === 'light') return stored
return null
}
function applyTheme(theme: Theme) {
if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light')
} else {
document.documentElement.removeAttribute('data-theme')
}
document.documentElement.style.colorScheme = theme
}
const listeners = new Set<() => void>()
let currentTheme: Theme = getStoredTheme() ?? getSystemTheme()
applyTheme(currentTheme)
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)')
mediaQuery.addEventListener('change', () => {
if (!getStoredTheme()) {
currentTheme = getSystemTheme()
applyTheme(currentTheme)
for (const listener of listeners) listener()
}
})
function subscribe(listener: () => void) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
function getSnapshot(): Theme {
return currentTheme
}
export function useTheme() {
const theme = useSyncExternalStore(subscribe, getSnapshot)
const toggle = useCallback(() => {
const next: Theme = currentTheme === 'dark' ? 'light' : 'dark'
currentTheme = next
localStorage.setItem(STORAGE_KEY, next)
applyTheme(next)
for (const listener of listeners) listener()
}, [])
return { theme, toggle } as const
}

View File

@@ -46,8 +46,9 @@
/* Text on dark */ /* Text on dark */
--color-text-primary: #e6edf3; --color-text-primary: #e6edf3;
--color-text-secondary: #7d8590; --color-text-secondary: #9198a1;
--color-text-tertiary: #484f58; --color-text-tertiary: #8b949e;
--color-text-muted: #8b949e;
--color-text-link: #7eb0ce; --color-text-link: #7eb0ce;
/* Borders */ /* Borders */
@@ -68,6 +69,50 @@
--color-status-failed-bg: rgba(248, 81, 73, 0.15); --color-status-failed-bg: rgba(248, 81, 73, 0.15);
} }
@custom-variant light (&:where([data-theme="light"], [data-theme="light"] *));
/* ── Light mode overrides ─────────────────────────────────────── */
html[data-theme='light'] {
color-scheme: light;
/* Surfaces */
--color-surface-0: #ffffff;
--color-surface-1: #f6f8fa;
--color-surface-2: #eef1f4;
--color-surface-3: #d8dee4;
--color-surface-4: #ced5dc;
/* Accent (darkened for text contrast on light surfaces) */
--color-accent-200: #245f7e;
--color-accent-300: #1a5068;
--color-accent-400: #2d6a89;
/* Text */
--color-text-primary: #1f2328;
--color-text-secondary: #656d76;
--color-text-tertiary: #596069;
--color-text-muted: #596069;
--color-text-link: #1a5068;
/* Borders */
--color-border-default: rgba(0, 0, 0, 0.15);
--color-border-muted: rgba(0, 0, 0, 0.08);
--color-border-accent: rgba(57, 94, 115, 0.35);
/* Status (adjusted for light surfaces) */
--color-status-alive: #1a7f37;
--color-status-alive-bg: rgba(26, 127, 55, 0.1);
--color-status-dead: #cf222e;
--color-status-dead-bg: rgba(207, 34, 46, 0.1);
--color-status-active: #116b2b;
--color-status-active-bg: rgba(17, 107, 43, 0.08);
--color-status-completed: #0969da;
--color-status-completed-bg: rgba(9, 105, 218, 0.1);
--color-status-failed: #cf222e;
--color-status-failed-bg: rgba(207, 34, 46, 0.1);
}
/* ── Base layer ────────────────────────────────────────────────── */ /* ── Base layer ────────────────────────────────────────────────── */
@layer base { @layer base {

View File

@@ -18,9 +18,9 @@ const statusRing: Record<RunStatus, string> = {
} }
const statusStyles: Record<RunStatus, string> = { const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-900/40 text-green-300', active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
completed: 'bg-blue-900/40 text-blue-300', completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
failed: 'bg-red-900/40 text-red-300', failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
} }
function LegIndicator({ leg }: { leg: GenlockeLegDetail }) { function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
@@ -270,7 +270,7 @@ export function GenlockeDetail() {
className={`px-4 py-2 rounded-lg font-medium transition-colors ${ className={`px-4 py-2 rounded-lg font-medium transition-colors ${
showGraveyard showGraveyard
? 'bg-red-600 text-white hover:bg-red-700' ? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-surface-3 text-text-secondary hover:bg-surface-4' : 'bg-surface-3 text-text-secondary light:text-text-primary hover:bg-surface-4'
}`} }`}
> >
Graveyard Graveyard
@@ -280,7 +280,7 @@ export function GenlockeDetail() {
className={`px-4 py-2 rounded-lg font-medium transition-colors ${ className={`px-4 py-2 rounded-lg font-medium transition-colors ${
showLineage showLineage
? 'bg-accent-600 text-white hover:bg-accent-500' ? 'bg-accent-600 text-white hover:bg-accent-500'
: 'bg-surface-3 text-text-secondary hover:bg-surface-4' : 'bg-surface-3 text-text-secondary light:text-text-primary hover:bg-surface-4'
}`} }`}
> >
Lineage Lineage

View File

@@ -3,9 +3,9 @@ import { useGenlockes } from '../hooks/useGenlockes'
import type { RunStatus } from '../types' import type { RunStatus } from '../types'
const statusStyles: Record<RunStatus, string> = { const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-900/40 text-green-300', active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
completed: 'bg-blue-900/40 text-blue-300', completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
failed: 'bg-red-900/40 text-red-300', failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
} }
export function GenlockeList() { export function GenlockeList() {

View File

@@ -31,9 +31,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
} }
const statusStyles: Record<RunStatus, string> = { const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-900/40 text-green-300', active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
completed: 'bg-blue-900/40 text-blue-300', completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
failed: 'bg-red-900/40 text-red-300', failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
} }
function formatDuration(start: string, end: string) { function formatDuration(start: string, end: string) {

View File

@@ -59,9 +59,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
} }
const statusStyles: Record<RunStatus, string> = { const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-900/40 text-green-300', active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
completed: 'bg-blue-900/40 text-blue-300', completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
failed: 'bg-red-900/40 text-red-300', failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
} }
function formatDuration(start: string, end: string) { function formatDuration(start: string, end: string) {
@@ -178,6 +178,17 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
return matches.length === 1 ? (matches[0] ?? null) : null return matches.length === 1 ? (matches[0] ?? null) : null
} }
/** Count boss pokemon for the effective variant (or all if no variants). */
function getBossTeamSize(pokemon: BossPokemon[], starterName?: string | null): number {
const labels = [
...new Set(pokemon.filter((bp) => bp.conditionLabel).map((bp) => bp.conditionLabel!)),
]
if (labels.length === 0) return pokemon.length
const matched = matchVariant(labels, starterName)
const variant = matched ?? labels[0] ?? null
return pokemon.filter((bp) => bp.conditionLabel === variant || bp.conditionLabel === null).length
}
function BossTeamPreview({ function BossTeamPreview({
pokemon, pokemon,
starterName, starterName,
@@ -254,6 +265,7 @@ function BossTeamPreview({
interface RouteGroupProps { interface RouteGroupProps {
group: RouteWithChildren group: RouteWithChildren
encounterByRoute: Map<number, EncounterDetail> encounterByRoute: Map<number, EncounterDetail>
giftEncounterByRoute: Map<number, EncounterDetail>
isExpanded: boolean isExpanded: boolean
onToggleExpand: () => void onToggleExpand: () => void
onRouteClick: (route: Route) => void onRouteClick: (route: Route) => void
@@ -264,6 +276,7 @@ interface RouteGroupProps {
function RouteGroup({ function RouteGroup({
group, group,
encounterByRoute, encounterByRoute,
giftEncounterByRoute,
isExpanded, isExpanded,
onToggleExpand, onToggleExpand,
onRouteClick, onRouteClick,
@@ -274,13 +287,23 @@ function RouteGroup({
const usePinwheel = pinwheelClause && groupHasZones(group) const usePinwheel = pinwheelClause && groupHasZones(group)
const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null
// Find first gift encounter in the group (for display)
let groupGiftEncounter: EncounterDetail | null = null
for (const child of group.children) {
const gift = giftEncounterByRoute.get(child.id)
if (gift) {
groupGiftEncounter = gift
break
}
}
const displayEncounter = groupEncounter ?? groupGiftEncounter
// For pinwheel groups, determine status from all zone statuses // For pinwheel groups, determine status from all zone statuses
let groupStatus: RouteStatus let groupStatus: RouteStatus
if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) { if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) {
// Use the first encounter's status as representative for the header groupStatus = displayEncounter ? displayEncounter.status : 'none'
groupStatus = groupEncounter ? groupEncounter.status : 'none'
} else { } else {
groupStatus = groupEncounter ? groupEncounter.status : 'none' groupStatus = displayEncounter ? displayEncounter.status : 'none'
} }
const si = statusIndicator[groupStatus] const si = statusIndicator[groupStatus]
@@ -289,10 +312,9 @@ function RouteGroup({
if (usePinwheel) { if (usePinwheel) {
// Show group if any zone matches the filter // Show group if any zone matches the filter
const anyChildMatches = group.children.some((child) => { const anyChildMatches = group.children.some((child) => {
const enc = encounterByRoute.get(child.id) const enc = encounterByRoute.get(child.id) ?? giftEncounterByRoute.get(child.id)
return getRouteStatus(enc) === filter return getRouteStatus(enc) === filter
}) })
// Also check children without encounters (for 'none' filter)
if (!anyChildMatches) return null if (!anyChildMatches) return null
} else if (groupStatus !== filter) { } else if (groupStatus !== filter) {
return null return null
@@ -330,6 +352,36 @@ function RouteGroup({
groupEncounter.faintLevel !== null && groupEncounter.faintLevel !== null &&
(groupEncounter.deathCause ? `${groupEncounter.deathCause}` : ' (dead)')} (groupEncounter.deathCause ? `${groupEncounter.deathCause}` : ' (dead)')}
</span> </span>
{groupGiftEncounter && (
<>
{groupGiftEncounter.pokemon.spriteUrl && (
<img
src={groupGiftEncounter.pokemon.spriteUrl}
alt={groupGiftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</>
)}
</div>
)}
{!groupEncounter && groupGiftEncounter && (
<div className="flex items-center gap-2 mt-0.5">
{groupGiftEncounter.pokemon.spriteUrl && (
<img
src={groupGiftEncounter.pokemon.spriteUrl}
alt={groupGiftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</div> </div>
)} )}
</div> </div>
@@ -349,7 +401,9 @@ function RouteGroup({
<div className="border-t border-border-default bg-surface-1/50"> <div className="border-t border-border-default bg-surface-1/50">
{group.children.map((child) => { {group.children.map((child) => {
const childEncounter = encounterByRoute.get(child.id) const childEncounter = encounterByRoute.get(child.id)
const childStatus = getRouteStatus(childEncounter) const giftEncounter = giftEncounterByRoute.get(child.id)
const displayEncounter = childEncounter ?? giftEncounter
const childStatus = getRouteStatus(displayEncounter)
const childSi = statusIndicator[childStatus] const childSi = statusIndicator[childStatus]
let isDisabled: boolean let isDisabled: boolean
@@ -375,7 +429,22 @@ function RouteGroup({
<span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} /> <span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-text-secondary">{child.name}</div> <div className="text-sm text-text-secondary">{child.name}</div>
{!childEncounter && child.encounterMethods.length > 0 && ( {giftEncounter && !childEncounter && (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</div>
)}
{!displayEncounter && child.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5"> <div className="flex flex-wrap gap-1 mt-0.5">
{child.encounterMethods.map((m) => ( {child.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" /> <EncounterMethodBadge key={m} method={m} size="xs" />
@@ -401,7 +470,11 @@ export function RunEncounters() {
const { data: run, isLoading, error } = useRun(runIdNum) const { data: run, isLoading, error } = useRun(runIdNum)
const advanceLeg = useAdvanceLeg() const advanceLeg = useAdvanceLeg()
const [showTransferModal, setShowTransferModal] = useState(false) const [showTransferModal, setShowTransferModal] = useState(false)
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(run?.gameId ?? null) const rulesAllowedTypes = run?.rules?.allowedTypes ?? []
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
run?.gameId ?? null,
rulesAllowedTypes.length ? rulesAllowedTypes : undefined
)
const createEncounter = useCreateEncounter(runIdNum) const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum) const updateEncounter = useUpdateEncounter(runIdNum)
const bulkRandomize = useBulkRandomize(runIdNum) const bulkRandomize = useBulkRandomize(runIdNum)
@@ -484,14 +557,29 @@ export function RunEncounters() {
} }
}, [run, transferIdSet]) }, [run, transferIdSet])
// Map routeId → encounter for quick lookup (normal encounters only) const giftClauseOn = run?.rules?.giftClause ?? false
// Map routeId → encounter for quick lookup (normal encounters only).
// When gift clause is on, gift-origin encounters are excluded so they
// don't lock the route for a regular encounter.
const encounterByRoute = useMemo(() => { const encounterByRoute = useMemo(() => {
const map = new Map<number, EncounterDetail>() const map = new Map<number, EncounterDetail>()
for (const enc of normalEncounters) { for (const enc of normalEncounters) {
if (giftClauseOn && enc.origin === 'gift') continue
map.set(enc.routeId, enc) map.set(enc.routeId, enc)
} }
return map return map
}, [normalEncounters]) }, [normalEncounters, giftClauseOn])
// Separate map for gift encounters (only populated when gift clause is on)
const giftEncounterByRoute = useMemo(() => {
const map = new Map<number, EncounterDetail>()
if (!giftClauseOn) return map
for (const enc of normalEncounters) {
if (enc.origin === 'gift') map.set(enc.routeId, enc)
}
return map
}, [normalEncounters, giftClauseOn])
// Build set of retired Pokemon IDs from genlocke context // Build set of retired Pokemon IDs from genlocke context
const retiredPokemonIds = useMemo(() => { const retiredPokemonIds = useMemo(() => {
@@ -677,6 +765,7 @@ export function RunEncounters() {
} }
const pinwheelClause = run.rules?.pinwheelClause ?? true const pinwheelClause = run.rules?.pinwheelClause ?? true
const useAllPokemon = !!(run.rules?.egglocke || run.rules?.wonderlocke || run.rules?.randomizer)
// Count completed locations (zone-aware when pinwheel clause is on) // Count completed locations (zone-aware when pinwheel clause is on)
let completedCount = 0 let completedCount = 0
@@ -755,7 +844,7 @@ export function RunEncounters() {
}) })
} }
// Filter routes // Filter routes (check both regular and gift encounters for status)
const filteredRoutes = organizedRoutes.filter((r) => { const filteredRoutes = organizedRoutes.filter((r) => {
if (filter === 'all') return true if (filter === 'all') return true
@@ -764,17 +853,23 @@ export function RunEncounters() {
if (usePinwheel) { if (usePinwheel) {
// Show group if any child/zone matches the filter // Show group if any child/zone matches the filter
return r.children.some((child) => { return r.children.some((child) => {
const enc = encounterByRoute.get(child.id) const enc = encounterByRoute.get(child.id) ?? giftEncounterByRoute.get(child.id)
return getRouteStatus(enc) === filter return getRouteStatus(enc) === filter
}) })
} }
// Classic: single status for whole group // Classic: single status for whole group
const groupEnc = getGroupEncounter(r, encounterByRoute) const groupEnc = getGroupEncounter(r, encounterByRoute)
return getRouteStatus(groupEnc ?? undefined) === filter if (groupEnc) return getRouteStatus(groupEnc) === filter
// Check gift encounters if no regular encounter in group
for (const child of r.children) {
const gift = giftEncounterByRoute.get(child.id)
if (gift) return getRouteStatus(gift) === filter
}
return filter === 'none'
} }
// Standalone route // Standalone route
const enc = encounterByRoute.get(r.id) const enc = encounterByRoute.get(r.id) ?? giftEncounterByRoute.get(r.id)
return getRouteStatus(enc) === filter return getRouteStatus(enc) === filter
}) })
@@ -801,7 +896,7 @@ export function RunEncounters() {
})} })}
</p> </p>
{run.genlocke && ( {run.genlocke && (
<p className="text-sm text-purple-400 mt-1 font-medium"> <p className="text-sm text-purple-400 light:text-purple-700 mt-1 font-medium">
Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} &mdash;{' '} Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} &mdash;{' '}
{run.genlocke.genlockeName} {run.genlocke.genlockeName}
</p> </p>
@@ -811,7 +906,7 @@ export function RunEncounters() {
{isActive && run.rules?.shinyClause && ( {isActive && run.rules?.shinyClause && (
<button <button
onClick={() => setShowShinyModal(true)} onClick={() => setShowShinyModal(true)}
className="px-3 py-1 text-sm border border-yellow-600 text-yellow-400 rounded-full font-medium hover:bg-yellow-900/20 transition-colors" className="px-3 py-1 text-sm border border-yellow-600 text-yellow-400 light:text-amber-700 light:border-amber-600 rounded-full font-medium hover:bg-yellow-900/20 light:hover:bg-amber-50 transition-colors"
> >
&#10022; Log Shiny &#10022; Log Shiny
</button> </button>
@@ -973,14 +1068,22 @@ export function RunEncounters() {
{/* Level Cap Bar */} {/* Level Cap Bar */}
{run.rules?.levelCaps && sortedBosses.length > 0 && ( {run.rules?.levelCaps && sortedBosses.length > 0 && (
<div className="sticky top-0 z-10 bg-surface-0 border border-border-default rounded-lg px-4 py-3 mb-6 shadow-sm"> <div className="sticky top-14 z-30 bg-surface-0 border border-border-default rounded-lg px-4 py-3 mb-6 shadow-sm">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-semibold text-text-primary"> <span className="text-sm font-semibold text-text-primary">
Level Cap: {currentLevelCap ?? '—'} Level Cap: {currentLevelCap ?? '—'}
</span> </span>
{nextBoss && ( {nextBoss && (
<span className="text-sm text-text-tertiary">Next: {nextBoss.name}</span> <span className="text-sm text-text-tertiary">
Next: {nextBoss.name}
{run.rules?.bossTeamMatch && (
<span className="text-text-muted">
{' '}
({getBossTeamSize(nextBoss.pokemon, starterName)} Pokémon match their team)
</span>
)}
</span>
)} )}
{!nextBoss && ( {!nextBoss && (
<span className="text-sm text-status-active">All bosses defeated!</span> <span className="text-sm text-status-active">All bosses defeated!</span>
@@ -1153,7 +1256,7 @@ export function RunEncounters() {
bulkRandomize.mutate() bulkRandomize.mutate()
} }
}} }}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'} {bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button> </button>
@@ -1225,6 +1328,7 @@ export function RunEncounters() {
key={route.id} key={route.id}
group={route} group={route}
encounterByRoute={encounterByRoute} encounterByRoute={encounterByRoute}
giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)} isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)} onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick} onRouteClick={handleRouteClick}
@@ -1234,7 +1338,9 @@ export function RunEncounters() {
) : ( ) : (
(() => { (() => {
const encounter = encounterByRoute.get(route.id) const encounter = encounterByRoute.get(route.id)
const rs = getRouteStatus(encounter) const giftEncounter = giftEncounterByRoute.get(route.id)
const displayEncounter = encounter ?? giftEncounter
const rs = getRouteStatus(displayEncounter)
const si = statusIndicator[rs] const si = statusIndicator[rs]
return ( return (
@@ -1262,6 +1368,35 @@ export function RunEncounters() {
encounter.faintLevel !== null && encounter.faintLevel !== null &&
(encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')} (encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')}
</span> </span>
{giftEncounter && (
<>
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</>
)}
</div>
) : giftEncounter ? (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</div> </div>
) : ( ) : (
route.encounterMethods.length > 0 && ( route.encounterMethods.length > 0 && (
@@ -1358,7 +1493,7 @@ export function RunEncounters() {
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
{isDefeated ? ( {isDefeated ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300"> <span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
Defeated &#10003; Defeated &#10003;
</span> </span>
) : isActive ? ( ) : isActive ? (
@@ -1411,6 +1546,9 @@ export function RunEncounters() {
setEditingEncounter(null) setEditingEncounter(null)
}} }}
isPending={createEncounter.isPending || updateEncounter.isPending} isPending={createEncounter.isPending || updateEncounter.isPending}
useAllPokemon={useAllPokemon}
staticClause={run?.rules?.staticClause ?? true}
allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined}
/> />
)} )}

View File

@@ -4,24 +4,24 @@ import { StatCard } from '../components'
import type { PokemonRanking, StatsResponse } from '../types/stats' import type { PokemonRanking, StatsResponse } from '../types/stats'
const typeBarColors: Record<string, string> = { const typeBarColors: Record<string, string> = {
normal: 'bg-gray-400', normal: '#9ca3af',
fire: 'bg-red-500', fire: '#ef4444',
water: 'bg-blue-500', water: '#3b82f6',
electric: 'bg-yellow-400', electric: '#facc15',
grass: 'bg-green-500', grass: '#22c55e',
ice: 'bg-cyan-300', ice: '#67e8f9',
fighting: 'bg-red-700', fighting: '#b91c1c',
poison: 'bg-purple-500', poison: '#a855f7',
ground: 'bg-amber-600', ground: '#d97706',
flying: 'bg-indigo-300', flying: '#a5b4fc',
psychic: 'bg-pink-500', psychic: '#ec4899',
bug: 'bg-lime-500', bug: '#84cc16',
rock: 'bg-amber-700', rock: '#b45309',
ghost: 'bg-purple-700', ghost: '#7e22ce',
dragon: 'bg-indigo-600', dragon: '#4f46e5',
dark: 'bg-gray-700', dark: '#374151',
steel: 'bg-gray-400', steel: '#9ca3af',
fairy: 'bg-pink-300', fairy: '#f9a8d4',
} }
function fmt(value: number | null, suffix = ''): string { function fmt(value: number | null, suffix = ''): string {
@@ -74,44 +74,50 @@ function PokemonList({ title, pokemon }: { title: string; pokemon: PokemonRankin
) )
} }
function hexLuminance(hex: string): number { function srgbLuminance(hex: string): number {
const r = parseInt(hex.slice(1, 3), 16) const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
const g = parseInt(hex.slice(3, 5), 16) const r = toLinear(parseInt(hex.slice(1, 3), 16) / 255)
const b = parseInt(hex.slice(5, 7), 16) const g = toLinear(parseInt(hex.slice(3, 5), 16) / 255)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 const b = toLinear(parseInt(hex.slice(5, 7), 16) / 255)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
function shouldUseDarkText(bgHex: string): boolean {
const bgL = srgbLuminance(bgHex)
const whiteContrast = 1.05 / (bgL + 0.05)
const blackContrast = (bgL + 0.05) / 0.05
return blackContrast > whiteContrast
} }
function HorizontalBar({ function HorizontalBar({
label, label,
value, value,
max, max,
color,
colorHex, colorHex,
}: { }: {
label: string label: string
value: number value: number
max: number max: number
color?: string colorHex: string
colorHex?: string
}) { }) {
const width = max > 0 ? (value / max) * 100 : 0 const width = max > 0 ? (value / max) * 100 : 0
const isLight = colorHex ? hexLuminance(colorHex) > 0.55 : false const useDark = shouldUseDarkText(colorHex)
return ( return (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<div className="flex-1 bg-surface-2 rounded-full h-6 overflow-hidden relative"> <div className="flex-1 bg-surface-2 rounded-full h-6 overflow-hidden relative">
<div <div
className={`h-full rounded-full ${color ?? ''}`} className="h-full rounded-full"
style={{ style={{
width: `${Math.max(width, 1)}%`, width: `${Math.max(width, 1)}%`,
...(colorHex ? { backgroundColor: colorHex } : {}), backgroundColor: colorHex,
}} }}
/> />
<span <span
className={`absolute inset-0 flex items-center px-3 text-xs font-medium capitalize truncate ${ className={`absolute inset-0 flex items-center px-3 text-xs font-medium capitalize truncate ${
isLight ? 'text-gray-900 dark:text-gray-900' : 'text-text-primary' useDark ? 'text-gray-900' : 'text-white'
}`} }`}
style={{ style={{
textShadow: isLight ? '0 0 4px rgba(255,255,255,0.8)' : '0 0 4px rgba(0,0,0,0.3)', textShadow: useDark ? '0 0 4px rgba(255,255,255,0.8)' : '0 0 4px rgba(0,0,0,0.5)',
}} }}
> >
{label} {label}
@@ -166,7 +172,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
label={g.gameName} label={g.gameName}
value={g.count} value={g.count}
max={gameMax} max={gameMax}
{...(g.gameColor ? { colorHex: g.gameColor } : { color: 'bg-blue-500' })} colorHex={g.gameColor ?? '#3b82f6'}
/> />
))} ))}
</div> </div>
@@ -258,7 +264,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
label={t.type} label={t.type}
value={t.count} value={t.count}
max={typeMax} max={typeMax}
color={typeBarColors[t.type] ?? 'bg-gray-500'} colorHex={typeBarColors[t.type] ?? '#6b7280'}
/> />
))} ))}
</div> </div>

View File

@@ -75,9 +75,9 @@ export function AdminEvolutions() {
return ( return (
<div> <div>
<div className="flex justify-between items-center mb-4"> <div className="flex flex-wrap justify-between items-center gap-2 mb-4">
<h2 className="text-xl font-semibold">Evolutions</h2> <h2 className="text-xl font-semibold">Evolutions</h2>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
<button <button
onClick={async () => { onClick={async () => {
const data = await exportEvolutions() const data = await exportEvolutions()
@@ -102,7 +102,7 @@ export function AdminEvolutions() {
</div> </div>
</div> </div>
<div className="mb-4 flex items-center gap-4"> <div className="mb-4 flex flex-wrap items-center gap-4">
<input <input
type="text" type="text"
value={search} value={search}
@@ -114,6 +114,7 @@ export function AdminEvolutions() {
className="w-full max-w-sm px-3 py-2 border rounded-md bg-surface-2 border-border-default" className="w-full max-w-sm px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
<select <select
aria-label="Filter by trigger"
value={triggerFilter} value={triggerFilter}
onChange={(e) => { onChange={(e) => {
setTriggerFilter(e.target.value) setTriggerFilter(e.target.value)

View File

@@ -70,8 +70,9 @@ export function AdminGames() {
</div> </div>
</div> </div>
<div className="mb-4 flex items-center gap-4"> <div className="mb-4 flex flex-wrap items-center gap-4">
<select <select
aria-label="Filter by region"
value={regionFilter} value={regionFilter}
onChange={(e) => setRegionFilter(e.target.value)} onChange={(e) => setRegionFilter(e.target.value)}
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default" className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
@@ -84,6 +85,7 @@ export function AdminGames() {
))} ))}
</select> </select>
<select <select
aria-label="Filter by generation"
value={genFilter} value={genFilter}
onChange={(e) => setGenFilter(e.target.value)} onChange={(e) => setGenFilter(e.target.value)}
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default" className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"

View File

@@ -116,6 +116,7 @@ export function AdminPokemon() {
className="w-full max-w-sm px-3 py-2 border rounded-md bg-surface-2 border-border-default" className="w-full max-w-sm px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
<select <select
aria-label="Filter by type"
value={typeFilter} value={typeFilter}
onChange={(e) => { onChange={(e) => {
setTypeFilter(e.target.value) setTypeFilter(e.target.value)

View File

@@ -0,0 +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(),
})),
})

View File

@@ -0,0 +1,29 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, type RenderOptions } from '@testing-library/react'
import { type ReactElement } from 'react'
import { MemoryRouter } from 'react-router-dom'
export function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
mutations: { retry: false },
},
})
}
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient()
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
)
}
function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
return render(ui, { wrapper: AllProviders, ...options })
}
export * from '@testing-library/react'
export { customRender as render }

View File

@@ -79,6 +79,7 @@ export interface Encounter {
faintLevel: number | null faintLevel: number | null
deathCause: string | null deathCause: string | null
isShiny: boolean isShiny: boolean
origin: string | null
caughtAt: string caughtAt: string
} }

View File

@@ -1,68 +1,61 @@
export interface NuzlockeRules { export interface NuzlockeRules {
// Core rules // Core rules (affect tracker behavior)
firstEncounterOnly: boolean
permadeath: boolean
nicknameRequired: boolean
duplicatesClause: boolean duplicatesClause: boolean
shinyClause: boolean shinyClause: boolean
giftClause: boolean
staticClause: boolean
pinwheelClause: boolean pinwheelClause: boolean
// Difficulty modifiers
hardcoreMode: boolean
levelCaps: boolean levelCaps: boolean
setModeOnly: boolean
// Completion // Playstyle (informational, for stats/categorization)
postGameCompletion: boolean hardcoreMode: boolean
setModeOnly: boolean
bossTeamMatch: boolean
// Variant (changes which Pokemon can appear)
egglocke: boolean
wonderlocke: boolean
randomizer: boolean
// Type restriction (monolocke and variants)
allowedTypes: string[]
} }
/** Keys of NuzlockeRules that are boolean toggles (excludes array fields) */
type BooleanRuleKeys = Exclude<keyof NuzlockeRules, 'allowedTypes'>
export const DEFAULT_RULES: NuzlockeRules = { export const DEFAULT_RULES: NuzlockeRules = {
// Core rules - standard Nuzlocke // Core rules
firstEncounterOnly: true,
permadeath: true,
nicknameRequired: true,
duplicatesClause: true, duplicatesClause: true,
shinyClause: true, shinyClause: true,
giftClause: false,
staticClause: true,
pinwheelClause: true, pinwheelClause: true,
// Difficulty modifiers - off by default
hardcoreMode: false,
levelCaps: false, levelCaps: false,
setModeOnly: false,
// Completion // Playstyle - off by default
postGameCompletion: false, hardcoreMode: false,
setModeOnly: false,
bossTeamMatch: false,
// Variant - off by default
egglocke: false,
wonderlocke: false,
randomizer: false,
// Type restriction - no restriction by default
allowedTypes: [],
} }
export interface RuleDefinition { export interface RuleDefinition {
key: keyof NuzlockeRules key: BooleanRuleKeys
name: string name: string
description: string description: string
category: 'core' | 'difficulty' | 'completion' category: 'core' | 'playstyle' | 'variant'
} }
export const RULE_DEFINITIONS: RuleDefinition[] = [ export const RULE_DEFINITIONS: RuleDefinition[] = [
// Core rules // Core rules
{
key: 'firstEncounterOnly',
name: 'First Encounter Only',
description:
'You may only catch the first Pokémon encountered in each area. If you fail to catch it, you get nothing from that area.',
category: 'core',
},
{
key: 'permadeath',
name: 'Permadeath',
description:
'If a Pokémon faints, it is considered dead and must be released or permanently boxed.',
category: 'core',
},
{
key: 'nicknameRequired',
name: 'Nickname Required',
description: 'All caught Pokémon must be given a nickname to form a stronger bond.',
category: 'core',
},
{ {
key: 'duplicatesClause', key: 'duplicatesClause',
name: 'Duplicates Clause', name: 'Duplicates Clause',
@@ -77,6 +70,20 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
'Shiny Pokémon may always be caught, regardless of whether they are your first encounter.', 'Shiny Pokémon may always be caught, regardless of whether they are your first encounter.',
category: 'core', category: 'core',
}, },
{
key: 'giftClause',
name: 'Gift Clause',
description:
"In-game gift Pokémon (starters, trades, fossils) do not count against a location's encounter limit.",
category: 'core',
},
{
key: 'staticClause',
name: 'Static Clause',
description:
'Static encounters (legendaries, scripted Pokémon) are available in the encounter selector. Disable to skip them and treat the next wild encounter as your pick.',
category: 'core',
},
{ {
key: 'pinwheelClause', key: 'pinwheelClause',
name: 'Pinwheel Clause', name: 'Pinwheel Clause',
@@ -84,35 +91,56 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
'Sub-zones within a location group each get their own encounter instead of sharing one.', 'Sub-zones within a location group each get their own encounter instead of sharing one.',
category: 'core', category: 'core',
}, },
// Difficulty modifiers
{
key: 'hardcoreMode',
name: 'Hardcore Mode',
description: 'No items may be used during battle. Held items are still allowed.',
category: 'difficulty',
},
{ {
key: 'levelCaps', key: 'levelCaps',
name: 'Level Caps', name: 'Level Caps',
description: description:
"Your Pokémon cannot exceed the level of the next Gym Leader's highest-level Pokémon before challenging them.", "Your Pokémon cannot exceed the level of the next Gym Leader's highest-level Pokémon before challenging them.",
category: 'difficulty', category: 'core',
},
// Playstyle
{
key: 'hardcoreMode',
name: 'Hardcore Mode',
description: 'No items may be used during battle. Held items are still allowed.',
category: 'playstyle',
}, },
{ {
key: 'setModeOnly', key: 'setModeOnly',
name: 'Set Mode Only', name: 'Set Mode Only',
description: description:
'The game must be played in "Set" battle style, meaning you cannot switch Pokémon after knocking out an opponent.', 'The game must be played in "Set" battle style, meaning you cannot switch Pokémon after knocking out an opponent.',
category: 'difficulty', category: 'playstyle',
},
{
key: 'bossTeamMatch',
name: 'Boss Team Match',
description:
'Limit your active party to the same number of Pokémon as the boss you are challenging.',
category: 'playstyle',
}, },
// Completion // Variant
{ {
key: 'postGameCompletion', key: 'egglocke',
name: 'Post-Game Completion', name: 'Egglocke',
description: description:
'The run continues into post-game content instead of ending after the Champion is defeated.', 'All caught Pokémon are replaced with traded eggs. The encounter selector shows all Pokémon since the hatched species is unknown.',
category: 'completion', category: 'variant',
},
{
key: 'wonderlocke',
name: 'Wonderlocke',
description:
'All caught Pokémon are Wonder Traded away. The encounter selector shows all Pokémon since the received species is unknown.',
category: 'variant',
},
{
key: 'randomizer',
name: 'Randomizer',
description:
"The ROM's wild Pokémon are randomized, so the encounter selector shows all Pokémon instead of the game's regional dex.",
category: 'variant',
}, },
] ]

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)
})
})

View File

@@ -0,0 +1,51 @@
import { formatEvolutionMethod } from './formatEvolution'
const base = { minLevel: null, item: null, heldItem: null, condition: null }
describe('formatEvolutionMethod', () => {
it('formats level-up with a min level', () => {
expect(formatEvolutionMethod({ ...base, trigger: 'level-up', minLevel: 16 })).toBe('Level 16')
})
it('formats level-up without a min level', () => {
expect(formatEvolutionMethod({ ...base, trigger: 'level-up' })).toBe('Level up')
})
it('formats use-item trigger', () => {
expect(formatEvolutionMethod({ ...base, trigger: 'use-item', item: 'fire-stone' })).toBe(
'Fire Stone'
)
})
it('formats trade trigger', () => {
expect(formatEvolutionMethod({ ...base, trigger: 'trade' })).toBe('Trade')
})
it('formats unknown trigger by capitalizing words', () => {
expect(formatEvolutionMethod({ ...base, trigger: 'shed-skin' })).toBe('Shed Skin')
})
it('appends held item', () => {
expect(formatEvolutionMethod({ ...base, trigger: 'trade', heldItem: 'metal-coat' })).toBe(
'Trade, holding Metal Coat'
)
})
it('appends condition', () => {
expect(
formatEvolutionMethod({ ...base, trigger: 'level-up', minLevel: 20, condition: 'at night' })
).toBe('Level 20, at night')
})
it('combines all parts', () => {
expect(
formatEvolutionMethod({
trigger: 'level-up',
minLevel: 25,
item: null,
heldItem: 'kings-rock',
condition: 'high friendship',
})
).toBe('Level 25, holding Kings Rock, high friendship')
})
})

View File

@@ -5,7 +5,7 @@
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */

View File

@@ -22,5 +22,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts", "playwright.config.ts"]
} }

Some files were not shown because too many files have changed in this diff Show More