Compare commits

...

23 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
70 changed files with 5746 additions and 747 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,9 +0,0 @@
---
# nuzlocke-tracker-1qzo
title: Fix WCAG AA color contrast violations
status: completed
type: bug
priority: high
created_at: 2026-02-20T19:19:32Z
updated_at: 2026-02-20T19:20:25Z
---

View File

@@ -1,53 +0,0 @@
---
# nuzlocke-tracker-49xj
title: Overhaul Nuzlocke Rules System
status: completed
type: epic
priority: normal
created_at: 2026-02-20T13:22:23Z
updated_at: 2026-02-21T11:23:31Z
---
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).
### Complex rules (need design work)
These need more complex logic and are tracked as draft sub-tasks:
- Type Restrictions (Monolocke) — bs0y
- Team Size Limit — fv7w
- Static/Legendary Clause — knnc
## Children
Work is tracked in sub-tasks:
- **o7r8** — Remove unused nuzlocke rules
- **fitk** — Add egglocke, wonderlocke, and randomizer rules
- **sij8** — Add gift clause rule
- **bs0y** — Add type restriction rules (monolocke) *(draft)*
- **fv7w** — Add team size limit rule *(draft)*
- **knnc** — Add static/legendary clause rule *(draft)*

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,33 +0,0 @@
---
# nuzlocke-tracker-bs0y
title: Add type restriction rules (monolocke)
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:16Z
updated_at: 2026-02-21T11:22:16Z
parent: nuzlocke-tracker-49xj
---
Restrict team composition to specific types (monolocke and similar variants).
## Design Decisions
**Type selection:** Multi-select from the 18 standard Pokemon types. A monolocke selects one type; multi-type variants (e.g., "fire and water only") select multiple.
**Dual-type matching:** A Pokemon qualifies if at least one of its types is in the allowed set. This matches the community standard for monolocke — e.g., in a Fire monolocke, Charizard (Fire/Flying) is allowed because it has Fire.
**Enforcement:** Soft enforcement via UI warnings, not hard blocks. The tracker warns when a caught Pokemon doesn't match the allowed types but doesn't prevent logging it. Reason: players sometimes need to use HM slaves or have edge cases the tracker shouldn't block.
**Data model:** Add `allowedTypes: string[]` to `NuzlockeRules`. Empty array means no restriction (disabled). This keeps it in the existing JSONB rules blob on the run.
**UI:** Add a "Type Restrictions" section to `RulesConfiguration` with a multi-select type picker (reuse the type badge styling from `TypeBadge`). Show a warning badge on encounters that don't match.
## Checklist
- [x] Add `allowedTypes: string[]` to `NuzlockeRules` interface (default: `[]`)
- [x] Add a new `BooleanRuleKeys` type to `RuleDefinition` to exclude non-boolean fields
- [x] Add type multi-select UI to `RulesConfiguration` (shown when allowedTypes toggle is on)
- [x] Show warning indicator on `PokemonCard` and encounter list for Pokemon that don't match allowed types
- [x] Add `RuleBadge` display for active type restriction (e.g., "Monolocke: Fire")
- [x] Update `RuleBadges` to handle `allowedTypes` separately from boolean rules

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: completed
type: feature
priority: normal
created_at: 2026-02-17T19:16:39Z
updated_at: 2026-02-20T19:05:21Z
---
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)
- [x] Add dark/light mode toggle to nav
- [x] Polish hover states and transitions across all interactive elements
- [x] Add automated Playwright accessibility and mobile layout tests
- [x] Verify accessibility (contrast ratios, focus indicators)
- [x] 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,10 +0,0 @@
---
# nuzlocke-tracker-fitk
title: Add egglocke, wonderlocke, and randomizer rules
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:05Z
updated_at: 2026-02-20T20:31:29Z
parent: nuzlocke-tracker-49xj
---

View File

@@ -1,29 +0,0 @@
---
# nuzlocke-tracker-fv7w
title: Add boss team match rule
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:22Z
updated_at: 2026-02-20T21:03:20Z
parent: nuzlocke-tracker-49xj
---
When enabled, hint to the player that they should limit their active party to the same number of Pokemon as the next boss fight. This is a self-imposed difficulty rule — the tracker cannot enforce it since it doesn't track the active party, but it can surface the information.
## Design
**Rule:** Add `bossTeamMatch: boolean` to `NuzlockeRules` (default: `false`, category: `playstyle`).
**Display:** When enabled and the sticky boss banner is shown, add a hint next to the boss name showing their team size, e.g. "Next: Brock (2 Pokemon — match their team)". This reuses the existing `nextBoss` and its `pokemon` array.
**Variant bosses:** Some bosses have conditional teams (e.g. rival starter choice). Use the same logic as `BossTeamPreview`: count pokemon without a `conditionLabel` plus those matching the auto-detected variant (via `matchVariant`). Falls back to first variant if no match is detected.
**Scope:** Frontend-only. No backend or data model changes needed.
## Checklist
- [x] Add `bossTeamMatch: boolean` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
- [x] Add `RuleDefinition` entry (category: `playstyle`)
- [x] Show boss team size hint in the sticky level cap banner when the rule is enabled
- [x] Handle variant boss teams (use auto-matched variant count when available)

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

