Compare commits
37 Commits
cb35bf161e
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70aa1156f5 | ||
| 1513bb3658 | |||
| 3b63285bd1 | |||
| 4f0f881736 | |||
| dde20c932b | |||
| efa0b5f855 | |||
| d535433583 | |||
| bf4302cdd4 | |||
| 9a8a4f75f9 | |||
| 00734ee233 | |||
| b50e9160ba | |||
| f6bcb1fbe5 | |||
| bf3a3d3329 | |||
| 9aaa95a1c7 | |||
| 0d2f419c6a | |||
| c80d7d0802 | |||
| ee5bf03f19 | |||
| 34835abe0c | |||
| ca736e0f39 | |||
| d6a0b60585 | |||
| 79eabf4f9f | |||
| 4aae12cd72 | |||
| b0ac3714a9 | |||
| 16f9e68821 | |||
| 993ad09d9c | |||
| 85fef68dae | |||
| aea5d1d84d | |||
| 347c25e8ed | |||
| 6968d35a33 | |||
| 18cc116348 | |||
| ed1f7ad3d0 | |||
| 2298c32691 | |||
| e25d1cf24c | |||
| 4fbfcf9b29 | |||
| a12478f24b | |||
| a7ec49fcad | |||
| a381633413 |
@@ -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
|
||||
@@ -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
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
# nuzlocke-tracker-49xj
|
||||
title: Overhaul Nuzlocke Rules System
|
||||
status: todo
|
||||
type: epic
|
||||
priority: normal
|
||||
created_at: 2026-02-20T13:22:23Z
|
||||
updated_at: 2026-02-20T13:27:58Z
|
||||
---
|
||||
|
||||
Audit and overhaul the nuzlocke rules configuration. The current rules are a flat collection of boolean settings, some of which don't meaningfully affect tracker behavior. This epic cleans up existing rules and adds new rules for popular variants with actual tracker logic.
|
||||
|
||||
## Scope
|
||||
|
||||
### Rules to REMOVE (5)
|
||||
These rules either define what a nuzlocke is (always true) or don't affect tracker behavior at all:
|
||||
- `firstEncounterOnly` — implicit; it's a nuzlocke tracker
|
||||
- `permadeath` — implicit; it's a nuzlocke tracker
|
||||
- `nicknameRequired` — not enforced or tracked
|
||||
- `setModeOnly` — not enforced or tracked
|
||||
- `postGameCompletion` — not enforced or tracked
|
||||
|
||||
### Rules to KEEP (5)
|
||||
These actively affect tracker logic:
|
||||
- `duplicatesClause` — used in encounter creation and bulk randomization
|
||||
- `shinyClause` — used in encounter creation (bypass route-lock)
|
||||
- `pinwheelClause` — used for zone-based encounter logic
|
||||
- `hardcoreMode` — used in BossDefeatModal (auto-win, 1 attempt)
|
||||
- `levelCaps` — displayed in sticky bar on encounters page
|
||||
|
||||
### New rules to ADD (4)
|
||||
These are boolean flags with real tracker logic:
|
||||
|
||||
- `egglocke` — all caught Pokemon are replaced with traded eggs. When enabled, encounter Pokemon selection should allow picking from ALL Pokemon (not just the game's regional dex), similar to the admin panel encounter creation / boss team creation flow.
|
||||
- `wonderlocke` — all caught Pokemon are Wonder Traded away. Same as egglocke: encounter Pokemon selection allows picking from ALL Pokemon.
|
||||
- `randomizer` — the run uses a randomized ROM. Same behavior: encounter Pokemon selection allows picking from ALL Pokemon since the dex is randomized.
|
||||
- `giftClause` — in-game gift Pokemon are free and do not count against the area's encounter limit. When enabled, gift-origin encounters should bypass the route-lock check (similar to how shinyClause bypasses it for shinies).
|
||||
|
||||
### Follow-up beans to CREATE
|
||||
Rules that need more complex logic, tracked separately:
|
||||
- Type Restrictions (Monolocke) — restrict team to specific types
|
||||
- Team Size Limit — cap active party size with warnings
|
||||
- Static/Legendary Clause — whether static encounters count or are banned
|
||||
|
||||
## Checklist
|
||||
|
||||
### Cleanup: remove unused rules
|
||||
- [ ] Remove `firstEncounterOnly`, `permadeath`, `nicknameRequired`, `setModeOnly`, `postGameCompletion` from `NuzlockeRules` interface and `DEFAULT_RULES`
|
||||
- [ ] Remove their entries from `RULE_DEFINITIONS`
|
||||
|
||||
### Add new rules: frontend types
|
||||
- [ ] Add `egglocke`, `wonderlocke`, `randomizer`, `giftClause` to `NuzlockeRules` interface and `DEFAULT_RULES` (default: false)
|
||||
- [ ] Add `RuleDefinition` entries for the new rules with appropriate categories
|
||||
|
||||
### Add new rules: egglocke / wonderlocke / randomizer logic
|
||||
- [ ] When any of `egglocke`, `wonderlocke`, or `randomizer` is enabled, the encounter Pokemon selector should allow picking from ALL Pokemon (not just the game's regional dex)
|
||||
- [ ] Reuse the existing "all Pokemon" selector pattern used in admin panel encounter creation and boss team creation
|
||||
|
||||
### Add new rules: giftClause logic
|
||||
- [ ] When `giftClause` is enabled, gift-origin encounters should bypass the route-lock check in the backend (similar to shinyClause bypass)
|
||||
- [ ] Update the encounter creation endpoint to check for giftClause when origin is "gift"
|
||||
|
||||
### Update components and pages
|
||||
- [ ] Update `RulesConfiguration`, `RuleToggle`, and `RuleBadges` components as needed
|
||||
- [ ] Update `NewRun.tsx` and `NewGenlocke.tsx` if they reference removed rules
|
||||
|
||||
### Backend and data
|
||||
- [ ] Verify backend encounter logic still works for removed rules (uses `.get()` with defaults)
|
||||
- [ ] Update backend test seed data if it references removed rules
|
||||
|
||||
### Follow-ups
|
||||
- [ ] Create follow-up beans for: Type Restrictions, Team Size Limit, Static/Legendary Clause
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
# nuzlocke-tracker-dpw7
|
||||
title: Modernize website design and look-and-feel
|
||||
status: in-progress
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-02-17T19:16:39Z
|
||||
updated_at: 2026-02-17T21:04:45Z
|
||||
---
|
||||
|
||||
Overhaul the UI to a dark-first, techy aesthetic with a cohesive brand identity derived from the ANT steel ant logo.
|
||||
|
||||
## Design direction
|
||||
|
||||
**Dark & techy** — dark-first surfaces, subtle glow/accent effects, code-editor-influenced aesthetic. Think GitHub dark, Discord, or Linear dark mode. Light mode becomes the secondary theme.
|
||||
|
||||
## 1. Brand palette + Tailwind theme
|
||||
|
||||
Define custom Tailwind v4 theme tokens in `index.css` using `@theme`:
|
||||
|
||||
- **Surfaces:** dark navy/charcoal base (`#0f1117`, `#161b22`, `#1c2128`) with layered elevation (darker = further back, lighter = elevated)
|
||||
- **Accent:** steel blue from the logo (`#395E73`, `#7EB0CE`) as the primary interactive color
|
||||
- **Text:** off-white primary (`#e6edf3`), muted gray secondary (`#7d8590`)
|
||||
- **Status colors:** keep green/red/blue semantics but shift to darker, more saturated variants that work on dark surfaces
|
||||
- **Borders:** subtle (`rgba(255,255,255,0.08)`) instead of gray-200/700
|
||||
|
||||
Replace ad-hoc Tailwind color classes throughout all components with theme tokens.
|
||||
|
||||
## 2. Typography
|
||||
|
||||
Self-host **Geist** (or Inter/JetBrains Mono pairing):
|
||||
|
||||
- Geist Sans for UI text (headings, labels, body)
|
||||
- Geist Mono for data-heavy elements (stats numbers, encounter rates, levels)
|
||||
- Set up via `@font-face` in `index.css`, configure in Tailwind `@theme`
|
||||
- Establish clear size/weight hierarchy: page titles (2xl bold), section headers (lg semibold), body (sm regular), labels (xs medium)
|
||||
|
||||
## 3. Navigation redesign
|
||||
|
||||
- Add the ant SVG logo mark next to "ANT" in the nav
|
||||
- Active route indicator (accent-colored underline or background highlight)
|
||||
- Subtle bottom border glow or gradient accent line
|
||||
- Slightly translucent/backdrop-blur nav background for depth
|
||||
- Better mobile menu transitions (slide or fade instead of instant toggle)
|
||||
|
||||
## 4. Home page hero
|
||||
|
||||
- Full-width dark gradient hero section with the ant logo as a subtle watermark/background element
|
||||
- Tagline with stronger typography hierarchy
|
||||
- Stats summary (total runs, completion rate) as glowing stat pills if the user has data
|
||||
- CTA button with accent glow/gradient
|
||||
|
||||
## 5. Cards & surfaces
|
||||
|
||||
- Dark elevated cards (`bg-[#161b22]`) with subtle border (`border-white/[0.06]`)
|
||||
- Hover state: slight border brightness increase + subtle shadow glow in accent color
|
||||
- Active/selected states with accent border
|
||||
- Pokemon cards: dark backgrounds make sprites pop better, accent ring on hover
|
||||
- Stat cards: accent-colored left border or top gradient
|
||||
- Modals: dark overlay with backdrop-blur, card-style modal surface
|
||||
|
||||
## 6. Status indicators & badges
|
||||
|
||||
- Status badges: more vibrant on dark backgrounds (alive=emerald glow, dead=red glow, caught=blue)
|
||||
- Type badges: use the established Pokemon type colors but tuned for dark surfaces
|
||||
- Encounter method badges: same treatment
|
||||
- Pulse animation on active run indicators
|
||||
|
||||
## 7. Micro-interactions
|
||||
|
||||
- Smooth transitions on all interactive elements (`transition-all duration-150`)
|
||||
- Hover lift on cards (`hover:-translate-y-0.5`)
|
||||
- Button press feedback (`active:scale-[0.98]`)
|
||||
- Loading spinners in accent color
|
||||
- Skeleton loading states for data-heavy pages
|
||||
|
||||
## 8. Dark/light mode
|
||||
|
||||
- Dark is the default and primary design target
|
||||
- Light mode: invert surfaces to white/gray-50, keep accent colors, adjust contrast
|
||||
- Toggle in nav (sun/moon icon)
|
||||
- Persist preference in localStorage, respect `prefers-color-scheme`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Define Tailwind v4 `@theme` tokens (colors, fonts, spacing) in `index.css`
|
||||
- [x] Self-host Geist font family, configure in theme
|
||||
- [x] Redesign nav bar (logo mark, active states, backdrop blur, dark surface)
|
||||
- [x] Redesign home page hero section
|
||||
- [x] Update card/surface styles globally (Layout, PokemonCard, StatCard, GameCard)
|
||||
- [x] Update all page-level backgrounds and containers
|
||||
- [x] Update modal styles (EncounterModal, StatusChangeModal, etc.)
|
||||
- [x] Update badge/indicator styles (TypeBadge, RuleBadges, EncounterMethodBadge)
|
||||
- [ ] Add dark/light mode toggle to nav
|
||||
- [x] Polish hover states and transitions across all interactive elements
|
||||
- [ ] Add automated Playwright accessibility and mobile layout tests
|
||||
- [ ] Verify accessibility (contrast ratios, focus indicators)
|
||||
- [ ] Verify mobile layout and touch targets
|
||||
|
||||
## Automated verification approach
|
||||
|
||||
Add a Playwright test suite that covers both accessibility and mobile layout:
|
||||
|
||||
### Accessibility (axe-core + Playwright)
|
||||
- Install `@axe-core/playwright` as a dev dependency
|
||||
- Write a test that visits each major page and runs axe-core
|
||||
- Pages to cover: Home, RunList, RunDashboard, RunEncounters, Stats, NewRun, GenlockeList, GenlockeDetail, NewGenlocke, admin pages
|
||||
- Check for: color contrast (WCAG AA), missing ARIA labels, heading hierarchy, focus indicators, form label associations
|
||||
- Run as part of CI
|
||||
|
||||
### Mobile layout (Playwright viewports)
|
||||
- Test each major page at 3 viewport sizes: mobile (375x667), tablet (768x1024), desktop (1280x800)
|
||||
- Assert no horizontal overflow (`document.documentElement.scrollWidth <= window.innerWidth`)
|
||||
- Assert touch targets are at least 44x44px (axe-core `target-size` rule)
|
||||
- Screenshot each page at each viewport for visual review
|
||||
|
||||
### Implementation
|
||||
- Add test file: `frontend/e2e/accessibility.spec.ts`
|
||||
- Add Playwright config if not present
|
||||
- Add `test:a11y` script to `package.json`
|
||||
|
||||
## Constraints
|
||||
|
||||
- Tailwind-only (no additional CSS frameworks or component libraries)
|
||||
- Self-hosted fonts only (no Google Fonts CDN)
|
||||
- Maintain accessibility (WCAG AA contrast ratios, visible focus indicators)
|
||||
- No performance regression (fonts loaded with `font-display: swap`, no layout shift)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
63
.github/workflows/ci.yml
vendored
@@ -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
@@ -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/
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add origin to encounters
|
||||
|
||||
Revision ID: i0d1e2f3a4b5
|
||||
Revises: h9c0d1e2f3a4
|
||||
Create Date: 2026-02-20 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "i0d1e2f3a4b5"
|
||||
down_revision: str | Sequence[str] | None = "h9c0d1e2f3a4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"encounters",
|
||||
sa.Column("origin", sa.String(20), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("encounters", "origin")
|
||||
@@ -58,12 +58,13 @@ async def create_encounter(
|
||||
detail="Cannot create encounter on a parent route. Use a child route instead.",
|
||||
)
|
||||
|
||||
# Shiny clause: shiny encounters bypass the route-lock check
|
||||
# Shiny/gift clause: certain encounters bypass the route-lock check
|
||||
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
|
||||
skip_route_lock = (data.is_shiny and shiny_clause_on) or data.origin in (
|
||||
"shed_evolution",
|
||||
"egg",
|
||||
"transfer",
|
||||
gift_clause_on = run.rules.get("giftClause", False) if run.rules else False
|
||||
skip_route_lock = (
|
||||
(data.is_shiny and shiny_clause_on)
|
||||
or (data.origin == "gift" and gift_clause_on)
|
||||
or data.origin in ("shed_evolution", "egg", "transfer")
|
||||
)
|
||||
|
||||
# If this route has a parent, check if sibling already has an encounter
|
||||
@@ -93,13 +94,17 @@ async def create_encounter(
|
||||
# Check if any relevant sibling already has an encounter in this run
|
||||
# Exclude transfer-target encounters so they don't block the starter
|
||||
transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
|
||||
existing_encounter = await session.execute(
|
||||
select(Encounter).where(
|
||||
lock_query = select(Encounter).where(
|
||||
Encounter.run_id == run_id,
|
||||
Encounter.route_id.in_(sibling_ids),
|
||||
~Encounter.id.in_(transfer_target_ids),
|
||||
)
|
||||
# Gift-origin encounters don't count toward route lock
|
||||
if gift_clause_on:
|
||||
lock_query = lock_query.where(
|
||||
Encounter.origin.is_(None) | (Encounter.origin != "gift")
|
||||
)
|
||||
existing_encounter = await session.execute(lock_query)
|
||||
if existing_encounter.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
@@ -119,6 +124,7 @@ async def create_encounter(
|
||||
status=data.status,
|
||||
catch_level=data.catch_level,
|
||||
is_shiny=data.is_shiny,
|
||||
origin=data.origin,
|
||||
)
|
||||
session.add(encounter)
|
||||
await session.commit()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -131,6 +131,7 @@ async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
|
||||
async def list_game_routes(
|
||||
game_id: int,
|
||||
flat: bool = False,
|
||||
allowed_types: list[str] | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
@@ -138,13 +139,18 @@ async def list_game_routes(
|
||||
|
||||
By default, returns a hierarchical structure with top-level routes containing
|
||||
nested children. Use `flat=True` to get a flat list of all routes.
|
||||
|
||||
When `allowed_types` is provided, routes with no encounters matching any of
|
||||
those Pokemon types are excluded.
|
||||
"""
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
result = await session.execute(
|
||||
select(Route)
|
||||
.where(Route.version_group_id == vg_id)
|
||||
.options(selectinload(Route.route_encounters))
|
||||
.options(
|
||||
selectinload(Route.route_encounters).selectinload(RouteEncounter.pokemon)
|
||||
)
|
||||
.order_by(Route.order)
|
||||
)
|
||||
all_routes = result.scalars().all()
|
||||
@@ -170,7 +176,14 @@ async def list_game_routes(
|
||||
|
||||
# Determine which routes have encounters for this game
|
||||
def has_encounters(route: Route) -> bool:
|
||||
return any(re.game_id == game_id for re in route.route_encounters)
|
||||
encounters = [re for re in route.route_encounters if re.game_id == game_id]
|
||||
if not encounters:
|
||||
return False
|
||||
if allowed_types:
|
||||
return any(
|
||||
t in allowed_types for re in encounters for t in re.pokemon.types
|
||||
)
|
||||
return True
|
||||
|
||||
# Collect IDs of parent routes that have at least one child with encounters
|
||||
parents_with_children = set()
|
||||
|
||||
@@ -24,6 +24,7 @@ class Encounter(Base):
|
||||
is_shiny: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default=text("false")
|
||||
)
|
||||
origin: Mapped[str | None] = mapped_column(String(20))
|
||||
caught_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ class EncounterResponse(CamelModel):
|
||||
faint_level: int | None
|
||||
death_cause: str | None
|
||||
is_shiny: bool
|
||||
origin: str | None
|
||||
caught_at: datetime
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ RUN_DEFS = [
|
||||
"name": "Unova Adventure",
|
||||
"status": "active",
|
||||
"progress": 0.35,
|
||||
"rules": {},
|
||||
"rules": {"randomizer": True},
|
||||
"started_days_ago": 5,
|
||||
"ended_days_ago": None,
|
||||
},
|
||||
@@ -142,15 +142,19 @@ RUN_DEFS = [
|
||||
|
||||
# Default rules (matches frontend DEFAULT_RULES)
|
||||
DEFAULT_RULES = {
|
||||
"firstEncounterOnly": True,
|
||||
"permadeath": True,
|
||||
"nicknameRequired": True,
|
||||
"duplicatesClause": True,
|
||||
"shinyClause": True,
|
||||
"giftClause": False,
|
||||
"staticClause": True,
|
||||
"pinwheelClause": True,
|
||||
"hardcoreMode": False,
|
||||
"levelCaps": False,
|
||||
"hardcoreMode": False,
|
||||
"setModeOnly": False,
|
||||
"bossTeamMatch": False,
|
||||
"egglocke": False,
|
||||
"wonderlocke": False,
|
||||
"randomizer": False,
|
||||
"allowedTypes": [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
594
backend/tests/test_genlocke_boss.py
Normal 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
|
||||
572
backend/tests/test_pokemon.py
Normal 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
@@ -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
|
||||
306
backend/tests/test_schemas.py
Normal 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
|
||||
174
backend/tests/test_services.py
Normal 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
|
||||
31
backend/tests/test_smoke.py
Normal 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
@@ -0,0 +1,670 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.18.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/41/ab8f624929847b49f84955c594b165855efd829b0c271e1a8cac694138e5/alembic-1.18.3.tar.gz", hash = "sha256:1212aa3778626f2b0f0aa6dd4e99a5f99b94bd25a0c1ac0bba3be65e081e50b0", size = 2052564, upload-time = "2026-01-29T20:24:15.124Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/8e/d79281f323e7469b060f15bd229e48d7cdd219559e67e71c013720a88340/alembic-1.18.3-py3-none-any.whl", hash = "sha256:12a0359bfc068a4ecbb9b3b02cf77856033abfdb59e4a5aca08b7eacd7b74ddd", size = 262282, upload-time = "2026-01-29T20:24:17.488Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "another-nuzlocke-tracker-api"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = "==1.18.3" },
|
||||
{ name = "asyncpg", specifier = "==0.31.0" },
|
||||
{ name = "fastapi", specifier = "==0.128.4" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" },
|
||||
{ name = "pydantic", specifier = "==2.12.5" },
|
||||
{ name = "pydantic-settings", specifier = "==2.12.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
|
||||
{ name = "python-dotenv", specifier = "==1.2.1" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.0" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.46" },
|
||||
{ name = "ty", marker = "extra == 'dev'", specifier = "==0.0.17" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.40.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncpg"
|
||||
version = "0.31.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.128.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/b7/21bf3d694cbff0b7cf5f459981d996c2c15e072bd5ca5609806383947f1e/fastapi-0.128.4.tar.gz", hash = "sha256:d6a2cc4c0edfbb2499f3fdec55ba62e751ee58a6354c50f85ed0dabdfbcfeb60", size = 375898, upload-time = "2026-02-07T08:14:09.616Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8b/c8050e556f5d7a1f33a93c2c94379a0bae23c58a79ad9709d7e052d0c3b8/fastapi-0.128.4-py3-none-any.whl", hash = "sha256:9321282cee605fd2075ccbc95c0f2e549d675c59de4a952bba202cd1730ac66b", size = 103684, upload-time = "2026-02-07T08:14:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.46"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
asyncio = [
|
||||
{ name = "greenlet" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.52.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.40.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
36
docker-compose.test.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
test-db:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=nuzlocke_test
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: "no"
|
||||
|
||||
test-api:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8100:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@test-db:5432/nuzlocke_test
|
||||
- DEBUG=true
|
||||
depends_on:
|
||||
test-db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8000/ || exit 1"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 15
|
||||
restart: "no"
|
||||
6
frontend/.gitignore
vendored
@@ -12,6 +12,12 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Playwright
|
||||
e2e/.fixtures.json
|
||||
e2e/screenshots/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
61
frontend/e2e/accessibility.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { loadFixtures } from './fixtures'
|
||||
|
||||
const fixtures = loadFixtures()
|
||||
|
||||
const pages = [
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'RunList', path: '/runs' },
|
||||
{ name: 'NewRun', path: '/runs/new' },
|
||||
{ name: 'RunEncounters', path: `/runs/${fixtures.runId}` },
|
||||
{ name: 'GenlockeList', path: '/genlockes' },
|
||||
{ name: 'NewGenlocke', path: '/genlockes/new' },
|
||||
{ name: 'GenlockeDetail', path: `/genlockes/${fixtures.genlockeId}` },
|
||||
{ name: 'Stats', path: '/stats' },
|
||||
{ name: 'AdminGames', path: '/admin/games' },
|
||||
{ name: 'AdminPokemon', path: '/admin/pokemon' },
|
||||
{ name: 'AdminEvolutions', path: '/admin/evolutions' },
|
||||
]
|
||||
|
||||
const themes = ['dark', 'light'] as const
|
||||
|
||||
for (const theme of themes) {
|
||||
test.describe(`Accessibility — ${theme} mode`, () => {
|
||||
test.use({
|
||||
storageState: undefined,
|
||||
})
|
||||
|
||||
for (const { name, path } of pages) {
|
||||
test(`${name} (${path}) has no WCAG violations`, async ({ page }) => {
|
||||
// Set theme before navigation
|
||||
await page.addInitScript((t) => {
|
||||
localStorage.setItem('ant-theme', t)
|
||||
}, theme)
|
||||
|
||||
await page.goto(path, { waitUntil: 'networkidle' })
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.analyze()
|
||||
|
||||
const violations = results.violations.map((v) => ({
|
||||
id: v.id,
|
||||
impact: v.impact,
|
||||
description: v.description,
|
||||
nodes: v.nodes.map((n) => ({
|
||||
html: n.html,
|
||||
target: n.target,
|
||||
failureSummary: n.failureSummary,
|
||||
})),
|
||||
}))
|
||||
|
||||
expect(
|
||||
violations,
|
||||
`${name} (${theme}): ${violations.length} violation(s):\n${JSON.stringify(violations, null, 2)}`,
|
||||
).toHaveLength(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
20
frontend/e2e/fixtures.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
interface Fixtures {
|
||||
gameId: number
|
||||
runId: number
|
||||
genlockeId: number
|
||||
}
|
||||
|
||||
let cached: Fixtures | null = null
|
||||
|
||||
export function loadFixtures(): Fixtures {
|
||||
if (cached) return cached
|
||||
const raw = readFileSync(resolve(__dirname, '.fixtures.json'), 'utf-8')
|
||||
cached = JSON.parse(raw) as Fixtures
|
||||
return cached
|
||||
}
|
||||
124
frontend/e2e/global-setup.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const API_HOST = process.env.E2E_API_URL || 'http://localhost:8100'
|
||||
const API_BASE = `${API_HOST}/api/v1`
|
||||
const COMPOSE_FILE = resolve(__dirname, '../../docker-compose.test.yml')
|
||||
const COMPOSE = `docker compose -p nuzlocke-test -f ${COMPOSE_FILE}`
|
||||
const FIXTURES_PATH = resolve(__dirname, '.fixtures.json')
|
||||
|
||||
function run(cmd: string): string {
|
||||
console.log(`[setup] ${cmd}`)
|
||||
return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'inherit'] })
|
||||
}
|
||||
|
||||
async function waitForApi(url: string, maxAttempts = 30): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.ok) return
|
||||
} catch {
|
||||
// not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
}
|
||||
throw new Error(`API at ${url} not ready after ${maxAttempts} attempts`)
|
||||
}
|
||||
|
||||
async function api<T>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`API ${options?.method ?? 'GET'} ${path} → ${res.status}: ${body}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
// 1. Start test DB + API
|
||||
run(`${COMPOSE} up -d --build`)
|
||||
|
||||
// 2. Wait for API to be healthy
|
||||
console.log('[setup] Waiting for API to be ready...')
|
||||
await waitForApi(`${API_HOST}/`)
|
||||
|
||||
// 3. Run migrations
|
||||
run(`${COMPOSE} exec -T test-api alembic -c /app/alembic.ini upgrade head`)
|
||||
|
||||
// 4. Seed reference data (run from /app/src where the app package lives)
|
||||
run(`${COMPOSE} exec -T -w /app/src test-api python -m app.seeds`)
|
||||
|
||||
// 5. Create test fixtures via API
|
||||
const games = await api<Array<{ id: number; name: string }>>('/games')
|
||||
const game = games[0]
|
||||
if (!game) throw new Error('No games found after seeding')
|
||||
|
||||
const routes = await api<Array<{ id: number; name: string; parentRouteId: number | null }>>(
|
||||
`/games/${game.id}/routes?flat=true`,
|
||||
)
|
||||
// Pick leaf routes (no children — a route is a leaf if no other route has it as parent)
|
||||
const parentIds = new Set(routes.map((r) => r.parentRouteId).filter(Boolean))
|
||||
const leafRoutes = routes.filter((r) => !parentIds.has(r.id))
|
||||
if (leafRoutes.length < 3) throw new Error(`Need ≥3 leaf routes, found ${leafRoutes.length}`)
|
||||
|
||||
const pokemonRes = await api<{ items: Array<{ id: number; name: string }> }>(
|
||||
'/pokemon?limit=10',
|
||||
)
|
||||
const pokemon = pokemonRes.items
|
||||
if (pokemon.length < 3) throw new Error(`Need ≥3 pokemon, found ${pokemon.length}`)
|
||||
|
||||
// Create a test run
|
||||
const testRun = await api<{ id: number }>('/runs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
gameId: game.id,
|
||||
name: 'E2E Test Run',
|
||||
rules: { duplicatesClause: true, shinyClause: true },
|
||||
}),
|
||||
})
|
||||
|
||||
// Create encounters: caught, fainted, missed
|
||||
const statuses = ['caught', 'fainted', 'missed'] as const
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await api(`/runs/${testRun.id}/encounters`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
routeId: leafRoutes[i]!.id,
|
||||
pokemonId: pokemon[i]!.id,
|
||||
nickname: `Test ${statuses[i]}`,
|
||||
status: statuses[i],
|
||||
catchLevel: statuses[i] === 'missed' ? null : 5 + i * 10,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// Create a genlocke with 2 game legs
|
||||
const secondGame = games[1] ?? game
|
||||
const genlocke = await api<{ id: number }>('/genlockes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: 'E2E Test Genlocke',
|
||||
gameIds: [game.id, secondGame.id],
|
||||
genlockeRules: {},
|
||||
nuzlockeRules: { duplicatesClause: true },
|
||||
}),
|
||||
})
|
||||
|
||||
// 6. Write fixtures file
|
||||
const fixtures = {
|
||||
gameId: game.id,
|
||||
runId: testRun.id,
|
||||
genlockeId: genlocke.id,
|
||||
}
|
||||
writeFileSync(FIXTURES_PATH, JSON.stringify(fixtures, null, 2))
|
||||
console.log('[setup] Fixtures written:', fixtures)
|
||||
}
|
||||
24
frontend/e2e/global-teardown.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const COMPOSE_FILE = resolve(__dirname, '../../docker-compose.test.yml')
|
||||
const COMPOSE = `docker compose -p nuzlocke-test -f ${COMPOSE_FILE}`
|
||||
const FIXTURES_PATH = resolve(__dirname, '.fixtures.json')
|
||||
|
||||
export default async function globalTeardown() {
|
||||
console.log('[teardown] Stopping test containers...')
|
||||
execSync(`${COMPOSE} down -v --remove-orphans`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
try {
|
||||
rmSync(FIXTURES_PATH)
|
||||
console.log('[teardown] Removed fixtures file')
|
||||
} catch {
|
||||
// File may not exist if setup failed
|
||||
}
|
||||
}
|
||||
71
frontend/e2e/mobile.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { loadFixtures } from './fixtures'
|
||||
|
||||
const fixtures = loadFixtures()
|
||||
|
||||
const pages = [
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'RunList', path: '/runs' },
|
||||
{ name: 'NewRun', path: '/runs/new' },
|
||||
{ name: 'RunEncounters', path: `/runs/${fixtures.runId}` },
|
||||
{ name: 'GenlockeList', path: '/genlockes' },
|
||||
{ name: 'NewGenlocke', path: '/genlockes/new' },
|
||||
{ name: 'GenlockeDetail', path: `/genlockes/${fixtures.genlockeId}` },
|
||||
{ name: 'Stats', path: '/stats' },
|
||||
{ name: 'AdminGames', path: '/admin/games' },
|
||||
{ name: 'AdminPokemon', path: '/admin/pokemon' },
|
||||
{ name: 'AdminEvolutions', path: '/admin/evolutions' },
|
||||
]
|
||||
|
||||
const viewports = [
|
||||
{ name: 'mobile', width: 375, height: 667 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1280, height: 800 },
|
||||
] as const
|
||||
|
||||
for (const viewport of viewports) {
|
||||
test.describe(`Mobile layout — ${viewport.name} (${viewport.width}x${viewport.height})`, () => {
|
||||
test.use({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
})
|
||||
|
||||
for (const { name, path } of pages) {
|
||||
test(`${name} (${path}) has no overflow or touch target issues`, async ({ page }) => {
|
||||
await page.goto(path, { waitUntil: 'networkidle' })
|
||||
|
||||
// Assert no horizontal overflow
|
||||
const overflow = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
}))
|
||||
expect(
|
||||
overflow.scrollWidth,
|
||||
`${name} at ${viewport.name}: horizontal overflow detected (scrollWidth=${overflow.scrollWidth}, innerWidth=${overflow.innerWidth})`,
|
||||
).toBeLessThanOrEqual(overflow.innerWidth)
|
||||
|
||||
// Run axe-core target-size rule for touch target validation
|
||||
const axeResults = await new AxeBuilder({ page })
|
||||
.withRules(['target-size'])
|
||||
.analyze()
|
||||
|
||||
const violations = axeResults.violations.map((v) => ({
|
||||
id: v.id,
|
||||
impact: v.impact,
|
||||
nodes: v.nodes.length,
|
||||
}))
|
||||
expect(
|
||||
violations,
|
||||
`${name} at ${viewport.name}: ${violations.length} touch target violations:\n${JSON.stringify(violations, null, 2)}`,
|
||||
).toHaveLength(0)
|
||||
|
||||
// Capture full-page screenshot
|
||||
await page.screenshot({
|
||||
path: `e2e/screenshots/${viewport.name}/${name.toLowerCase()}.png`,
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -17,6 +17,15 @@
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('ant-theme');
|
||||
if (t === 'light' || (!t && window.matchMedia('(prefers-color-scheme: light)').matches)) {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
936
frontend/package-lock.json
generated
@@ -11,7 +11,9 @@
|
||||
"format:check": "oxfmt --check src/",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
@@ -24,11 +26,17 @@
|
||||
"sonner": "2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
|
||||
27
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
globalTeardown: './e2e/global-teardown.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 905 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 758 B |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 703 B |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 826 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 707 B |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 754 B |
@@ -13,10 +13,12 @@ export function getGame(id: number): Promise<GameDetail> {
|
||||
return api.get(`/games/${id}`)
|
||||
}
|
||||
|
||||
export function getGameRoutes(gameId: number): Promise<Route[]> {
|
||||
export function getGameRoutes(gameId: number, allowedTypes?: string[]): Promise<Route[]> {
|
||||
// Use flat=true to get all routes in a flat list
|
||||
// The frontend organizes them into hierarchy based on parentRouteId
|
||||
return api.get(`/games/${gameId}/routes?flat=true`)
|
||||
const params = new URLSearchParams({ flat: 'true' })
|
||||
for (const t of allowedTypes ?? []) params.append('allowed_types', t)
|
||||
return api.get(`/games/${gameId}/routes?${params}`)
|
||||
}
|
||||
|
||||
export function getRoutePokemon(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> {
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
const CONDITION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
'Mega Evolution': { label: 'Mega', color: 'bg-fuchsia-900/40 text-fuchsia-300' },
|
||||
Gigantamax: { label: 'G-Max', color: 'bg-red-900/40 text-red-300' },
|
||||
Dynamax: { label: 'D-Max', color: 'bg-rose-900/40 text-rose-300' },
|
||||
Terastallize: { label: 'Tera', color: 'bg-teal-900/40 text-teal-300' },
|
||||
'Mega Evolution': {
|
||||
label: 'Mega',
|
||||
color: 'bg-fuchsia-900/40 text-fuchsia-300 light:bg-fuchsia-100 light:text-fuchsia-700',
|
||||
},
|
||||
Gigantamax: {
|
||||
label: 'G-Max',
|
||||
color: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-700',
|
||||
},
|
||||
Dynamax: {
|
||||
label: 'D-Max',
|
||||
color: 'bg-rose-900/40 text-rose-300 light:bg-rose-100 light:text-rose-700',
|
||||
},
|
||||
Terastallize: {
|
||||
label: 'Tera',
|
||||
color: 'bg-teal-900/40 text-teal-300 light:bg-teal-100 light:text-teal-700',
|
||||
},
|
||||
}
|
||||
|
||||
export function ConditionBadge({
|
||||
|
||||
@@ -1,17 +1,60 @@
|
||||
export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
starter: { label: 'Starter', color: 'bg-yellow-900/40 text-yellow-300' },
|
||||
gift: { label: 'Gift', color: 'bg-pink-900/40 text-pink-300' },
|
||||
fossil: { label: 'Fossil', color: 'bg-amber-900/40 text-amber-300' },
|
||||
trade: { label: 'Trade', color: 'bg-emerald-900/40 text-emerald-300' },
|
||||
walk: { label: 'Grass', color: 'bg-green-900/40 text-green-300' },
|
||||
headbutt: { label: 'Headbutt', color: 'bg-lime-900/40 text-lime-300' },
|
||||
surf: { label: 'Surfing', color: 'bg-blue-900/40 text-blue-300' },
|
||||
'rock-smash': { label: 'Rock Smash', color: 'bg-orange-900/40 text-orange-300' },
|
||||
'old-rod': { label: 'Old Rod', color: 'bg-cyan-900/40 text-cyan-300' },
|
||||
'good-rod': { label: 'Good Rod', color: 'bg-sky-900/40 text-sky-300' },
|
||||
'super-rod': { label: 'Super Rod', color: 'bg-indigo-900/40 text-indigo-300' },
|
||||
horde: { label: 'Horde', color: 'bg-rose-900/40 text-rose-300' },
|
||||
sos: { label: 'SOS', color: 'bg-violet-900/40 text-violet-300' },
|
||||
starter: {
|
||||
label: 'Starter',
|
||||
color: 'bg-yellow-900/40 text-yellow-300 light:bg-yellow-100 light:text-yellow-800',
|
||||
},
|
||||
gift: {
|
||||
label: 'Gift',
|
||||
color: 'bg-pink-900/40 text-pink-300 light:bg-pink-100 light:text-pink-700',
|
||||
},
|
||||
fossil: {
|
||||
label: 'Fossil',
|
||||
color: 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-800',
|
||||
},
|
||||
trade: {
|
||||
label: 'Trade',
|
||||
color: 'bg-emerald-900/40 text-emerald-300 light:bg-emerald-100 light:text-emerald-700',
|
||||
},
|
||||
static: {
|
||||
label: 'Static',
|
||||
color: 'bg-teal-900/40 text-teal-300 light:bg-teal-100 light:text-teal-700',
|
||||
},
|
||||
walk: {
|
||||
label: 'Grass',
|
||||
color: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-700',
|
||||
},
|
||||
headbutt: {
|
||||
label: 'Headbutt',
|
||||
color: 'bg-lime-900/40 text-lime-300 light:bg-lime-100 light:text-lime-800',
|
||||
},
|
||||
surf: {
|
||||
label: 'Surfing',
|
||||
color: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700',
|
||||
},
|
||||
'rock-smash': {
|
||||
label: 'Rock Smash',
|
||||
color: 'bg-orange-900/40 text-orange-300 light:bg-orange-100 light:text-orange-800',
|
||||
},
|
||||
'old-rod': {
|
||||
label: 'Old Rod',
|
||||
color: 'bg-cyan-900/40 text-cyan-300 light:bg-cyan-100 light:text-cyan-700',
|
||||
},
|
||||
'good-rod': {
|
||||
label: 'Good Rod',
|
||||
color: 'bg-sky-900/40 text-sky-300 light:bg-sky-100 light:text-sky-700',
|
||||
},
|
||||
'super-rod': {
|
||||
label: 'Super Rod',
|
||||
color: 'bg-indigo-900/40 text-indigo-300 light:bg-indigo-100 light:text-indigo-700',
|
||||
},
|
||||
horde: {
|
||||
label: 'Horde',
|
||||
color: 'bg-rose-900/40 text-rose-300 light:bg-rose-100 light:text-rose-700',
|
||||
},
|
||||
sos: {
|
||||
label: 'SOS',
|
||||
color: 'bg-violet-900/40 text-violet-300 light:bg-violet-100 light:text-violet-700',
|
||||
},
|
||||
}
|
||||
|
||||
/** Display order for encounter method groups */
|
||||
@@ -20,6 +63,7 @@ export const METHOD_ORDER = [
|
||||
'gift',
|
||||
'fossil',
|
||||
'trade',
|
||||
'static',
|
||||
'walk',
|
||||
'headbutt',
|
||||
'surf',
|
||||
@@ -51,7 +95,7 @@ export function EncounterMethodBadge({
|
||||
}) {
|
||||
const config = METHOD_CONFIG[method]
|
||||
if (!config) return null
|
||||
const sizeClass = size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5'
|
||||
const sizeClass = size === 'xs' ? 'text-[10px] px-1.5 py-0.5' : 'text-xs px-2 py-0.5'
|
||||
return (
|
||||
<span className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}>
|
||||
{config.label}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { useRoutePokemon } from '../hooks/useGames'
|
||||
import { useNameSuggestions } from '../hooks/useRuns'
|
||||
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
|
||||
import type { Route, EncounterDetail, EncounterStatus, RouteEncounterDetail } from '../types'
|
||||
import type {
|
||||
Route,
|
||||
EncounterDetail,
|
||||
EncounterStatus,
|
||||
RouteEncounterDetail,
|
||||
Pokemon,
|
||||
} from '../types'
|
||||
|
||||
interface EncounterModalProps {
|
||||
route: Route
|
||||
@@ -19,6 +26,7 @@ interface EncounterModalProps {
|
||||
nickname?: string | undefined
|
||||
status: EncounterStatus
|
||||
catchLevel?: number | undefined
|
||||
origin?: string | undefined
|
||||
}) => void
|
||||
onUpdate?:
|
||||
| ((data: {
|
||||
@@ -33,6 +41,9 @@ interface EncounterModalProps {
|
||||
| undefined
|
||||
onClose: () => void
|
||||
isPending: boolean
|
||||
useAllPokemon?: boolean | undefined
|
||||
staticClause?: boolean | undefined
|
||||
allowedTypes?: string[] | undefined
|
||||
}
|
||||
|
||||
const statusOptions: {
|
||||
@@ -43,12 +54,14 @@ const statusOptions: {
|
||||
{
|
||||
value: 'caught',
|
||||
label: 'Caught',
|
||||
color: 'bg-green-900/40 text-green-300 border-green-700',
|
||||
color:
|
||||
'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800 border-green-700 light:border-green-300',
|
||||
},
|
||||
{
|
||||
value: 'fainted',
|
||||
label: 'Fainted',
|
||||
color: 'bg-red-900/40 text-red-300 border-red-700',
|
||||
color:
|
||||
'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800 border-red-700 light:border-red-300',
|
||||
},
|
||||
{
|
||||
value: 'missed',
|
||||
@@ -121,7 +134,8 @@ function groupByMethod(
|
||||
} else {
|
||||
// Determine the display rate
|
||||
let displayRate: number | null = null
|
||||
const isSpecial = SPECIAL_METHODS.includes(rp.encounterMethod)
|
||||
const isSpecial =
|
||||
SPECIAL_METHODS.includes(rp.encounterMethod) || rp.encounterMethod === 'static'
|
||||
if (!isSpecial) {
|
||||
if (selectedCondition) {
|
||||
const key = `${rp.pokemonId}:${rp.encounterMethod}`
|
||||
@@ -186,8 +200,14 @@ export function EncounterModal({
|
||||
onUpdate,
|
||||
onClose,
|
||||
isPending,
|
||||
useAllPokemon,
|
||||
staticClause = true,
|
||||
allowedTypes,
|
||||
}: EncounterModalProps) {
|
||||
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(route.id, gameId)
|
||||
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
||||
useAllPokemon ? null : route.id,
|
||||
useAllPokemon ? undefined : gameId
|
||||
)
|
||||
|
||||
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
|
||||
const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught')
|
||||
@@ -197,6 +217,8 @@ export function EncounterModal({
|
||||
const [deathCause, setDeathCause] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCondition, setSelectedCondition] = useState<string | null>(null)
|
||||
const [allPokemonResults, setAllPokemonResults] = useState<Pokemon[]>([])
|
||||
const [isSearchingAll, setIsSearchingAll] = useState(false)
|
||||
|
||||
const isEditing = !!existing
|
||||
|
||||
@@ -216,13 +238,41 @@ export function EncounterModal({
|
||||
}
|
||||
}, [existing, routePokemon])
|
||||
|
||||
// Debounced all-Pokemon search (variant rules)
|
||||
useEffect(() => {
|
||||
if (!useAllPokemon) return
|
||||
|
||||
if (search.length < 2) {
|
||||
setAllPokemonResults([])
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setIsSearchingAll(true)
|
||||
try {
|
||||
const data = await api.get<{ items: Pokemon[] }>(
|
||||
`/pokemon?search=${encodeURIComponent(search)}&limit=20`
|
||||
)
|
||||
setAllPokemonResults(data.items)
|
||||
} catch {
|
||||
setAllPokemonResults([])
|
||||
} finally {
|
||||
setIsSearchingAll(false)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [search, useAllPokemon])
|
||||
|
||||
const availableConditions = useMemo(
|
||||
() => (routePokemon ? getUniqueConditions(routePokemon) : []),
|
||||
[routePokemon]
|
||||
)
|
||||
|
||||
const filteredPokemon = routePokemon?.filter((rp) =>
|
||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
|
||||
const filteredPokemon = routePokemon?.filter(
|
||||
(rp) =>
|
||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
(!allowedTypes?.length || rp.pokemon.types.some((t) => allowedTypes.includes(t)))
|
||||
)
|
||||
|
||||
const groupedPokemon = useMemo(
|
||||
@@ -249,6 +299,7 @@ export function EncounterModal({
|
||||
nickname: nickname || undefined,
|
||||
status,
|
||||
catchLevel: catchLevel ? Number(catchLevel) : undefined,
|
||||
origin: SPECIAL_METHODS.includes(selectedPokemon.encounterMethod) ? 'gift' : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -280,7 +331,110 @@ export function EncounterModal({
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* Pokemon Selection (only for new encounters) */}
|
||||
{!isEditing && (
|
||||
{!isEditing && useAllPokemon && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Pokemon</label>
|
||||
{selectedPokemon ? (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-accent-400 bg-accent-900/20">
|
||||
{selectedPokemon.pokemon.spriteUrl ? (
|
||||
<img
|
||||
src={selectedPokemon.pokemon.spriteUrl}
|
||||
alt={selectedPokemon.pokemon.name}
|
||||
className="w-10 h-10"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
|
||||
{selectedPokemon.pokemon.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium text-text-primary capitalize">
|
||||
{selectedPokemon.pokemon.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPokemon(null)
|
||||
setSearch('')
|
||||
setAllPokemonResults([])
|
||||
}}
|
||||
className="ml-auto text-sm text-text-tertiary hover:text-text-secondary"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search all pokemon by name..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-400"
|
||||
/>
|
||||
{isSearchingAll && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="w-6 h-6 border-2 border-accent-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{allPokemonResults.length > 0 && (
|
||||
<div className="mt-2 max-h-64 overflow-y-auto grid grid-cols-3 gap-2">
|
||||
{allPokemonResults.map((p) => {
|
||||
const isDuped = dupedPokemonIds?.has(p.id) ?? false
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isDuped) {
|
||||
setSelectedPokemon({
|
||||
id: 0,
|
||||
routeId: 0,
|
||||
gameId: 0,
|
||||
pokemonId: p.id,
|
||||
pokemon: p,
|
||||
encounterMethod: 'walking',
|
||||
encounterRate: 0,
|
||||
condition: '',
|
||||
minLevel: 1,
|
||||
maxLevel: 100,
|
||||
})
|
||||
}
|
||||
}}
|
||||
disabled={isDuped}
|
||||
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
||||
isDuped
|
||||
? 'opacity-40 cursor-not-allowed border-border-default'
|
||||
: 'border-border-default hover:border-accent-400'
|
||||
}`}
|
||||
>
|
||||
{p.spriteUrl ? (
|
||||
<img src={p.spriteUrl} alt={p.name} className="w-10 h-10" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
|
||||
{p.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-text-secondary mt-1 capitalize">
|
||||
{p.name}
|
||||
</span>
|
||||
{isDuped && (
|
||||
<span className="text-[10px] text-text-tertiary italic">
|
||||
{retiredPokemonIds?.has(p.id) ? 'retired (HoF)' : 'already caught'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{search.length >= 2 && !isSearchingAll && allPokemonResults.length === 0 && (
|
||||
<p className="text-sm text-text-tertiary py-2">No pokemon found</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && !useAllPokemon && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-text-secondary">Pokemon</label>
|
||||
@@ -296,10 +450,17 @@ export function EncounterModal({
|
||||
}
|
||||
onClick={() => {
|
||||
if (routePokemon) {
|
||||
setSelectedPokemon(pickRandomPokemon(routePokemon, dupedPokemonIds))
|
||||
const eligible = routePokemon
|
||||
.filter((rp) => staticClause || rp.encounterMethod !== 'static')
|
||||
.filter(
|
||||
(rp) =>
|
||||
!allowedTypes?.length ||
|
||||
rp.pokemon.types.some((t) => allowedTypes.includes(t))
|
||||
)
|
||||
setSelectedPokemon(pickRandomPokemon(eligible, dupedPokemonIds))
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{selectedPokemon ? 'Re-roll' : 'Randomize'}
|
||||
</button>
|
||||
@@ -361,6 +522,9 @@ export function EncounterModal({
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{pokemon.map(({ encounter: rp, conditions, displayRate }) => {
|
||||
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
|
||||
const isStaticDisabled =
|
||||
!staticClause && rp.encounterMethod === 'static'
|
||||
const isDisabled = isDuped || isStaticDisabled
|
||||
const isSelected =
|
||||
selectedPokemon?.pokemonId === rp.pokemonId &&
|
||||
selectedPokemon?.encounterMethod === rp.encounterMethod
|
||||
@@ -368,10 +532,10 @@ export function EncounterModal({
|
||||
<button
|
||||
key={`${rp.encounterMethod}-${rp.pokemonId}`}
|
||||
type="button"
|
||||
onClick={() => !isDuped && setSelectedPokemon(rp)}
|
||||
disabled={isDuped}
|
||||
onClick={() => !isDisabled && setSelectedPokemon(rp)}
|
||||
disabled={isDisabled}
|
||||
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
||||
isDuped
|
||||
isDisabled
|
||||
? 'opacity-40 cursor-not-allowed border-border-default'
|
||||
: isSelected
|
||||
? 'border-accent-400 bg-accent-900/30'
|
||||
@@ -399,22 +563,31 @@ export function EncounterModal({
|
||||
: 'already caught'}
|
||||
</span>
|
||||
)}
|
||||
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
||||
{isStaticDisabled && (
|
||||
<span className="text-[10px] text-text-tertiary italic">
|
||||
static clause off
|
||||
</span>
|
||||
)}
|
||||
{!isDisabled &&
|
||||
(SPECIAL_METHODS.includes(rp.encounterMethod) ||
|
||||
rp.encounterMethod === 'static') && (
|
||||
<EncounterMethodBadge method={rp.encounterMethod} />
|
||||
)}
|
||||
{!isDuped && displayRate !== null && displayRate !== undefined && (
|
||||
<span className="text-[10px] text-purple-400 font-medium">
|
||||
{!isDisabled &&
|
||||
displayRate !== null &&
|
||||
displayRate !== undefined && (
|
||||
<span className="text-[10px] text-purple-400 light:text-purple-700 font-medium">
|
||||
{displayRate}%
|
||||
</span>
|
||||
)}
|
||||
{!isDuped &&
|
||||
{!isDisabled &&
|
||||
selectedCondition === null &&
|
||||
conditions.length > 0 && (
|
||||
<span className="text-[10px] text-purple-400">
|
||||
<span className="text-[10px] text-purple-400 light:text-purple-700">
|
||||
{conditions.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{!isDuped && (
|
||||
{!isDisabled && (
|
||||
<span className="text-[10px] text-text-tertiary">
|
||||
Lv. {rp.minLevel}
|
||||
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
||||
@@ -518,7 +691,7 @@ export function EncounterModal({
|
||||
onClick={() => setNickname(name)}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||
nickname === name
|
||||
? 'bg-accent-900/40 border-accent-600 text-accent-300'
|
||||
? 'bg-accent-900/40 border-accent-600 text-accent-300 light:bg-accent-100 light:text-accent-700'
|
||||
: 'border-border-default text-text-secondary hover:border-accent-600 hover:bg-accent-900/20'
|
||||
}`}
|
||||
>
|
||||
|
||||
71
frontend/src/components/EndRunModal.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { EndRunModal } from './EndRunModal'
|
||||
|
||||
function setup(overrides: Partial<React.ComponentProps<typeof EndRunModal>> = {}) {
|
||||
const props = {
|
||||
onConfirm: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
render(<EndRunModal {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
describe('EndRunModal', () => {
|
||||
it('renders Victory, Defeat, and Cancel buttons', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('button', { name: /victory/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /defeat/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm with "completed" when Victory is clicked', async () => {
|
||||
const { onConfirm } = setup()
|
||||
await userEvent.click(screen.getByRole('button', { name: /victory/i }))
|
||||
expect(onConfirm).toHaveBeenCalledWith('completed')
|
||||
})
|
||||
|
||||
it('calls onConfirm with "failed" when Defeat is clicked', async () => {
|
||||
const { onConfirm } = setup()
|
||||
await userEvent.click(screen.getByRole('button', { name: /defeat/i }))
|
||||
expect(onConfirm).toHaveBeenCalledWith('failed')
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', async () => {
|
||||
const { onClose } = setup()
|
||||
await userEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onClose when the backdrop is clicked', async () => {
|
||||
const { onClose } = setup()
|
||||
const backdrop = document.querySelector('.fixed.inset-0.bg-black\\/50') as HTMLElement
|
||||
await userEvent.click(backdrop)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables all buttons when isPending is true', () => {
|
||||
setup({ isPending: true })
|
||||
expect(screen.getByRole('button', { name: /victory/i })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /defeat/i })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows default description text without a genlocke context', () => {
|
||||
setup()
|
||||
expect(screen.getByText('Beat the game successfully')).toBeInTheDocument()
|
||||
expect(screen.getByText('All Pokemon fainted or gave up')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows genlocke-specific description for non-final legs', () => {
|
||||
setup({ genlockeContext: { isFinalLeg: false, legOrder: 1, totalLegs: 3 } as never })
|
||||
expect(screen.getByText('Complete this leg and continue your genlocke')).toBeInTheDocument()
|
||||
expect(screen.getByText('This will end the entire genlocke')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows final-leg description on the last genlocke leg', () => {
|
||||
setup({ genlockeContext: { isFinalLeg: true, legOrder: 3, totalLegs: 3 } as never })
|
||||
expect(screen.getByText('Complete the final leg of your genlocke!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
115
frontend/src/components/GameGrid.test.tsx
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -48,7 +48,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
|
||||
|
||||
<div className="text-xs text-text-muted mt-0.5">{entry.routeName}</div>
|
||||
|
||||
<div className="text-[10px] text-purple-400 mt-0.5 font-medium">
|
||||
<div className="text-[10px] text-purple-400 light:text-purple-700 mt-0.5 font-medium">
|
||||
Leg {entry.legOrder} — {entry.gameName}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,18 +48,18 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
|
||||
<div
|
||||
className={`font-medium ${
|
||||
leg.faintLevel !== null
|
||||
? 'text-red-300'
|
||||
? 'text-red-300 light:text-red-700'
|
||||
: leg.wasTransferred
|
||||
? 'text-blue-300'
|
||||
? 'text-blue-300 light:text-blue-700'
|
||||
: leg.enteredHof
|
||||
? 'text-yellow-300'
|
||||
: 'text-green-300'
|
||||
? 'text-yellow-300 light:text-amber-700'
|
||||
: 'text-green-300 light:text-green-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{leg.enteredHof && leg.faintLevel === null && (
|
||||
<div className="text-yellow-300">Hall of Fame</div>
|
||||
<div className="text-yellow-300 light:text-amber-700">Hall of Fame</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45 -mt-1" />
|
||||
@@ -156,8 +156,8 @@ function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegO
|
||||
<span
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
lineage.status === 'alive'
|
||||
? 'bg-green-900/40 text-green-300'
|
||||
: 'bg-red-900/40 text-red-300'
|
||||
? 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800'
|
||||
: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800'
|
||||
}`}
|
||||
>
|
||||
{lineage.status === 'alive' ? 'Alive' : 'Dead'}
|
||||
|
||||
61
frontend/src/components/Layout.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { Layout } from './Layout'
|
||||
|
||||
vi.mock('../hooks/useTheme', () => ({
|
||||
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
|
||||
}))
|
||||
|
||||
function renderLayout(initialPath = '/') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Layout />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Layout', () => {
|
||||
it('renders all desktop navigation links', () => {
|
||||
renderLayout()
|
||||
expect(screen.getAllByRole('link', { name: /new run/i })[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('link', { name: /my runs/i })[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('link', { name: /genlockes/i })[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('link', { name: /stats/i })[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('link', { name: /admin/i })[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the brand logo link', () => {
|
||||
renderLayout()
|
||||
expect(screen.getByRole('link', { name: /ant/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the theme toggle button', () => {
|
||||
renderLayout()
|
||||
expect(screen.getAllByRole('button', { name: /switch to light mode/i })[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('initially hides the mobile dropdown menu', () => {
|
||||
renderLayout()
|
||||
// Mobile menu items exist in DOM but menu is hidden; the mobile dropdown
|
||||
// only appears inside the sm:hidden block after state toggle.
|
||||
// The hamburger button should be present.
|
||||
expect(screen.getByRole('button', { name: /toggle menu/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the mobile dropdown when the hamburger is clicked', async () => {
|
||||
renderLayout()
|
||||
const hamburger = screen.getByRole('button', { name: /toggle menu/i })
|
||||
await userEvent.click(hamburger)
|
||||
// After click, the menu open state adds a dropdown with nav links
|
||||
// We can verify the menu is open by checking a class change or that
|
||||
// the nav links appear in the mobile dropdown section.
|
||||
// The mobile dropdown renders navLinks in a div inside sm:hidden
|
||||
expect(screen.getAllByRole('link', { name: /my runs/i }).length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('renders the footer with PokeDB attribution', () => {
|
||||
renderLayout()
|
||||
expect(screen.getByRole('link', { name: /pokedb/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/runs/new', label: 'New Run' },
|
||||
@@ -37,6 +38,39 @@ function NavLink({
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeToggle() {
|
||||
const { theme, toggle } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="p-2 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3v1m0 16v1m8.66-13.66l-.71.71M4.05 19.95l-.71.71M21 12h-1M4 12H3m16.66 7.66l-.71-.71M4.05 4.05l-.71-.71M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
@@ -68,9 +102,11 @@ export function Layout() {
|
||||
{link.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{/* Mobile hamburger */}
|
||||
<div className="flex items-center sm:hidden">
|
||||
<div className="flex items-center gap-1 sm:hidden">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NuzlockeRules } from '../types'
|
||||
import { RULE_DEFINITIONS } from '../types/rules'
|
||||
import { TypeBadge } from './TypeBadge'
|
||||
|
||||
interface RuleBadgesProps {
|
||||
rules: NuzlockeRules
|
||||
@@ -7,8 +8,9 @@ interface RuleBadgesProps {
|
||||
|
||||
export function RuleBadges({ rules }: RuleBadgesProps) {
|
||||
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
|
||||
const allowedTypes = rules.allowedTypes ?? []
|
||||
|
||||
if (enabledRules.length === 0) {
|
||||
if (enabledRules.length === 0 && allowedTypes.length === 0) {
|
||||
return <span className="text-sm text-text-tertiary">No rules enabled</span>
|
||||
}
|
||||
|
||||
@@ -20,15 +22,26 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
|
||||
title={def.description}
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
def.category === 'core'
|
||||
? 'bg-blue-900/40 text-blue-300'
|
||||
: def.category === 'completion'
|
||||
? 'bg-green-900/40 text-green-300'
|
||||
: 'bg-amber-900/40 text-amber-300'
|
||||
? 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700'
|
||||
: def.category === 'variant'
|
||||
? 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700'
|
||||
: 'bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-700'
|
||||
}`}
|
||||
>
|
||||
{def.name}
|
||||
</span>
|
||||
))}
|
||||
{allowedTypes.length > 0 && (
|
||||
<span
|
||||
title={`Type restriction: ${allowedTypes.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(', ')}`}
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700 flex items-center gap-1"
|
||||
>
|
||||
<span>Type Restriction</span>
|
||||
{allowedTypes.map((t) => (
|
||||
<TypeBadge key={t} type={t} size="sm" />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
84
frontend/src/components/RulesConfiguration.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { RulesConfiguration } from './RulesConfiguration'
|
||||
import { DEFAULT_RULES } from '../types/rules'
|
||||
import type { NuzlockeRules } from '../types/rules'
|
||||
|
||||
function setup(overrides: Partial<React.ComponentProps<typeof RulesConfiguration>> = {}) {
|
||||
const props = {
|
||||
rules: { ...DEFAULT_RULES },
|
||||
onChange: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
render(<RulesConfiguration {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
describe('RulesConfiguration', () => {
|
||||
it('renders all rule section headings', () => {
|
||||
setup()
|
||||
expect(screen.getByText('Core Rules')).toBeInTheDocument()
|
||||
expect(screen.getByText('Playstyle')).toBeInTheDocument()
|
||||
expect(screen.getByText('Run Variant')).toBeInTheDocument()
|
||||
expect(screen.getByText('Type Restriction')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the enabled/total count', () => {
|
||||
setup()
|
||||
expect(screen.getByText(/\d+ of \d+ rules enabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the Reset to Default button', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('button', { name: /reset to default/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChange with updated rules when a rule is toggled off', async () => {
|
||||
const { onChange } = setup()
|
||||
// RuleToggle renders a role="switch" with no accessible name; navigate
|
||||
// to it via the sibling label text.
|
||||
const label = screen.getByText('Duplicates Clause')
|
||||
// Structure: span → .flex.items-center.gap-2 → .flex-1.pr-4 → row div → switch button
|
||||
const switchEl = label
|
||||
.closest('div[class]')
|
||||
?.parentElement?.parentElement?.querySelector('[role="switch"]') as HTMLElement
|
||||
await userEvent.click(switchEl)
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ duplicatesClause: false }))
|
||||
})
|
||||
|
||||
it('calls onChange with DEFAULT_RULES when Reset to Default is clicked', async () => {
|
||||
const { onChange } = setup({ rules: { ...DEFAULT_RULES, duplicatesClause: false } })
|
||||
await userEvent.click(screen.getByRole('button', { name: /reset to default/i }))
|
||||
expect(onChange).toHaveBeenCalledWith(DEFAULT_RULES)
|
||||
})
|
||||
|
||||
it('calls onReset when Reset to Default is clicked', async () => {
|
||||
const onReset = vi.fn()
|
||||
setup({ onReset })
|
||||
await userEvent.click(screen.getByRole('button', { name: /reset to default/i }))
|
||||
expect(onReset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('toggles a type on when a type button is clicked', async () => {
|
||||
const { onChange } = setup()
|
||||
await userEvent.click(screen.getByRole('button', { name: /fire/i }))
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ allowedTypes: ['fire'] }))
|
||||
})
|
||||
|
||||
it('shows Clear selection button when types are selected', () => {
|
||||
setup({ rules: { ...DEFAULT_RULES, allowedTypes: ['fire'] } })
|
||||
expect(screen.getByRole('button', { name: /clear selection/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears selected types when Clear selection is clicked', async () => {
|
||||
const { onChange } = setup({ rules: { ...DEFAULT_RULES, allowedTypes: ['fire', 'water'] } })
|
||||
await userEvent.click(screen.getByRole('button', { name: /clear selection/i }))
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ allowedTypes: [] }))
|
||||
})
|
||||
|
||||
it('hides rules in the hiddenRules set', () => {
|
||||
const hiddenRules = new Set<keyof NuzlockeRules>(['duplicatesClause'])
|
||||
setup({ hiddenRules })
|
||||
expect(screen.queryByText('Duplicates Clause')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,28 @@
|
||||
import type { NuzlockeRules } from '../types/rules'
|
||||
import { RULE_DEFINITIONS, DEFAULT_RULES } from '../types/rules'
|
||||
import { RuleToggle } from './RuleToggle'
|
||||
import { TypeBadge } from './TypeBadge'
|
||||
|
||||
const POKEMON_TYPES = [
|
||||
'bug',
|
||||
'dark',
|
||||
'dragon',
|
||||
'electric',
|
||||
'fairy',
|
||||
'fighting',
|
||||
'fire',
|
||||
'flying',
|
||||
'ghost',
|
||||
'grass',
|
||||
'ground',
|
||||
'ice',
|
||||
'normal',
|
||||
'poison',
|
||||
'psychic',
|
||||
'rock',
|
||||
'steel',
|
||||
'water',
|
||||
] as const
|
||||
|
||||
interface RulesConfigurationProps {
|
||||
rules: NuzlockeRules
|
||||
@@ -19,8 +41,8 @@ export function RulesConfiguration({
|
||||
? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key))
|
||||
: RULE_DEFINITIONS
|
||||
const coreRules = visibleRules.filter((r) => r.category === 'core')
|
||||
const difficultyRules = visibleRules.filter((r) => r.category === 'difficulty')
|
||||
const completionRules = visibleRules.filter((r) => r.category === 'completion')
|
||||
const playstyleRules = visibleRules.filter((r) => r.category === 'playstyle')
|
||||
const variantRules = visibleRules.filter((r) => r.category === 'variant')
|
||||
|
||||
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
|
||||
onChange({ ...rules, [key]: value })
|
||||
@@ -31,8 +53,18 @@ export function RulesConfiguration({
|
||||
onReset?.()
|
||||
}
|
||||
|
||||
const enabledCount = visibleRules.filter((r) => rules[r.key]).length
|
||||
const totalCount = visibleRules.length
|
||||
const allowedTypes = rules.allowedTypes ?? []
|
||||
|
||||
const toggleType = (type: string) => {
|
||||
const next = allowedTypes.includes(type)
|
||||
? allowedTypes.filter((t) => t !== type)
|
||||
: [...allowedTypes, type]
|
||||
onChange({ ...rules, allowedTypes: next })
|
||||
}
|
||||
|
||||
const enabledCount =
|
||||
visibleRules.filter((r) => rules[r.key]).length + (allowedTypes.length > 0 ? 1 : 0)
|
||||
const totalCount = visibleRules.length + 1 // +1 for type restriction
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -74,11 +106,13 @@ export function RulesConfiguration({
|
||||
|
||||
<div className="bg-surface-1 rounded-lg shadow">
|
||||
<div className="px-4 py-3 border-b border-border-default">
|
||||
<h3 className="text-lg font-medium text-text-primary">Difficulty Modifiers</h3>
|
||||
<p className="text-sm text-text-tertiary">Optional rules to increase the challenge</p>
|
||||
<h3 className="text-lg font-medium text-text-primary">Playstyle</h3>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Describe how you're playing — doesn't affect tracker behavior
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{difficultyRules.map((rule) => (
|
||||
{playstyleRules.map((rule) => (
|
||||
<RuleToggle
|
||||
key={rule.key}
|
||||
name={rule.name}
|
||||
@@ -90,14 +124,15 @@ export function RulesConfiguration({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{completionRules.length > 0 && (
|
||||
<div className="bg-surface-1 rounded-lg shadow">
|
||||
<div className="px-4 py-3 border-b border-border-default">
|
||||
<h3 className="text-lg font-medium text-text-primary">Completion</h3>
|
||||
<p className="text-sm text-text-tertiary">When is the run considered complete</p>
|
||||
<h3 className="text-lg font-medium text-text-primary">Run Variant</h3>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Changes which Pokémon can appear — affects the encounter selector
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{completionRules.map((rule) => (
|
||||
{variantRules.map((rule) => (
|
||||
<RuleToggle
|
||||
key={rule.key}
|
||||
name={rule.name}
|
||||
@@ -108,7 +143,44 @@ export function RulesConfiguration({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-1 rounded-lg shadow">
|
||||
<div className="px-4 py-3 border-b border-border-default">
|
||||
<h3 className="text-lg font-medium text-text-primary">Type Restriction</h3>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Monolocke and variants. Select allowed types — a Pokémon qualifies if it shares at least
|
||||
one type. Leave all deselected to disable.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{POKEMON_TYPES.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => toggleType(type)}
|
||||
title={type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
className={`p-1.5 rounded-lg border-2 transition-colors ${
|
||||
allowedTypes.includes(type)
|
||||
? 'border-accent-400 bg-accent-900/20'
|
||||
: 'border-transparent opacity-40 hover:opacity-70'
|
||||
}`}
|
||||
>
|
||||
<TypeBadge type={type} size="md" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{allowedTypes.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...rules, allowedTypes: [] })}
|
||||
className="mt-3 text-xs text-text-tertiary hover:text-text-secondary"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ShinyBoxProps {
|
||||
export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
|
||||
return (
|
||||
<div className="border-2 border-yellow-600 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-yellow-400 mb-3 flex items-center gap-1.5">
|
||||
<h3 className="text-sm font-semibold text-yellow-400 light:text-amber-700 mb-3 flex items-center gap-1.5">
|
||||
<span>✦</span>
|
||||
Shiny Box
|
||||
<span className="text-xs font-normal text-text-muted ml-1">
|
||||
|
||||
@@ -110,7 +110,7 @@ export function ShinyEncounterModal({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-400 mt-1">
|
||||
<p className="text-sm text-yellow-400 light:text-amber-700 mt-1">
|
||||
Shiny catches bypass the one-per-route rule
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,9 @@ export function AdminLayout() {
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`block px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap ${
|
||||
isActive ? 'bg-accent-900/40 text-accent-300' : 'hover:bg-surface-2'
|
||||
isActive
|
||||
? 'bg-accent-900/40 text-accent-300 light:bg-accent-100 light:text-accent-700'
|
||||
: 'hover:bg-surface-2'
|
||||
}`
|
||||
}
|
||||
>
|
||||
|
||||
@@ -77,7 +77,7 @@ export function BulkImportModal({
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="p-3 bg-green-900/30 text-green-300 rounded-md text-sm">
|
||||
<div className="p-3 bg-green-900/30 text-green-300 light:bg-green-100 light:text-green-800 rounded-md text-sm">
|
||||
<p>
|
||||
{createdLabel}: {result.created}, {updatedLabel}: {result.updated}
|
||||
</p>
|
||||
|
||||
148
frontend/src/hooks/useAdmin.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
118
frontend/src/hooks/useBosses.test.tsx
Normal 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'] })
|
||||
})
|
||||
})
|
||||
161
frontend/src/hooks/useEncounters.test.tsx
Normal 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] })
|
||||
})
|
||||
})
|
||||
89
frontend/src/hooks/useGames.test.tsx
Normal 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))
|
||||
})
|
||||
})
|
||||
@@ -15,10 +15,10 @@ export function useGame(id: number) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useGameRoutes(gameId: number | null) {
|
||||
export function useGameRoutes(gameId: number | null, allowedTypes?: string[]) {
|
||||
return useQuery({
|
||||
queryKey: ['games', gameId, 'routes'],
|
||||
queryFn: () => getGameRoutes(gameId!),
|
||||
queryKey: ['games', gameId, 'routes', allowedTypes],
|
||||
queryFn: () => getGameRoutes(gameId!, allowedTypes),
|
||||
enabled: gameId !== null,
|
||||
})
|
||||
}
|
||||
|
||||
178
frontend/src/hooks/useGenlockes.test.tsx
Normal 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'] })
|
||||
})
|
||||
})
|
||||
93
frontend/src/hooks/usePokemon.test.tsx
Normal 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))
|
||||
})
|
||||
})
|
||||
181
frontend/src/hooks/useRuns.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
38
frontend/src/hooks/useStats.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
63
frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
const STORAGE_KEY = 'ant-theme'
|
||||
|
||||
function getSystemTheme(): Theme {
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
function getStoredTheme(): Theme | null {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'dark' || stored === 'light') return stored
|
||||
return null
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
if (theme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
}
|
||||
document.documentElement.style.colorScheme = theme
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
let currentTheme: Theme = getStoredTheme() ?? getSystemTheme()
|
||||
|
||||
applyTheme(currentTheme)
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)')
|
||||
mediaQuery.addEventListener('change', () => {
|
||||
if (!getStoredTheme()) {
|
||||
currentTheme = getSystemTheme()
|
||||
applyTheme(currentTheme)
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
})
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(): Theme {
|
||||
return currentTheme
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const theme = useSyncExternalStore(subscribe, getSnapshot)
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const next: Theme = currentTheme === 'dark' ? 'light' : 'dark'
|
||||
currentTheme = next
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
applyTheme(next)
|
||||
for (const listener of listeners) listener()
|
||||
}, [])
|
||||
|
||||
return { theme, toggle } as const
|
||||
}
|
||||
@@ -46,8 +46,9 @@
|
||||
|
||||
/* Text on dark */
|
||||
--color-text-primary: #e6edf3;
|
||||
--color-text-secondary: #7d8590;
|
||||
--color-text-tertiary: #484f58;
|
||||
--color-text-secondary: #9198a1;
|
||||
--color-text-tertiary: #8b949e;
|
||||
--color-text-muted: #8b949e;
|
||||
--color-text-link: #7eb0ce;
|
||||
|
||||
/* Borders */
|
||||
@@ -68,6 +69,50 @@
|
||||
--color-status-failed-bg: rgba(248, 81, 73, 0.15);
|
||||
}
|
||||
|
||||
@custom-variant light (&:where([data-theme="light"], [data-theme="light"] *));
|
||||
|
||||
/* ── Light mode overrides ─────────────────────────────────────── */
|
||||
|
||||
html[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
/* Surfaces */
|
||||
--color-surface-0: #ffffff;
|
||||
--color-surface-1: #f6f8fa;
|
||||
--color-surface-2: #eef1f4;
|
||||
--color-surface-3: #d8dee4;
|
||||
--color-surface-4: #ced5dc;
|
||||
|
||||
/* Accent (darkened for text contrast on light surfaces) */
|
||||
--color-accent-200: #245f7e;
|
||||
--color-accent-300: #1a5068;
|
||||
--color-accent-400: #2d6a89;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #1f2328;
|
||||
--color-text-secondary: #656d76;
|
||||
--color-text-tertiary: #596069;
|
||||
--color-text-muted: #596069;
|
||||
--color-text-link: #1a5068;
|
||||
|
||||
/* Borders */
|
||||
--color-border-default: rgba(0, 0, 0, 0.15);
|
||||
--color-border-muted: rgba(0, 0, 0, 0.08);
|
||||
--color-border-accent: rgba(57, 94, 115, 0.35);
|
||||
|
||||
/* Status (adjusted for light surfaces) */
|
||||
--color-status-alive: #1a7f37;
|
||||
--color-status-alive-bg: rgba(26, 127, 55, 0.1);
|
||||
--color-status-dead: #cf222e;
|
||||
--color-status-dead-bg: rgba(207, 34, 46, 0.1);
|
||||
--color-status-active: #116b2b;
|
||||
--color-status-active-bg: rgba(17, 107, 43, 0.08);
|
||||
--color-status-completed: #0969da;
|
||||
--color-status-completed-bg: rgba(9, 105, 218, 0.1);
|
||||
--color-status-failed: #cf222e;
|
||||
--color-status-failed-bg: rgba(207, 34, 46, 0.1);
|
||||
}
|
||||
|
||||
/* ── Base layer ────────────────────────────────────────────────── */
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -18,9 +18,9 @@ const statusRing: Record<RunStatus, string> = {
|
||||
}
|
||||
|
||||
const statusStyles: Record<RunStatus, string> = {
|
||||
active: 'bg-green-900/40 text-green-300',
|
||||
completed: 'bg-blue-900/40 text-blue-300',
|
||||
failed: 'bg-red-900/40 text-red-300',
|
||||
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
|
||||
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
|
||||
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
|
||||
}
|
||||
|
||||
function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
|
||||
@@ -270,7 +270,7 @@ export function GenlockeDetail() {
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
showGraveyard
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-surface-3 text-text-secondary hover:bg-surface-4'
|
||||
: 'bg-surface-3 text-text-secondary light:text-text-primary hover:bg-surface-4'
|
||||
}`}
|
||||
>
|
||||
Graveyard
|
||||
@@ -280,7 +280,7 @@ export function GenlockeDetail() {
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
showLineage
|
||||
? 'bg-accent-600 text-white hover:bg-accent-500'
|
||||
: 'bg-surface-3 text-text-secondary hover:bg-surface-4'
|
||||
: 'bg-surface-3 text-text-secondary light:text-text-primary hover:bg-surface-4'
|
||||
}`}
|
||||
>
|
||||
Lineage
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useGenlockes } from '../hooks/useGenlockes'
|
||||
import type { RunStatus } from '../types'
|
||||
|
||||
const statusStyles: Record<RunStatus, string> = {
|
||||
active: 'bg-green-900/40 text-green-300',
|
||||
completed: 'bg-blue-900/40 text-blue-300',
|
||||
failed: 'bg-red-900/40 text-red-300',
|
||||
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
|
||||
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
|
||||
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
|
||||
}
|
||||
|
||||
export function GenlockeList() {
|
||||
|
||||
@@ -31,9 +31,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
|
||||
}
|
||||
|
||||
const statusStyles: Record<RunStatus, string> = {
|
||||
active: 'bg-green-900/40 text-green-300',
|
||||
completed: 'bg-blue-900/40 text-blue-300',
|
||||
failed: 'bg-red-900/40 text-red-300',
|
||||
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
|
||||
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
|
||||
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
|
||||
}
|
||||
|
||||
function formatDuration(start: string, end: string) {
|
||||
|
||||
@@ -59,9 +59,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
|
||||
}
|
||||
|
||||
const statusStyles: Record<RunStatus, string> = {
|
||||
active: 'bg-green-900/40 text-green-300',
|
||||
completed: 'bg-blue-900/40 text-blue-300',
|
||||
failed: 'bg-red-900/40 text-red-300',
|
||||
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
|
||||
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
|
||||
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
|
||||
}
|
||||
|
||||
function formatDuration(start: string, end: string) {
|
||||
@@ -178,6 +178,17 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
|
||||
return matches.length === 1 ? (matches[0] ?? null) : null
|
||||
}
|
||||
|
||||
/** Count boss pokemon for the effective variant (or all if no variants). */
|
||||
function getBossTeamSize(pokemon: BossPokemon[], starterName?: string | null): number {
|
||||
const labels = [
|
||||
...new Set(pokemon.filter((bp) => bp.conditionLabel).map((bp) => bp.conditionLabel!)),
|
||||
]
|
||||
if (labels.length === 0) return pokemon.length
|
||||
const matched = matchVariant(labels, starterName)
|
||||
const variant = matched ?? labels[0] ?? null
|
||||
return pokemon.filter((bp) => bp.conditionLabel === variant || bp.conditionLabel === null).length
|
||||
}
|
||||
|
||||
function BossTeamPreview({
|
||||
pokemon,
|
||||
starterName,
|
||||
@@ -254,6 +265,7 @@ function BossTeamPreview({
|
||||
interface RouteGroupProps {
|
||||
group: RouteWithChildren
|
||||
encounterByRoute: Map<number, EncounterDetail>
|
||||
giftEncounterByRoute: Map<number, EncounterDetail>
|
||||
isExpanded: boolean
|
||||
onToggleExpand: () => void
|
||||
onRouteClick: (route: Route) => void
|
||||
@@ -264,6 +276,7 @@ interface RouteGroupProps {
|
||||
function RouteGroup({
|
||||
group,
|
||||
encounterByRoute,
|
||||
giftEncounterByRoute,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onRouteClick,
|
||||
@@ -274,13 +287,23 @@ function RouteGroup({
|
||||
const usePinwheel = pinwheelClause && groupHasZones(group)
|
||||
const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null
|
||||
|
||||
// Find first gift encounter in the group (for display)
|
||||
let groupGiftEncounter: EncounterDetail | null = null
|
||||
for (const child of group.children) {
|
||||
const gift = giftEncounterByRoute.get(child.id)
|
||||
if (gift) {
|
||||
groupGiftEncounter = gift
|
||||
break
|
||||
}
|
||||
}
|
||||
const displayEncounter = groupEncounter ?? groupGiftEncounter
|
||||
|
||||
// For pinwheel groups, determine status from all zone statuses
|
||||
let groupStatus: RouteStatus
|
||||
if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) {
|
||||
// Use the first encounter's status as representative for the header
|
||||
groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
||||
groupStatus = displayEncounter ? displayEncounter.status : 'none'
|
||||
} else {
|
||||
groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
||||
groupStatus = displayEncounter ? displayEncounter.status : 'none'
|
||||
}
|
||||
const si = statusIndicator[groupStatus]
|
||||
|
||||
@@ -289,10 +312,9 @@ function RouteGroup({
|
||||
if (usePinwheel) {
|
||||
// Show group if any zone matches the filter
|
||||
const anyChildMatches = group.children.some((child) => {
|
||||
const enc = encounterByRoute.get(child.id)
|
||||
const enc = encounterByRoute.get(child.id) ?? giftEncounterByRoute.get(child.id)
|
||||
return getRouteStatus(enc) === filter
|
||||
})
|
||||
// Also check children without encounters (for 'none' filter)
|
||||
if (!anyChildMatches) return null
|
||||
} else if (groupStatus !== filter) {
|
||||
return null
|
||||
@@ -330,6 +352,36 @@ function RouteGroup({
|
||||
groupEncounter.faintLevel !== null &&
|
||||
(groupEncounter.deathCause ? ` — ${groupEncounter.deathCause}` : ' (dead)')}
|
||||
</span>
|
||||
{groupGiftEncounter && (
|
||||
<>
|
||||
{groupGiftEncounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={groupGiftEncounter.pokemon.spriteUrl}
|
||||
alt={groupGiftEncounter.pokemon.name}
|
||||
className="w-8 h-8 opacity-60"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary capitalize">
|
||||
{groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name}
|
||||
<span className="text-text-muted ml-1">(gift)</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!groupEncounter && groupGiftEncounter && (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{groupGiftEncounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={groupGiftEncounter.pokemon.spriteUrl}
|
||||
alt={groupGiftEncounter.pokemon.name}
|
||||
className="w-8 h-8 opacity-60"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary capitalize">
|
||||
{groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name}
|
||||
<span className="text-text-muted ml-1">(gift)</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -349,7 +401,9 @@ function RouteGroup({
|
||||
<div className="border-t border-border-default bg-surface-1/50">
|
||||
{group.children.map((child) => {
|
||||
const childEncounter = encounterByRoute.get(child.id)
|
||||
const childStatus = getRouteStatus(childEncounter)
|
||||
const giftEncounter = giftEncounterByRoute.get(child.id)
|
||||
const displayEncounter = childEncounter ?? giftEncounter
|
||||
const childStatus = getRouteStatus(displayEncounter)
|
||||
const childSi = statusIndicator[childStatus]
|
||||
|
||||
let isDisabled: boolean
|
||||
@@ -375,7 +429,22 @@ function RouteGroup({
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-text-secondary">{child.name}</div>
|
||||
{!childEncounter && child.encounterMethods.length > 0 && (
|
||||
{giftEncounter && !childEncounter && (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{giftEncounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={giftEncounter.pokemon.spriteUrl}
|
||||
alt={giftEncounter.pokemon.name}
|
||||
className="w-8 h-8 opacity-60"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary capitalize">
|
||||
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
||||
<span className="text-text-muted ml-1">(gift)</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!displayEncounter && child.encounterMethods.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{child.encounterMethods.map((m) => (
|
||||
<EncounterMethodBadge key={m} method={m} size="xs" />
|
||||
@@ -401,7 +470,11 @@ export function RunEncounters() {
|
||||
const { data: run, isLoading, error } = useRun(runIdNum)
|
||||
const advanceLeg = useAdvanceLeg()
|
||||
const [showTransferModal, setShowTransferModal] = useState(false)
|
||||
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(run?.gameId ?? null)
|
||||
const rulesAllowedTypes = run?.rules?.allowedTypes ?? []
|
||||
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
|
||||
run?.gameId ?? null,
|
||||
rulesAllowedTypes.length ? rulesAllowedTypes : undefined
|
||||
)
|
||||
const createEncounter = useCreateEncounter(runIdNum)
|
||||
const updateEncounter = useUpdateEncounter(runIdNum)
|
||||
const bulkRandomize = useBulkRandomize(runIdNum)
|
||||
@@ -484,14 +557,29 @@ export function RunEncounters() {
|
||||
}
|
||||
}, [run, transferIdSet])
|
||||
|
||||
// Map routeId → encounter for quick lookup (normal encounters only)
|
||||
const giftClauseOn = run?.rules?.giftClause ?? false
|
||||
|
||||
// Map routeId → encounter for quick lookup (normal encounters only).
|
||||
// When gift clause is on, gift-origin encounters are excluded so they
|
||||
// don't lock the route for a regular encounter.
|
||||
const encounterByRoute = useMemo(() => {
|
||||
const map = new Map<number, EncounterDetail>()
|
||||
for (const enc of normalEncounters) {
|
||||
if (giftClauseOn && enc.origin === 'gift') continue
|
||||
map.set(enc.routeId, enc)
|
||||
}
|
||||
return map
|
||||
}, [normalEncounters])
|
||||
}, [normalEncounters, giftClauseOn])
|
||||
|
||||
// Separate map for gift encounters (only populated when gift clause is on)
|
||||
const giftEncounterByRoute = useMemo(() => {
|
||||
const map = new Map<number, EncounterDetail>()
|
||||
if (!giftClauseOn) return map
|
||||
for (const enc of normalEncounters) {
|
||||
if (enc.origin === 'gift') map.set(enc.routeId, enc)
|
||||
}
|
||||
return map
|
||||
}, [normalEncounters, giftClauseOn])
|
||||
|
||||
// Build set of retired Pokemon IDs from genlocke context
|
||||
const retiredPokemonIds = useMemo(() => {
|
||||
@@ -677,6 +765,7 @@ export function RunEncounters() {
|
||||
}
|
||||
|
||||
const pinwheelClause = run.rules?.pinwheelClause ?? true
|
||||
const useAllPokemon = !!(run.rules?.egglocke || run.rules?.wonderlocke || run.rules?.randomizer)
|
||||
|
||||
// Count completed locations (zone-aware when pinwheel clause is on)
|
||||
let completedCount = 0
|
||||
@@ -755,7 +844,7 @@ export function RunEncounters() {
|
||||
})
|
||||
}
|
||||
|
||||
// Filter routes
|
||||
// Filter routes (check both regular and gift encounters for status)
|
||||
const filteredRoutes = organizedRoutes.filter((r) => {
|
||||
if (filter === 'all') return true
|
||||
|
||||
@@ -764,17 +853,23 @@ export function RunEncounters() {
|
||||
if (usePinwheel) {
|
||||
// Show group if any child/zone matches the filter
|
||||
return r.children.some((child) => {
|
||||
const enc = encounterByRoute.get(child.id)
|
||||
const enc = encounterByRoute.get(child.id) ?? giftEncounterByRoute.get(child.id)
|
||||
return getRouteStatus(enc) === filter
|
||||
})
|
||||
}
|
||||
// Classic: single status for whole group
|
||||
const groupEnc = getGroupEncounter(r, encounterByRoute)
|
||||
return getRouteStatus(groupEnc ?? undefined) === filter
|
||||
if (groupEnc) return getRouteStatus(groupEnc) === filter
|
||||
// Check gift encounters if no regular encounter in group
|
||||
for (const child of r.children) {
|
||||
const gift = giftEncounterByRoute.get(child.id)
|
||||
if (gift) return getRouteStatus(gift) === filter
|
||||
}
|
||||
return filter === 'none'
|
||||
}
|
||||
|
||||
// Standalone route
|
||||
const enc = encounterByRoute.get(r.id)
|
||||
const enc = encounterByRoute.get(r.id) ?? giftEncounterByRoute.get(r.id)
|
||||
return getRouteStatus(enc) === filter
|
||||
})
|
||||
|
||||
@@ -801,7 +896,7 @@ export function RunEncounters() {
|
||||
})}
|
||||
</p>
|
||||
{run.genlocke && (
|
||||
<p className="text-sm text-purple-400 mt-1 font-medium">
|
||||
<p className="text-sm text-purple-400 light:text-purple-700 mt-1 font-medium">
|
||||
Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} —{' '}
|
||||
{run.genlocke.genlockeName}
|
||||
</p>
|
||||
@@ -811,7 +906,7 @@ export function RunEncounters() {
|
||||
{isActive && run.rules?.shinyClause && (
|
||||
<button
|
||||
onClick={() => setShowShinyModal(true)}
|
||||
className="px-3 py-1 text-sm border border-yellow-600 text-yellow-400 rounded-full font-medium hover:bg-yellow-900/20 transition-colors"
|
||||
className="px-3 py-1 text-sm border border-yellow-600 text-yellow-400 light:text-amber-700 light:border-amber-600 rounded-full font-medium hover:bg-yellow-900/20 light:hover:bg-amber-50 transition-colors"
|
||||
>
|
||||
✦ Log Shiny
|
||||
</button>
|
||||
@@ -973,14 +1068,22 @@ export function RunEncounters() {
|
||||
|
||||
{/* Level Cap Bar */}
|
||||
{run.rules?.levelCaps && sortedBosses.length > 0 && (
|
||||
<div className="sticky top-0 z-10 bg-surface-0 border border-border-default rounded-lg px-4 py-3 mb-6 shadow-sm">
|
||||
<div className="sticky top-14 z-30 bg-surface-0 border border-border-default rounded-lg px-4 py-3 mb-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
Level Cap: {currentLevelCap ?? '—'}
|
||||
</span>
|
||||
{nextBoss && (
|
||||
<span className="text-sm text-text-tertiary">Next: {nextBoss.name}</span>
|
||||
<span className="text-sm text-text-tertiary">
|
||||
Next: {nextBoss.name}
|
||||
{run.rules?.bossTeamMatch && (
|
||||
<span className="text-text-muted">
|
||||
{' '}
|
||||
({getBossTeamSize(nextBoss.pokemon, starterName)} Pokémon — match their team)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!nextBoss && (
|
||||
<span className="text-sm text-status-active">All bosses defeated!</span>
|
||||
@@ -1153,7 +1256,7 @@ export function RunEncounters() {
|
||||
bulkRandomize.mutate()
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
|
||||
</button>
|
||||
@@ -1225,6 +1328,7 @@ export function RunEncounters() {
|
||||
key={route.id}
|
||||
group={route}
|
||||
encounterByRoute={encounterByRoute}
|
||||
giftEncounterByRoute={giftEncounterByRoute}
|
||||
isExpanded={expandedGroups.has(route.id)}
|
||||
onToggleExpand={() => toggleGroup(route.id)}
|
||||
onRouteClick={handleRouteClick}
|
||||
@@ -1234,7 +1338,9 @@ export function RunEncounters() {
|
||||
) : (
|
||||
(() => {
|
||||
const encounter = encounterByRoute.get(route.id)
|
||||
const rs = getRouteStatus(encounter)
|
||||
const giftEncounter = giftEncounterByRoute.get(route.id)
|
||||
const displayEncounter = encounter ?? giftEncounter
|
||||
const rs = getRouteStatus(displayEncounter)
|
||||
const si = statusIndicator[rs]
|
||||
|
||||
return (
|
||||
@@ -1262,6 +1368,35 @@ export function RunEncounters() {
|
||||
encounter.faintLevel !== null &&
|
||||
(encounter.deathCause ? ` — ${encounter.deathCause}` : ' (dead)')}
|
||||
</span>
|
||||
{giftEncounter && (
|
||||
<>
|
||||
{giftEncounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={giftEncounter.pokemon.spriteUrl}
|
||||
alt={giftEncounter.pokemon.name}
|
||||
className="w-8 h-8 opacity-60"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary capitalize">
|
||||
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
||||
<span className="text-text-muted ml-1">(gift)</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : giftEncounter ? (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{giftEncounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={giftEncounter.pokemon.spriteUrl}
|
||||
alt={giftEncounter.pokemon.name}
|
||||
className="w-8 h-8 opacity-60"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary capitalize">
|
||||
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
||||
<span className="text-text-muted ml-1">(gift)</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
route.encounterMethods.length > 0 && (
|
||||
@@ -1358,7 +1493,7 @@ export function RunEncounters() {
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{isDefeated ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
|
||||
Defeated ✓
|
||||
</span>
|
||||
) : isActive ? (
|
||||
@@ -1411,6 +1546,9 @@ export function RunEncounters() {
|
||||
setEditingEncounter(null)
|
||||
}}
|
||||
isPending={createEncounter.isPending || updateEncounter.isPending}
|
||||
useAllPokemon={useAllPokemon}
|
||||
staticClause={run?.rules?.staticClause ?? true}
|
||||
allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,24 +4,24 @@ import { StatCard } from '../components'
|
||||
import type { PokemonRanking, StatsResponse } from '../types/stats'
|
||||
|
||||
const typeBarColors: Record<string, string> = {
|
||||
normal: 'bg-gray-400',
|
||||
fire: 'bg-red-500',
|
||||
water: 'bg-blue-500',
|
||||
electric: 'bg-yellow-400',
|
||||
grass: 'bg-green-500',
|
||||
ice: 'bg-cyan-300',
|
||||
fighting: 'bg-red-700',
|
||||
poison: 'bg-purple-500',
|
||||
ground: 'bg-amber-600',
|
||||
flying: 'bg-indigo-300',
|
||||
psychic: 'bg-pink-500',
|
||||
bug: 'bg-lime-500',
|
||||
rock: 'bg-amber-700',
|
||||
ghost: 'bg-purple-700',
|
||||
dragon: 'bg-indigo-600',
|
||||
dark: 'bg-gray-700',
|
||||
steel: 'bg-gray-400',
|
||||
fairy: 'bg-pink-300',
|
||||
normal: '#9ca3af',
|
||||
fire: '#ef4444',
|
||||
water: '#3b82f6',
|
||||
electric: '#facc15',
|
||||
grass: '#22c55e',
|
||||
ice: '#67e8f9',
|
||||
fighting: '#b91c1c',
|
||||
poison: '#a855f7',
|
||||
ground: '#d97706',
|
||||
flying: '#a5b4fc',
|
||||
psychic: '#ec4899',
|
||||
bug: '#84cc16',
|
||||
rock: '#b45309',
|
||||
ghost: '#7e22ce',
|
||||
dragon: '#4f46e5',
|
||||
dark: '#374151',
|
||||
steel: '#9ca3af',
|
||||
fairy: '#f9a8d4',
|
||||
}
|
||||
|
||||
function fmt(value: number | null, suffix = ''): string {
|
||||
@@ -74,44 +74,50 @@ function PokemonList({ title, pokemon }: { title: string; pokemon: PokemonRankin
|
||||
)
|
||||
}
|
||||
|
||||
function hexLuminance(hex: string): number {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
function srgbLuminance(hex: string): number {
|
||||
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
|
||||
const r = toLinear(parseInt(hex.slice(1, 3), 16) / 255)
|
||||
const g = toLinear(parseInt(hex.slice(3, 5), 16) / 255)
|
||||
const b = toLinear(parseInt(hex.slice(5, 7), 16) / 255)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
function shouldUseDarkText(bgHex: string): boolean {
|
||||
const bgL = srgbLuminance(bgHex)
|
||||
const whiteContrast = 1.05 / (bgL + 0.05)
|
||||
const blackContrast = (bgL + 0.05) / 0.05
|
||||
return blackContrast > whiteContrast
|
||||
}
|
||||
|
||||
function HorizontalBar({
|
||||
label,
|
||||
value,
|
||||
max,
|
||||
color,
|
||||
colorHex,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
max: number
|
||||
color?: string
|
||||
colorHex?: string
|
||||
colorHex: string
|
||||
}) {
|
||||
const width = max > 0 ? (value / max) * 100 : 0
|
||||
const isLight = colorHex ? hexLuminance(colorHex) > 0.55 : false
|
||||
const useDark = shouldUseDarkText(colorHex)
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex-1 bg-surface-2 rounded-full h-6 overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full rounded-full ${color ?? ''}`}
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.max(width, 1)}%`,
|
||||
...(colorHex ? { backgroundColor: colorHex } : {}),
|
||||
backgroundColor: colorHex,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`absolute inset-0 flex items-center px-3 text-xs font-medium capitalize truncate ${
|
||||
isLight ? 'text-gray-900 dark:text-gray-900' : 'text-text-primary'
|
||||
useDark ? 'text-gray-900' : 'text-white'
|
||||
}`}
|
||||
style={{
|
||||
textShadow: isLight ? '0 0 4px rgba(255,255,255,0.8)' : '0 0 4px rgba(0,0,0,0.3)',
|
||||
textShadow: useDark ? '0 0 4px rgba(255,255,255,0.8)' : '0 0 4px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
@@ -166,7 +172,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
|
||||
label={g.gameName}
|
||||
value={g.count}
|
||||
max={gameMax}
|
||||
{...(g.gameColor ? { colorHex: g.gameColor } : { color: 'bg-blue-500' })}
|
||||
colorHex={g.gameColor ?? '#3b82f6'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -258,7 +264,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
|
||||
label={t.type}
|
||||
value={t.count}
|
||||
max={typeMax}
|
||||
color={typeBarColors[t.type] ?? 'bg-gray-500'}
|
||||
colorHex={typeBarColors[t.type] ?? '#6b7280'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -75,9 +75,9 @@ export function AdminEvolutions() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex flex-wrap justify-between items-center gap-2 mb-4">
|
||||
<h2 className="text-xl font-semibold">Evolutions</h2>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const data = await exportEvolutions()
|
||||
@@ -102,7 +102,7 @@ export function AdminEvolutions() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
@@ -114,6 +114,7 @@ export function AdminEvolutions() {
|
||||
className="w-full max-w-sm px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
||||
/>
|
||||
<select
|
||||
aria-label="Filter by trigger"
|
||||
value={triggerFilter}
|
||||
onChange={(e) => {
|
||||
setTriggerFilter(e.target.value)
|
||||
|
||||
@@ -70,8 +70,9 @@ export function AdminGames() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4">
|
||||
<select
|
||||
aria-label="Filter by region"
|
||||
value={regionFilter}
|
||||
onChange={(e) => setRegionFilter(e.target.value)}
|
||||
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
||||
@@ -84,6 +85,7 @@ export function AdminGames() {
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filter by generation"
|
||||
value={genFilter}
|
||||
onChange={(e) => setGenFilter(e.target.value)}
|
||||
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
||||
|
||||
@@ -116,6 +116,7 @@ export function AdminPokemon() {
|
||||
className="w-full max-w-sm px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
||||
/>
|
||||
<select
|
||||
aria-label="Filter by type"
|
||||
value={typeFilter}
|
||||
onChange={(e) => {
|
||||
setTypeFilter(e.target.value)
|
||||
|
||||
17
frontend/src/test/setup.ts
Normal 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(),
|
||||
})),
|
||||
})
|
||||
29
frontend/src/test/utils.tsx
Normal 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 }
|
||||
@@ -79,6 +79,7 @@ export interface Encounter {
|
||||
faintLevel: number | null
|
||||
deathCause: string | null
|
||||
isShiny: boolean
|
||||
origin: string | null
|
||||
caughtAt: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +1,61 @@
|
||||
export interface NuzlockeRules {
|
||||
// Core rules
|
||||
firstEncounterOnly: boolean
|
||||
permadeath: boolean
|
||||
nicknameRequired: boolean
|
||||
// Core rules (affect tracker behavior)
|
||||
duplicatesClause: boolean
|
||||
shinyClause: boolean
|
||||
giftClause: boolean
|
||||
staticClause: boolean
|
||||
pinwheelClause: boolean
|
||||
|
||||
// Difficulty modifiers
|
||||
hardcoreMode: boolean
|
||||
levelCaps: boolean
|
||||
setModeOnly: boolean
|
||||
|
||||
// Completion
|
||||
postGameCompletion: boolean
|
||||
// Playstyle (informational, for stats/categorization)
|
||||
hardcoreMode: boolean
|
||||
setModeOnly: boolean
|
||||
bossTeamMatch: boolean
|
||||
|
||||
// Variant (changes which Pokemon can appear)
|
||||
egglocke: boolean
|
||||
wonderlocke: boolean
|
||||
randomizer: boolean
|
||||
|
||||
// Type restriction (monolocke and variants)
|
||||
allowedTypes: string[]
|
||||
}
|
||||
|
||||
/** Keys of NuzlockeRules that are boolean toggles (excludes array fields) */
|
||||
type BooleanRuleKeys = Exclude<keyof NuzlockeRules, 'allowedTypes'>
|
||||
|
||||
export const DEFAULT_RULES: NuzlockeRules = {
|
||||
// Core rules - standard Nuzlocke
|
||||
firstEncounterOnly: true,
|
||||
permadeath: true,
|
||||
nicknameRequired: true,
|
||||
// Core rules
|
||||
duplicatesClause: true,
|
||||
shinyClause: true,
|
||||
giftClause: false,
|
||||
staticClause: true,
|
||||
pinwheelClause: true,
|
||||
|
||||
// Difficulty modifiers - off by default
|
||||
hardcoreMode: false,
|
||||
levelCaps: false,
|
||||
setModeOnly: false,
|
||||
|
||||
// Completion
|
||||
postGameCompletion: false,
|
||||
// Playstyle - off by default
|
||||
hardcoreMode: false,
|
||||
setModeOnly: false,
|
||||
bossTeamMatch: false,
|
||||
|
||||
// Variant - off by default
|
||||
egglocke: false,
|
||||
wonderlocke: false,
|
||||
randomizer: false,
|
||||
|
||||
// Type restriction - no restriction by default
|
||||
allowedTypes: [],
|
||||
}
|
||||
|
||||
export interface RuleDefinition {
|
||||
key: keyof NuzlockeRules
|
||||
key: BooleanRuleKeys
|
||||
name: string
|
||||
description: string
|
||||
category: 'core' | 'difficulty' | 'completion'
|
||||
category: 'core' | 'playstyle' | 'variant'
|
||||
}
|
||||
|
||||
export const RULE_DEFINITIONS: RuleDefinition[] = [
|
||||
// Core rules
|
||||
{
|
||||
key: 'firstEncounterOnly',
|
||||
name: 'First Encounter Only',
|
||||
description:
|
||||
'You may only catch the first Pokémon encountered in each area. If you fail to catch it, you get nothing from that area.',
|
||||
category: 'core',
|
||||
},
|
||||
{
|
||||
key: 'permadeath',
|
||||
name: 'Permadeath',
|
||||
description:
|
||||
'If a Pokémon faints, it is considered dead and must be released or permanently boxed.',
|
||||
category: 'core',
|
||||
},
|
||||
{
|
||||
key: 'nicknameRequired',
|
||||
name: 'Nickname Required',
|
||||
description: 'All caught Pokémon must be given a nickname to form a stronger bond.',
|
||||
category: 'core',
|
||||
},
|
||||
{
|
||||
key: 'duplicatesClause',
|
||||
name: 'Duplicates Clause',
|
||||
@@ -77,6 +70,20 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
|
||||
'Shiny Pokémon may always be caught, regardless of whether they are your first encounter.',
|
||||
category: 'core',
|
||||
},
|
||||
{
|
||||
key: 'giftClause',
|
||||
name: 'Gift Clause',
|
||||
description:
|
||||
"In-game gift Pokémon (starters, trades, fossils) do not count against a location's encounter limit.",
|
||||
category: 'core',
|
||||
},
|
||||
{
|
||||
key: 'staticClause',
|
||||
name: 'Static Clause',
|
||||
description:
|
||||
'Static encounters (legendaries, scripted Pokémon) are available in the encounter selector. Disable to skip them and treat the next wild encounter as your pick.',
|
||||
category: 'core',
|
||||
},
|
||||
{
|
||||
key: 'pinwheelClause',
|
||||
name: 'Pinwheel Clause',
|
||||
@@ -84,35 +91,56 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
|
||||
'Sub-zones within a location group each get their own encounter instead of sharing one.',
|
||||
category: 'core',
|
||||
},
|
||||
|
||||
// Difficulty modifiers
|
||||
{
|
||||
key: 'hardcoreMode',
|
||||
name: 'Hardcore Mode',
|
||||
description: 'No items may be used during battle. Held items are still allowed.',
|
||||
category: 'difficulty',
|
||||
},
|
||||
{
|
||||
key: 'levelCaps',
|
||||
name: 'Level Caps',
|
||||
description:
|
||||
"Your Pokémon cannot exceed the level of the next Gym Leader's highest-level Pokémon before challenging them.",
|
||||
category: 'difficulty',
|
||||
category: 'core',
|
||||
},
|
||||
|
||||
// Playstyle
|
||||
{
|
||||
key: 'hardcoreMode',
|
||||
name: 'Hardcore Mode',
|
||||
description: 'No items may be used during battle. Held items are still allowed.',
|
||||
category: 'playstyle',
|
||||
},
|
||||
{
|
||||
key: 'setModeOnly',
|
||||
name: 'Set Mode Only',
|
||||
description:
|
||||
'The game must be played in "Set" battle style, meaning you cannot switch Pokémon after knocking out an opponent.',
|
||||
category: 'difficulty',
|
||||
category: 'playstyle',
|
||||
},
|
||||
{
|
||||
key: 'bossTeamMatch',
|
||||
name: 'Boss Team Match',
|
||||
description:
|
||||
'Limit your active party to the same number of Pokémon as the boss you are challenging.',
|
||||
category: 'playstyle',
|
||||
},
|
||||
|
||||
// Completion
|
||||
// Variant
|
||||
{
|
||||
key: 'postGameCompletion',
|
||||
name: 'Post-Game Completion',
|
||||
key: 'egglocke',
|
||||
name: 'Egglocke',
|
||||
description:
|
||||
'The run continues into post-game content instead of ending after the Champion is defeated.',
|
||||
category: 'completion',
|
||||
'All caught Pokémon are replaced with traded eggs. The encounter selector shows all Pokémon since the hatched species is unknown.',
|
||||
category: 'variant',
|
||||
},
|
||||
{
|
||||
key: 'wonderlocke',
|
||||
name: 'Wonderlocke',
|
||||
description:
|
||||
'All caught Pokémon are Wonder Traded away. The encounter selector shows all Pokémon since the received species is unknown.',
|
||||
category: 'variant',
|
||||
},
|
||||
{
|
||||
key: 'randomizer',
|
||||
name: 'Randomizer',
|
||||
description:
|
||||
"The ROM's wild Pokémon are randomized, so the encounter selector shows all Pokémon instead of the game's regional dex.",
|
||||
category: 'variant',
|
||||
},
|
||||
]
|
||||
|
||||
47
frontend/src/utils/download.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
51
frontend/src/utils/formatEvolution.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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 */
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["vite.config.ts", "playwright.config.ts"]
|
||||
}
|
||||
|
||||