@@ -1,38 +0,0 @@
---
# nuzlocke-tracker-knnc
title: Add static encounter filter rule
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:27Z
updated_at: 2026-02-21T11:04:45Z
parent: nuzlocke-tracker-49xj
---
Control whether static encounters are available in the encounter selector. Static encounters already exist in the route encounter tables (e.g., Zapdos in Power Plant, Snorlax on Route 7 in X/Y). This rule acts as a display filter, not a route-lock bypass like gift clause.
## Motivation
Static encounters can feel unfair in nuzlockes because they are deterministic — the player is forced to pick a specific Pokemon rather than getting the randomness that makes nuzlockes fun. Example: Snorlax blocks Route 7 in X/Y. By definition it is the first encounter, but being forced to take it reduces variety.
Some static encounters are also overpowered (legendaries), which some players want to avoid.
## Design
**Rule:** `staticClause: boolean` (default: true — static encounters enabled by default). When disabled, encounters with a `static` encounter method are hidden or grayed out in the encounter selector, so the player skips them and gets a different first encounter.
**This is NOT like gift clause.** There is no dual-encounter per route. Disabling static encounters simply filters them out of the available encounter pool for a location. The player still gets one encounter per area — just not the static one.
**Encounter method:** The existing encounter tables already include static encounters (e.g., Zapdos in Power Plant). The `static` encounter method may already exist in seed data — verify before adding. If not present, add it to seed data and `METHOD_CONFIG` / `METHOD_ORDER`.
**Frontend behavior:**
- When `staticClause` is **enabled** (default): static encounters appear normally in the encounter selector
- When `staticClause` is **disabled**: static encounters are hidden or visually grayed out in the encounter selector, preventing the player from selecting them
## Checklist
- [x] Verify `static` encounter method exists in seed data; add to `METHOD_CONFIG` / `METHOD_ORDER` if missing
- [x] Add `staticClause` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: true)
- [x] Add `RuleDefinition` entry under `core` category
- [x] Frontend: filter or gray out static encounters in encounter selector when `staticClause` is disabled
- [x] Backend seed data: add `staticClause` to `DEFAULT_RULES` in `inject_test_data.py`

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,26 +0,0 @@
---
# nuzlocke-tracker-o7r8
title: Remove unused nuzlocke rules
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:55:59Z
updated_at: 2026-02-20T20:04:33Z
parent: nuzlocke-tracker-49xj
---
Remove 5 rules that either define what a nuzlocke is (always true) or don't affect tracker behavior:
- `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
## Checklist
- [x] Remove from `NuzlockeRules` interface and `DEFAULT_RULES`
- [x] Remove their entries from `RULE_DEFINITIONS`
- [x] Update `RulesConfiguration`, `RuleToggle`, and `RuleBadges` components as needed
- [x] Update `NewRun.tsx` and `NewGenlocke.tsx` if they reference removed rules
- [x] Verify backend encounter logic still works (uses `.get()` with defaults)
- [x] Update backend test seed data if it references removed rules

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,26 +0,0 @@
---
# nuzlocke-tracker-sij8
title: Add gift clause rule
status: completed
type: feature
priority: normal
created_at: 2026-02-20T19:56:10Z
updated_at: 2026-02-20T20:55:23Z
parent: nuzlocke-tracker-49xj
---
Add a new giftClause boolean rule: in-game gift Pokemon are free and do not count against the area's encounter limit. When enabled, a location with a gift allows both the gift encounter and a regular encounter, in any order.
## Checklist
- [x] Add giftClause to NuzlockeRules interface and DEFAULT_RULES (default: false)
- [x] Add RuleDefinition entry with core category
- [x] Add origin column to Encounter model + alembic migration
- [x] Add origin to EncounterResponse schema and frontend Encounter type
- [x] Persist origin when creating encounters (frontend sends, backend stores)
- [x] Backend: gift-origin encounters bypass route-lock check (skip_route_lock)
- [x] Backend: existing gift encounters excluded from route-lock query
- [x] Frontend: split encounterByRoute into regular and gift maps
- [x] Frontend: routes with only gift encounters remain clickable for new encounters
- [x] Frontend: gift encounters displayed on route cards with (gift) label
- [x] Frontend: route filtering accounts for gift encounters

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
jobs:
backend-lint:
backend-tests:
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:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
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
- name: Install uv and Python
run: |
bash <(curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
sudo mv actionlint /usr/local/bin/
- name: Lint GitHub Actions
run: actionlint
- name: Audit GitHub Actions security
run: pipx run zizmor .github/workflows/
curl -LsSf https://astral.sh/uv/install.sh | sh
source "$HOME/.local/bin/env"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
uv python install 3.14
- name: Run tests
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
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
@@ -67,12 +66,6 @@ jobs:
- name: Install dependencies
run: npm ci
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
- name: Check formatting
run: npx oxfmt --check "src/"
working-directory: frontend
- name: Type check
run: npx tsc -b
- name: Run tests
run: npm test
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]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
testpaths = ["tests"]

View File

@@ -2,6 +2,7 @@
Usage:
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 --export # Export all seed data from DB to JSON files
"""
@@ -21,7 +22,8 @@ async def main():
await export_all()
return
await seed()
prune = "--prune" in sys.argv
await seed(prune=prune)
if "--verify" in sys.argv:
await verify()

View File

@@ -1,11 +1,12 @@
"""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.ext.asyncio import AsyncSession
from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon
from app.models.encounter import Encounter
from app.models.evolution import Evolution
from app.models.game import Game
from app.models.pokemon import Pokemon
@@ -124,11 +125,14 @@ async def upsert_routes(
session: AsyncSession,
version_group_id: int,
routes: list[dict],
*,
prune: bool = False,
) -> dict[str, int]:
"""Upsert route records for a version group, return {name: id} mapping.
Handles hierarchical routes: routes with 'children' are parent routes,
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)
for route in routes:
@@ -185,6 +189,43 @@ async def upsert_routes(
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
result = await session.execute(
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],
dex_to_id: dict[int, int],
game_id: int,
*,
prune: bool = False,
) -> 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
for enc in encounters:
pokemon_id = dex_to_id.get(enc["pokeapi_id"])
@@ -245,6 +293,7 @@ async def upsert_route_encounters(
conditions = enc.get("conditions")
if conditions:
for condition_name, rate in conditions.items():
seed_keys.add((pokemon_id, enc["method"], condition_name))
await _upsert_single_encounter(
session,
route_id,
@@ -258,6 +307,7 @@ async def upsert_route_encounters(
)
count += 1
else:
seed_keys.add((pokemon_id, enc["method"], ""))
await _upsert_single_encounter(
session,
route_id,
@@ -270,6 +320,23 @@ async def upsert_route_encounters(
)
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
@@ -280,8 +347,13 @@ async def upsert_bosses(
dex_to_id: dict[int, int],
route_name_to_id: dict[str, int] | None = None,
slug_to_game_id: dict[str, int] | None = None,
*,
prune: bool = False,
) -> 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
for boss in bosses:
# Resolve after_route_name to an ID
@@ -364,6 +436,20 @@ async def upsert_bosses(
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()
return count

View File

@@ -38,9 +38,12 @@ def load_json(filename: str):
return json.load(f)
async def seed():
"""Run the full seed process."""
print("Starting seed...")
async def seed(*, prune: bool = False):
"""Run the full seed process.
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():
# 1. Upsert version groups
@@ -88,7 +91,7 @@ async def seed():
continue
# 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
total_routes += len(route_map)
print(f" {vg_slug}: {len(route_map)} routes")
@@ -119,6 +122,7 @@ async def seed():
route["encounters"],
dex_to_id,
game_id,
prune=prune,
)
total_encounters += enc_count
@@ -137,6 +141,7 @@ async def seed():
child["encounters"],
dex_to_id,
game_id,
prune=prune,
)
total_encounters += enc_count
@@ -160,7 +165,13 @@ async def seed():
route_name_to_id = route_maps_by_vg.get(vg_id, {})
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
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" },
]

View File

@@ -21,7 +21,7 @@ services:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
- "8100:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@test-db:5432/nuzlocke_test
- DEBUG=true

View File

@@ -4,7 +4,8 @@ import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const API_BASE = 'http://localhost:8000/api/v1'
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')
@@ -48,7 +49,7 @@ export default async function globalSetup() {
// 2. Wait for API to be healthy
console.log('[setup] Waiting for API to be ready...')
await waitForApi('http://localhost:8000/')
await waitForApi(`${API_HOST}/`)
// 3. Run migrations
run(`${COMPOSE} exec -T test-api alembic -c /app/alembic.ini upgrade head`)

File diff suppressed because it is too large Load Diff

View File

@@ -29,10 +29,14 @@
"@axe-core/playwright": "4.11.1",
"@playwright/test": "1.58.2",
"@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/react": "19.2.11",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.3",
"jsdom": "^28.1.0",
"oxfmt": "0.33.0",
"oxlint": "1.48.0",
"tailwindcss": "4.1.18",

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -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,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"],
"skipLibCheck": true,
/* Bundler mode */

View File

@@ -8,11 +8,14 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
exclude: ['**/node_modules/**', '**/e2e/**'],
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
target: process.env.E2E_API_URL || 'http://localhost:8000',
changeOrigin: true,
},
},

View File

@@ -43,5 +43,21 @@ hooks = [
language = "system",
files = '^frontend/src/.*\.(ts|tsx)$',
pass_filenames = false
},
{
id = "actionlint",
name = "actionlint",
entry = "bash -c 'actionlint'",
language = "system",
files = '^.github/workflows/.*.(yml|yaml)',
pass_filenames = false
},
{
id = "zizmor",
name = "zizmor",
entry = "bash -c 'zizmor .github/workflows/'",
language = "system",
files = '^.github/workflows/.*.(yml|yaml)',
pass_filenames = false
}
]

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}