Compare commits
2 Commits
develop
...
68d9f17db3
| Author | SHA1 | Date | |
|---|---|---|---|
| 68d9f17db3 | |||
| 93eab59a3e |
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-9c8d
|
||||||
|
title: Rebrand to Another Nuzlocke Tracker (ANT)
|
||||||
|
status: in-progress
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-10T14:46:09Z
|
||||||
|
updated_at: 2026-02-17T19:08:18Z
|
||||||
|
---
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
# 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,19 +0,0 @@
|
|||||||
---
|
|
||||||
# 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.
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
# 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,32 +0,0 @@
|
|||||||
---
|
|
||||||
# nuzlocke-tracker-mz16
|
|
||||||
title: Session Journal / Blog Posts
|
|
||||||
status: draft
|
|
||||||
type: epic
|
|
||||||
created_at: 2026-02-19T07:43:05Z
|
|
||||||
updated_at: 2026-02-19T07:43:05Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Let users tell the story of their nuzlocke run through session journal entries (blog posts).
|
|
||||||
|
|
||||||
Nuzlockes are inherently story-driven — encounters you first think are weak become the star of the show, a needed sacrifice lets the run survive, one crit in a boss battle means defeat. Users should be able to capture those moments.
|
|
||||||
|
|
||||||
## Concept
|
|
||||||
|
|
||||||
For each play session, users can write a short post to document what happened. Posts can:
|
|
||||||
|
|
||||||
- Include rich text / markdown content
|
|
||||||
- Embed screenshots and images
|
|
||||||
- Automatically link to their current team (or a subset of it)
|
|
||||||
- Reference deaths, new encounters, and other run events
|
|
||||||
- Be tied to a specific run
|
|
||||||
|
|
||||||
The journal becomes a chronological narrative of the nuzlocke run, with game data woven in automatically.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- [ ] What editor experience? (Markdown, rich text, block editor?)
|
|
||||||
- [ ] How are images stored? (Local uploads, external links, cloud storage?)
|
|
||||||
- [ ] What run events can be linked/embedded? (Team snapshots, deaths, catches, badge progress?)
|
|
||||||
- [ ] Should posts be publishable/shareable, or private by default?
|
|
||||||
- [ ] How does the journal UI look? Timeline view? Blog-style list?
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
# 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)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
67
.github/workflows/ci.yml
vendored
@@ -22,39 +22,40 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-tests:
|
backend-lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
ports:
|
|
||||||
- 5433:5432
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
POSTGRES_DB: nuzlocke_test
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U postgres"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install uv and Python
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
run: |
|
with:
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
python-version: "3.14"
|
||||||
source "$HOME/.local/bin/env"
|
- run: pip install ruff ty
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
- name: Check linting
|
||||||
uv python install 3.14
|
run: ruff check backend/
|
||||||
- name: Run tests
|
- name: Check formatting
|
||||||
run: uv run --python 3.14 --extra dev pytest -q
|
run: ruff format --check backend/
|
||||||
working-directory: backend
|
- name: Type check
|
||||||
env:
|
run: ty check backend/src/
|
||||||
TEST_DATABASE_URL: postgresql+asyncpg://postgres:postgres@192.168.1.10:5433/nuzlocke_test
|
continue-on-error: true
|
||||||
|
|
||||||
frontend-tests:
|
actions-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Install actionlint
|
||||||
|
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/
|
||||||
|
|
||||||
|
frontend-lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
@@ -66,6 +67,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Run tests
|
- name: Lint
|
||||||
run: npm test
|
run: npm run lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
- name: Check formatting
|
||||||
|
run: npx oxfmt --check "src/"
|
||||||
|
working-directory: frontend
|
||||||
|
- name: Type check
|
||||||
|
run: npx tsc -b
|
||||||
|
working-directory: frontend
|
||||||
|
|||||||
35
.github/workflows/e2e.yml
vendored
@@ -1,35 +0,0 @@
|
|||||||
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,6 +66,4 @@ root = "src"
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
asyncio_default_fixture_loop_scope = "session"
|
|
||||||
asyncio_default_test_loop_scope = "session"
|
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"""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,13 +58,12 @@ async def create_encounter(
|
|||||||
detail="Cannot create encounter on a parent route. Use a child route instead.",
|
detail="Cannot create encounter on a parent route. Use a child route instead.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Shiny/gift clause: certain encounters bypass the route-lock check
|
# Shiny clause: shiny encounters bypass the route-lock check
|
||||||
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
|
shiny_clause_on = run.rules.get("shinyClause", True) if run.rules else True
|
||||||
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 in (
|
||||||
skip_route_lock = (
|
"shed_evolution",
|
||||||
(data.is_shiny and shiny_clause_on)
|
"egg",
|
||||||
or (data.origin == "gift" and gift_clause_on)
|
"transfer",
|
||||||
or data.origin in ("shed_evolution", "egg", "transfer")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# If this route has a parent, check if sibling already has an encounter
|
# If this route has a parent, check if sibling already has an encounter
|
||||||
@@ -94,17 +93,13 @@ async def create_encounter(
|
|||||||
# Check if any relevant sibling already has an encounter in this run
|
# Check if any relevant sibling already has an encounter in this run
|
||||||
# Exclude transfer-target encounters so they don't block the starter
|
# Exclude transfer-target encounters so they don't block the starter
|
||||||
transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
|
transfer_target_ids = select(GenlockeTransfer.target_encounter_id)
|
||||||
lock_query = select(Encounter).where(
|
existing_encounter = await session.execute(
|
||||||
Encounter.run_id == run_id,
|
select(Encounter).where(
|
||||||
Encounter.route_id.in_(sibling_ids),
|
Encounter.run_id == run_id,
|
||||||
~Encounter.id.in_(transfer_target_ids),
|
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:
|
if existing_encounter.scalar_one_or_none() is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
@@ -124,7 +119,6 @@ async def create_encounter(
|
|||||||
status=data.status,
|
status=data.status,
|
||||||
catch_level=data.catch_level,
|
catch_level=data.catch_level,
|
||||||
is_shiny=data.is_shiny,
|
is_shiny=data.is_shiny,
|
||||||
origin=data.origin,
|
|
||||||
)
|
)
|
||||||
session.add(encounter)
|
session.add(encounter)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import delete, select, update
|
from sqlalchemy import delete, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -131,7 +131,6 @@ async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
|
|||||||
async def list_game_routes(
|
async def list_game_routes(
|
||||||
game_id: int,
|
game_id: int,
|
||||||
flat: bool = False,
|
flat: bool = False,
|
||||||
allowed_types: list[str] | None = Query(None),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -139,18 +138,13 @@ async def list_game_routes(
|
|||||||
|
|
||||||
By default, returns a hierarchical structure with top-level routes containing
|
By default, returns a hierarchical structure with top-level routes containing
|
||||||
nested children. Use `flat=True` to get a flat list of all routes.
|
nested children. Use `flat=True` to get a flat list of all routes.
|
||||||
|
|
||||||
When `allowed_types` is provided, routes with no encounters matching any of
|
|
||||||
those Pokemon types are excluded.
|
|
||||||
"""
|
"""
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Route)
|
select(Route)
|
||||||
.where(Route.version_group_id == vg_id)
|
.where(Route.version_group_id == vg_id)
|
||||||
.options(
|
.options(selectinload(Route.route_encounters))
|
||||||
selectinload(Route.route_encounters).selectinload(RouteEncounter.pokemon)
|
|
||||||
)
|
|
||||||
.order_by(Route.order)
|
.order_by(Route.order)
|
||||||
)
|
)
|
||||||
all_routes = result.scalars().all()
|
all_routes = result.scalars().all()
|
||||||
@@ -176,14 +170,7 @@ async def list_game_routes(
|
|||||||
|
|
||||||
# Determine which routes have encounters for this game
|
# Determine which routes have encounters for this game
|
||||||
def has_encounters(route: Route) -> bool:
|
def has_encounters(route: Route) -> bool:
|
||||||
encounters = [re for re in route.route_encounters if re.game_id == game_id]
|
return any(re.game_id == game_id for re in route.route_encounters)
|
||||||
if not encounters:
|
|
||||||
return False
|
|
||||||
if allowed_types:
|
|
||||||
return any(
|
|
||||||
t in allowed_types for re in encounters for t in re.pokemon.types
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Collect IDs of parent routes that have at least one child with encounters
|
# Collect IDs of parent routes that have at least one child with encounters
|
||||||
parents_with_children = set()
|
parents_with_children = set()
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class Encounter(Base):
|
|||||||
is_shiny: Mapped[bool] = mapped_column(
|
is_shiny: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default=text("false")
|
Boolean, default=False, server_default=text("false")
|
||||||
)
|
)
|
||||||
origin: Mapped[str | None] = mapped_column(String(20))
|
|
||||||
caught_at: Mapped[datetime] = mapped_column(
|
caught_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
DateTime(timezone=True), server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ class EncounterResponse(CamelModel):
|
|||||||
faint_level: int | None
|
faint_level: int | None
|
||||||
death_cause: str | None
|
death_cause: str | None
|
||||||
is_shiny: bool
|
is_shiny: bool
|
||||||
origin: str | None
|
|
||||||
caught_at: datetime
|
caught_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python -m app.seeds # Run seed
|
python -m app.seeds # Run seed
|
||||||
python -m app.seeds --prune # Run seed and remove stale data not in seed files
|
|
||||||
python -m app.seeds --verify # Run seed + verification
|
python -m app.seeds --verify # Run seed + verification
|
||||||
python -m app.seeds --export # Export all seed data from DB to JSON files
|
python -m app.seeds --export # Export all seed data from DB to JSON files
|
||||||
"""
|
"""
|
||||||
@@ -22,8 +21,7 @@ async def main():
|
|||||||
await export_all()
|
await export_all()
|
||||||
return
|
return
|
||||||
|
|
||||||
prune = "--prune" in sys.argv
|
await seed()
|
||||||
await seed(prune=prune)
|
|
||||||
if "--verify" in sys.argv:
|
if "--verify" in sys.argv:
|
||||||
await verify()
|
await verify()
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ RUN_DEFS = [
|
|||||||
"name": "Unova Adventure",
|
"name": "Unova Adventure",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"progress": 0.35,
|
"progress": 0.35,
|
||||||
"rules": {"randomizer": True},
|
"rules": {},
|
||||||
"started_days_ago": 5,
|
"started_days_ago": 5,
|
||||||
"ended_days_ago": None,
|
"ended_days_ago": None,
|
||||||
},
|
},
|
||||||
@@ -142,19 +142,15 @@ RUN_DEFS = [
|
|||||||
|
|
||||||
# Default rules (matches frontend DEFAULT_RULES)
|
# Default rules (matches frontend DEFAULT_RULES)
|
||||||
DEFAULT_RULES = {
|
DEFAULT_RULES = {
|
||||||
|
"firstEncounterOnly": True,
|
||||||
|
"permadeath": True,
|
||||||
|
"nicknameRequired": True,
|
||||||
"duplicatesClause": True,
|
"duplicatesClause": True,
|
||||||
"shinyClause": True,
|
"shinyClause": True,
|
||||||
"giftClause": False,
|
|
||||||
"staticClause": True,
|
|
||||||
"pinwheelClause": True,
|
"pinwheelClause": True,
|
||||||
"levelCaps": False,
|
|
||||||
"hardcoreMode": False,
|
"hardcoreMode": False,
|
||||||
|
"levelCaps": False,
|
||||||
"setModeOnly": False,
|
"setModeOnly": False,
|
||||||
"bossTeamMatch": False,
|
|
||||||
"egglocke": False,
|
|
||||||
"wonderlocke": False,
|
|
||||||
"randomizer": False,
|
|
||||||
"allowedTypes": [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"""Database upsert helpers for seed data."""
|
"""Database upsert helpers for seed data."""
|
||||||
|
|
||||||
from sqlalchemy import delete, select, update
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.boss_battle import BossBattle
|
from app.models.boss_battle import BossBattle
|
||||||
from app.models.boss_pokemon import BossPokemon
|
from app.models.boss_pokemon import BossPokemon
|
||||||
from app.models.encounter import Encounter
|
|
||||||
from app.models.evolution import Evolution
|
from app.models.evolution import Evolution
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
from app.models.pokemon import Pokemon
|
from app.models.pokemon import Pokemon
|
||||||
@@ -125,14 +124,11 @@ async def upsert_routes(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
version_group_id: int,
|
version_group_id: int,
|
||||||
routes: list[dict],
|
routes: list[dict],
|
||||||
*,
|
|
||||||
prune: bool = False,
|
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Upsert route records for a version group, return {name: id} mapping.
|
"""Upsert route records for a version group, return {name: id} mapping.
|
||||||
|
|
||||||
Handles hierarchical routes: routes with 'children' are parent routes,
|
Handles hierarchical routes: routes with 'children' are parent routes,
|
||||||
and their children get parent_route_id set accordingly.
|
and their children get parent_route_id set accordingly.
|
||||||
When prune is True, deletes routes not present in the seed data.
|
|
||||||
"""
|
"""
|
||||||
# First pass: upsert all parent routes (without parent_route_id)
|
# First pass: upsert all parent routes (without parent_route_id)
|
||||||
for route in routes:
|
for route in routes:
|
||||||
@@ -189,43 +185,6 @@ async def upsert_routes(
|
|||||||
|
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
if prune:
|
|
||||||
seed_names: set[str] = set()
|
|
||||||
for route in routes:
|
|
||||||
seed_names.add(route["name"])
|
|
||||||
for child in route.get("children", []):
|
|
||||||
seed_names.add(child["name"])
|
|
||||||
|
|
||||||
# Find stale route IDs, excluding routes with user encounters
|
|
||||||
in_use_subq = select(Encounter.route_id).distinct().subquery()
|
|
||||||
stale_route_ids_result = await session.execute(
|
|
||||||
select(Route.id).where(
|
|
||||||
Route.version_group_id == version_group_id,
|
|
||||||
Route.name.not_in(seed_names),
|
|
||||||
Route.id.not_in(select(in_use_subq)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
stale_route_ids = [row.id for row in stale_route_ids_result]
|
|
||||||
|
|
||||||
if stale_route_ids:
|
|
||||||
# Delete encounters referencing stale routes (no DB-level cascade)
|
|
||||||
await session.execute(
|
|
||||||
delete(RouteEncounter).where(
|
|
||||||
RouteEncounter.route_id.in_(stale_route_ids)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Nullify boss battle references to stale routes
|
|
||||||
await session.execute(
|
|
||||||
update(BossBattle)
|
|
||||||
.where(BossBattle.after_route_id.in_(stale_route_ids))
|
|
||||||
.values(after_route_id=None)
|
|
||||||
)
|
|
||||||
# Now safe to delete the routes
|
|
||||||
await session.execute(delete(Route).where(Route.id.in_(stale_route_ids)))
|
|
||||||
print(f" Pruned {len(stale_route_ids)} stale route(s)")
|
|
||||||
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Return full mapping including children
|
# Return full mapping including children
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Route.name, Route.id).where(Route.version_group_id == version_group_id)
|
select(Route.name, Route.id).where(Route.version_group_id == version_group_id)
|
||||||
@@ -274,15 +233,8 @@ async def upsert_route_encounters(
|
|||||||
encounters: list[dict],
|
encounters: list[dict],
|
||||||
dex_to_id: dict[int, int],
|
dex_to_id: dict[int, int],
|
||||||
game_id: int,
|
game_id: int,
|
||||||
*,
|
|
||||||
prune: bool = False,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Upsert encounters for a route and game, return count of upserted rows.
|
"""Upsert encounters for a route and game, return count of upserted rows."""
|
||||||
|
|
||||||
When prune is True, deletes encounters not present in the seed data.
|
|
||||||
"""
|
|
||||||
seed_keys: set[tuple[int, str, str]] = set()
|
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for enc in encounters:
|
for enc in encounters:
|
||||||
pokemon_id = dex_to_id.get(enc["pokeapi_id"])
|
pokemon_id = dex_to_id.get(enc["pokeapi_id"])
|
||||||
@@ -293,7 +245,6 @@ async def upsert_route_encounters(
|
|||||||
conditions = enc.get("conditions")
|
conditions = enc.get("conditions")
|
||||||
if conditions:
|
if conditions:
|
||||||
for condition_name, rate in conditions.items():
|
for condition_name, rate in conditions.items():
|
||||||
seed_keys.add((pokemon_id, enc["method"], condition_name))
|
|
||||||
await _upsert_single_encounter(
|
await _upsert_single_encounter(
|
||||||
session,
|
session,
|
||||||
route_id,
|
route_id,
|
||||||
@@ -307,7 +258,6 @@ async def upsert_route_encounters(
|
|||||||
)
|
)
|
||||||
count += 1
|
count += 1
|
||||||
else:
|
else:
|
||||||
seed_keys.add((pokemon_id, enc["method"], ""))
|
|
||||||
await _upsert_single_encounter(
|
await _upsert_single_encounter(
|
||||||
session,
|
session,
|
||||||
route_id,
|
route_id,
|
||||||
@@ -320,23 +270,6 @@ async def upsert_route_encounters(
|
|||||||
)
|
)
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
if prune:
|
|
||||||
existing = await session.execute(
|
|
||||||
select(RouteEncounter).where(
|
|
||||||
RouteEncounter.route_id == route_id,
|
|
||||||
RouteEncounter.game_id == game_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
stale_ids = [
|
|
||||||
row.id
|
|
||||||
for row in existing.scalars()
|
|
||||||
if (row.pokemon_id, row.encounter_method, row.condition) not in seed_keys
|
|
||||||
]
|
|
||||||
if stale_ids:
|
|
||||||
await session.execute(
|
|
||||||
delete(RouteEncounter).where(RouteEncounter.id.in_(stale_ids))
|
|
||||||
)
|
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
@@ -347,13 +280,8 @@ async def upsert_bosses(
|
|||||||
dex_to_id: dict[int, int],
|
dex_to_id: dict[int, int],
|
||||||
route_name_to_id: dict[str, int] | None = None,
|
route_name_to_id: dict[str, int] | None = None,
|
||||||
slug_to_game_id: dict[str, int] | None = None,
|
slug_to_game_id: dict[str, int] | None = None,
|
||||||
*,
|
|
||||||
prune: bool = False,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Upsert boss battles for a version group, return count of bosses upserted.
|
"""Upsert boss battles for a version group, return count of bosses upserted."""
|
||||||
|
|
||||||
When prune is True, deletes boss battles not present in the seed data.
|
|
||||||
"""
|
|
||||||
count = 0
|
count = 0
|
||||||
for boss in bosses:
|
for boss in bosses:
|
||||||
# Resolve after_route_name to an ID
|
# Resolve after_route_name to an ID
|
||||||
@@ -436,20 +364,6 @@ async def upsert_bosses(
|
|||||||
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
if prune:
|
|
||||||
seed_orders = {boss["order"] for boss in bosses}
|
|
||||||
pruned = await session.execute(
|
|
||||||
delete(BossBattle)
|
|
||||||
.where(
|
|
||||||
BossBattle.version_group_id == version_group_id,
|
|
||||||
BossBattle.order.not_in(seed_orders),
|
|
||||||
)
|
|
||||||
.returning(BossBattle.id)
|
|
||||||
)
|
|
||||||
pruned_count = len(pruned.all())
|
|
||||||
if pruned_count:
|
|
||||||
print(f" Pruned {pruned_count} stale boss battle(s)")
|
|
||||||
|
|
||||||
await session.flush()
|
await session.flush()
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,9 @@ def load_json(filename: str):
|
|||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
async def seed(*, prune: bool = False):
|
async def seed():
|
||||||
"""Run the full seed process.
|
"""Run the full seed process."""
|
||||||
|
print("Starting seed...")
|
||||||
When prune is True, removes DB rows not present in seed data.
|
|
||||||
"""
|
|
||||||
print("Starting seed..." + (" (with pruning)" if prune else ""))
|
|
||||||
|
|
||||||
async with async_session() as session, session.begin():
|
async with async_session() as session, session.begin():
|
||||||
# 1. Upsert version groups
|
# 1. Upsert version groups
|
||||||
@@ -91,7 +88,7 @@ async def seed(*, prune: bool = False):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Upsert routes once per version group
|
# Upsert routes once per version group
|
||||||
route_map = await upsert_routes(session, vg_id, routes_data, prune=prune)
|
route_map = await upsert_routes(session, vg_id, routes_data)
|
||||||
route_maps_by_vg[vg_id] = route_map
|
route_maps_by_vg[vg_id] = route_map
|
||||||
total_routes += len(route_map)
|
total_routes += len(route_map)
|
||||||
print(f" {vg_slug}: {len(route_map)} routes")
|
print(f" {vg_slug}: {len(route_map)} routes")
|
||||||
@@ -122,7 +119,6 @@ async def seed(*, prune: bool = False):
|
|||||||
route["encounters"],
|
route["encounters"],
|
||||||
dex_to_id,
|
dex_to_id,
|
||||||
game_id,
|
game_id,
|
||||||
prune=prune,
|
|
||||||
)
|
)
|
||||||
total_encounters += enc_count
|
total_encounters += enc_count
|
||||||
|
|
||||||
@@ -141,7 +137,6 @@ async def seed(*, prune: bool = False):
|
|||||||
child["encounters"],
|
child["encounters"],
|
||||||
dex_to_id,
|
dex_to_id,
|
||||||
game_id,
|
game_id,
|
||||||
prune=prune,
|
|
||||||
)
|
)
|
||||||
total_encounters += enc_count
|
total_encounters += enc_count
|
||||||
|
|
||||||
@@ -165,13 +160,7 @@ async def seed(*, prune: bool = False):
|
|||||||
|
|
||||||
route_name_to_id = route_maps_by_vg.get(vg_id, {})
|
route_name_to_id = route_maps_by_vg.get(vg_id, {})
|
||||||
boss_count = await upsert_bosses(
|
boss_count = await upsert_bosses(
|
||||||
session,
|
session, vg_id, bosses_data, dex_to_id, route_name_to_id, slug_to_id
|
||||||
vg_id,
|
|
||||||
bosses_data,
|
|
||||||
dex_to_id,
|
|
||||||
route_name_to_id,
|
|
||||||
slug_to_id,
|
|
||||||
prune=prune,
|
|
||||||
)
|
)
|
||||||
total_bosses += boss_count
|
total_bosses += boss_count
|
||||||
print(f" {vg_slug}: {boss_count} bosses")
|
print(f" {vg_slug}: {boss_count} bosses")
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,594 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,572 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""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
@@ -1,670 +0,0 @@
|
|||||||
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" },
|
|
||||||
]
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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,12 +12,6 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Playwright
|
|
||||||
e2e/.fixtures.json
|
|
||||||
e2e/screenshots/
|
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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,15 +17,6 @@
|
|||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
var t = localStorage.getItem('ant-theme');
|
|
||||||
if (t === 'light' || (!t && window.matchMedia('(prefers-color-scheme: light)').matches)) {
|
|
||||||
document.documentElement.setAttribute('data-theme', 'light');
|
|
||||||
document.documentElement.style.colorScheme = 'light';
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
936
frontend/package-lock.json
generated
@@ -11,9 +11,7 @@
|
|||||||
"format:check": "oxfmt --check src/",
|
"format:check": "oxfmt --check src/",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest"
|
||||||
"test:e2e": "playwright test",
|
|
||||||
"test:e2e:ui": "playwright test --ui"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
@@ -26,17 +24,11 @@
|
|||||||
"sonner": "2.0.7"
|
"sonner": "2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.11.1",
|
|
||||||
"@playwright/test": "1.58.2",
|
|
||||||
"@tailwindcss/vite": "4.1.18",
|
"@tailwindcss/vite": "4.1.18",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
|
||||||
"@testing-library/react": "^16.3.2",
|
|
||||||
"@testing-library/user-event": "^14.6.1",
|
|
||||||
"@types/node": "24.10.10",
|
"@types/node": "24.10.10",
|
||||||
"@types/react": "19.2.11",
|
"@types/react": "19.2.11",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "5.1.3",
|
"@vitejs/plugin-react": "5.1.3",
|
||||||
"jsdom": "^28.1.0",
|
|
||||||
"oxfmt": "0.33.0",
|
"oxfmt": "0.33.0",
|
||||||
"oxlint": "1.48.0",
|
"oxlint": "1.48.0",
|
||||||
"tailwindcss": "4.1.18",
|
"tailwindcss": "4.1.18",
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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: 905 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 758 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 696 B After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 703 B After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 774 B After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 826 B After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 707 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 754 B After Width: | Height: | Size: 4.5 KiB |
@@ -1,92 +0,0 @@
|
|||||||
Copyright (c) 2023 Vercel, in collaboration with basement.studio
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION AND CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -13,12 +13,10 @@ export function getGame(id: number): Promise<GameDetail> {
|
|||||||
return api.get(`/games/${id}`)
|
return api.get(`/games/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGameRoutes(gameId: number, allowedTypes?: string[]): Promise<Route[]> {
|
export function getGameRoutes(gameId: number): Promise<Route[]> {
|
||||||
// Use flat=true to get all routes in a flat list
|
// Use flat=true to get all routes in a flat list
|
||||||
// The frontend organizes them into hierarchy based on parentRouteId
|
// The frontend organizes them into hierarchy based on parentRouteId
|
||||||
const params = new URLSearchParams({ flat: 'true' })
|
return api.get(`/games/${gameId}/routes?flat=true`)
|
||||||
for (const t of allowedTypes ?? []) params.append('allowed_types', t)
|
|
||||||
return api.get(`/games/${gameId}/routes?${params}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRoutePokemon(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> {
|
export function getRoutePokemon(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> {
|
||||||
|
|||||||
@@ -66,15 +66,15 @@ export function BossDefeatModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-md w-full mx-4">
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
<div className="px-6 py-4 border-b border-border-default">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold">Battle: {boss.name}</h2>
|
<h2 className="text-lg font-semibold">Battle: {boss.name}</h2>
|
||||||
<p className="text-sm text-text-tertiary">{boss.location}</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">{boss.location}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Boss team preview */}
|
{/* Boss team preview */}
|
||||||
{boss.pokemon.length > 0 && (
|
{boss.pokemon.length > 0 && (
|
||||||
<div className="px-6 py-3 border-b border-border-default">
|
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
{showPills && (
|
{showPills && (
|
||||||
<div className="flex gap-1 mb-2 flex-wrap">
|
<div className="flex gap-1 mb-2 flex-wrap">
|
||||||
{variantLabels.map((label) => (
|
{variantLabels.map((label) => (
|
||||||
@@ -84,8 +84,8 @@ export function BossDefeatModal({
|
|||||||
onClick={() => setSelectedVariant(label)}
|
onClick={() => setSelectedVariant(label)}
|
||||||
className={`px-2 py-0.5 text-xs font-medium rounded-full transition-colors ${
|
className={`px-2 py-0.5 text-xs font-medium rounded-full transition-colors ${
|
||||||
selectedVariant === label
|
selectedVariant === label
|
||||||
? 'bg-accent-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -101,10 +101,14 @@ export function BossDefeatModal({
|
|||||||
{bp.pokemon.spriteUrl ? (
|
{bp.pokemon.spriteUrl ? (
|
||||||
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
|
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 bg-surface-3 rounded-full" />
|
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||||
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
|
{bp.pokemon.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Lv.{bp.level}
|
||||||
|
</span>
|
||||||
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -123,7 +127,7 @@ export function BossDefeatModal({
|
|||||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
|
||||||
result === 'won'
|
result === 'won'
|
||||||
? 'bg-green-600 text-white border-green-600'
|
? 'bg-green-600 text-white border-green-600'
|
||||||
: 'border-border-default hover:bg-surface-2'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Won
|
Won
|
||||||
@@ -135,7 +139,7 @@ export function BossDefeatModal({
|
|||||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
|
||||||
result === 'lost'
|
result === 'lost'
|
||||||
? 'bg-red-600 text-white border-red-600'
|
? 'bg-red-600 text-white border-red-600'
|
||||||
: 'border-border-default hover:bg-surface-2'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Lost
|
Lost
|
||||||
@@ -152,24 +156,24 @@ export function BossDefeatModal({
|
|||||||
min={1}
|
min={1}
|
||||||
value={attempts}
|
value={attempts}
|
||||||
onChange={(e) => setAttempts(e.target.value)}
|
onChange={(e) => setAttempts(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-t border-border-default flex justify-end gap-3">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500 disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isPending ? 'Saving...' : 'Save Result'}
|
{isPending ? 'Saving...' : 'Save Result'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
const CONDITION_CONFIG: Record<string, { label: string; color: string }> = {
|
const CONDITION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
'Mega Evolution': {
|
'Mega Evolution': {
|
||||||
label: 'Mega',
|
label: 'Mega',
|
||||||
color: 'bg-fuchsia-900/40 text-fuchsia-300 light:bg-fuchsia-100 light:text-fuchsia-700',
|
color: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-900/40 dark:text-fuchsia-300',
|
||||||
},
|
},
|
||||||
Gigantamax: {
|
Gigantamax: {
|
||||||
label: 'G-Max',
|
label: 'G-Max',
|
||||||
color: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-700',
|
color: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
},
|
},
|
||||||
Dynamax: {
|
Dynamax: {
|
||||||
label: 'D-Max',
|
label: 'D-Max',
|
||||||
color: 'bg-rose-900/40 text-rose-300 light:bg-rose-100 light:text-rose-700',
|
color: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
|
||||||
},
|
},
|
||||||
Terastallize: {
|
Terastallize: {
|
||||||
label: 'Tera',
|
label: 'Tera',
|
||||||
color: 'bg-teal-900/40 text-teal-300 light:bg-teal-100 light:text-teal-700',
|
color: 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,14 +76,17 @@ export function EggEncounterModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-surface-1 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="sticky top-0 bg-surface-1 border-b border-green-600 px-6 py-4 rounded-t-xl">
|
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-green-300 dark:border-green-600 px-6 py-4 rounded-t-xl">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
<span className="text-green-500">🥚</span>
|
<span className="text-green-500">🥚</span>
|
||||||
Log Egg Hatch
|
Log Egg Hatch
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="text-text-tertiary hover:text-text-primary">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -94,7 +97,7 @@ export function EggEncounterModal({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-status-active mt-1">
|
<p className="text-sm text-green-600 dark:text-green-400 mt-1">
|
||||||
Egg hatches bypass the one-per-route rule
|
Egg hatches bypass the one-per-route rule
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,13 +105,13 @@ export function EggEncounterModal({
|
|||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
{/* Route selector */}
|
{/* Route selector */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Hatch Location
|
Hatch Location
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={selectedRouteId ?? ''}
|
value={selectedRouteId ?? ''}
|
||||||
onChange={(e) => setSelectedRouteId(Number(e.target.value))}
|
onChange={(e) => setSelectedRouteId(Number(e.target.value))}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-green-500"
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
>
|
>
|
||||||
<option value="">Select a location...</option>
|
<option value="">Select a location...</option>
|
||||||
{leafRoutes.map((r) => (
|
{leafRoutes.map((r) => (
|
||||||
@@ -121,9 +124,11 @@ export function EggEncounterModal({
|
|||||||
|
|
||||||
{/* Pokemon search */}
|
{/* Pokemon search */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-secondary mb-1">Pokemon</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Pokemon
|
||||||
|
</label>
|
||||||
{selectedPokemon ? (
|
{selectedPokemon ? (
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-green-600 bg-green-900/20">
|
<div className="flex items-center gap-3 p-3 rounded-lg border border-green-300 dark:border-green-600 bg-green-50 dark:bg-green-900/20">
|
||||||
{selectedPokemon.spriteUrl ? (
|
{selectedPokemon.spriteUrl ? (
|
||||||
<img
|
<img
|
||||||
src={selectedPokemon.spriteUrl}
|
src={selectedPokemon.spriteUrl}
|
||||||
@@ -131,11 +136,11 @@ export function EggEncounterModal({
|
|||||||
className="w-10 h-10"
|
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">
|
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
||||||
{selectedPokemon.name[0]?.toUpperCase()}
|
{selectedPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="font-medium text-text-primary capitalize">
|
<span className="font-medium text-gray-900 dark:text-gray-100 capitalize">
|
||||||
{selectedPokemon.name}
|
{selectedPokemon.name}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -144,7 +149,7 @@ export function EggEncounterModal({
|
|||||||
setSearch('')
|
setSearch('')
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
}}
|
}}
|
||||||
className="ml-auto text-sm text-text-tertiary hover:text-text-secondary"
|
className="ml-auto text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
>
|
>
|
||||||
Change
|
Change
|
||||||
</button>
|
</button>
|
||||||
@@ -156,7 +161,7 @@ export function EggEncounterModal({
|
|||||||
placeholder="Search pokemon by name..."
|
placeholder="Search pokemon by name..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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 focus:outline-none focus:ring-2 focus:ring-green-500"
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
/>
|
/>
|
||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
@@ -170,16 +175,16 @@ export function EggEncounterModal({
|
|||||||
key={p.id}
|
key={p.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedPokemon(p)}
|
onClick={() => setSelectedPokemon(p)}
|
||||||
className="flex flex-col items-center p-2 rounded-lg border border-border-default hover:border-green-600 text-center transition-colors"
|
className="flex flex-col items-center p-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 text-center transition-colors"
|
||||||
>
|
>
|
||||||
{p.spriteUrl ? (
|
{p.spriteUrl ? (
|
||||||
<img src={p.spriteUrl} alt={p.name} className="w-10 h-10" />
|
<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">
|
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
||||||
{p.name[0]?.toUpperCase()}
|
{p.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-text-secondary mt-1 capitalize">
|
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
{p.name}
|
{p.name}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -187,7 +192,7 @@ export function EggEncounterModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{search.length >= 2 && !isSearching && searchResults.length === 0 && (
|
{search.length >= 2 && !isSearching && searchResults.length === 0 && (
|
||||||
<p className="text-sm text-text-tertiary py-2">No pokemon found</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">No pokemon found</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -198,7 +203,7 @@ export function EggEncounterModal({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="egg-nickname"
|
htmlFor="egg-nickname"
|
||||||
className="block text-sm font-medium text-text-secondary mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Nickname
|
Nickname
|
||||||
</label>
|
</label>
|
||||||
@@ -208,7 +213,7 @@ export function EggEncounterModal({
|
|||||||
value={nickname}
|
value={nickname}
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
placeholder="Give it a name..."
|
placeholder="Give it a name..."
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-green-500"
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -218,7 +223,7 @@ export function EggEncounterModal({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="egg-catch-level"
|
htmlFor="egg-catch-level"
|
||||||
className="block text-sm font-medium text-text-secondary mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Hatch Level
|
Hatch Level
|
||||||
</label>
|
</label>
|
||||||
@@ -230,17 +235,17 @@ export function EggEncounterModal({
|
|||||||
value={catchLevel}
|
value={catchLevel}
|
||||||
onChange={(e) => setCatchLevel(e.target.value)}
|
onChange={(e) => setCatchLevel(e.target.value)}
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
className="w-24 px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-green-500"
|
className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky bottom-0 bg-surface-1 border-t border-border-default px-6 py-4 rounded-b-xl flex justify-end gap-3">
|
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-text-secondary bg-surface-2 rounded-lg font-medium hover:bg-surface-3 transition-colors"
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,59 +1,55 @@
|
|||||||
export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
starter: {
|
starter: {
|
||||||
label: 'Starter',
|
label: 'Starter',
|
||||||
color: 'bg-yellow-900/40 text-yellow-300 light:bg-yellow-100 light:text-yellow-800',
|
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||||
},
|
},
|
||||||
gift: {
|
gift: {
|
||||||
label: 'Gift',
|
label: 'Gift',
|
||||||
color: 'bg-pink-900/40 text-pink-300 light:bg-pink-100 light:text-pink-700',
|
color: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300',
|
||||||
},
|
},
|
||||||
fossil: {
|
fossil: {
|
||||||
label: 'Fossil',
|
label: 'Fossil',
|
||||||
color: 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-800',
|
color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
},
|
},
|
||||||
trade: {
|
trade: {
|
||||||
label: 'Trade',
|
label: 'Trade',
|
||||||
color: 'bg-emerald-900/40 text-emerald-300 light:bg-emerald-100 light:text-emerald-700',
|
color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||||
},
|
|
||||||
static: {
|
|
||||||
label: 'Static',
|
|
||||||
color: 'bg-teal-900/40 text-teal-300 light:bg-teal-100 light:text-teal-700',
|
|
||||||
},
|
},
|
||||||
walk: {
|
walk: {
|
||||||
label: 'Grass',
|
label: 'Grass',
|
||||||
color: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-700',
|
color: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
},
|
},
|
||||||
headbutt: {
|
headbutt: {
|
||||||
label: 'Headbutt',
|
label: 'Headbutt',
|
||||||
color: 'bg-lime-900/40 text-lime-300 light:bg-lime-100 light:text-lime-800',
|
color: 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300',
|
||||||
},
|
},
|
||||||
surf: {
|
surf: {
|
||||||
label: 'Surfing',
|
label: 'Surfing',
|
||||||
color: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700',
|
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
},
|
},
|
||||||
'rock-smash': {
|
'rock-smash': {
|
||||||
label: 'Rock Smash',
|
label: 'Rock Smash',
|
||||||
color: 'bg-orange-900/40 text-orange-300 light:bg-orange-100 light:text-orange-800',
|
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
|
||||||
},
|
},
|
||||||
'old-rod': {
|
'old-rod': {
|
||||||
label: 'Old Rod',
|
label: 'Old Rod',
|
||||||
color: 'bg-cyan-900/40 text-cyan-300 light:bg-cyan-100 light:text-cyan-700',
|
color: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||||
},
|
},
|
||||||
'good-rod': {
|
'good-rod': {
|
||||||
label: 'Good Rod',
|
label: 'Good Rod',
|
||||||
color: 'bg-sky-900/40 text-sky-300 light:bg-sky-100 light:text-sky-700',
|
color: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
|
||||||
},
|
},
|
||||||
'super-rod': {
|
'super-rod': {
|
||||||
label: 'Super Rod',
|
label: 'Super Rod',
|
||||||
color: 'bg-indigo-900/40 text-indigo-300 light:bg-indigo-100 light:text-indigo-700',
|
color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||||
},
|
},
|
||||||
horde: {
|
horde: {
|
||||||
label: 'Horde',
|
label: 'Horde',
|
||||||
color: 'bg-rose-900/40 text-rose-300 light:bg-rose-100 light:text-rose-700',
|
color: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
|
||||||
},
|
},
|
||||||
sos: {
|
sos: {
|
||||||
label: 'SOS',
|
label: 'SOS',
|
||||||
color: 'bg-violet-900/40 text-violet-300 light:bg-violet-100 light:text-violet-700',
|
color: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +59,6 @@ export const METHOD_ORDER = [
|
|||||||
'gift',
|
'gift',
|
||||||
'fossil',
|
'fossil',
|
||||||
'trade',
|
'trade',
|
||||||
'static',
|
|
||||||
'walk',
|
'walk',
|
||||||
'headbutt',
|
'headbutt',
|
||||||
'surf',
|
'surf',
|
||||||
@@ -83,7 +78,9 @@ export function getMethodLabel(method: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getMethodColor(method: string): string {
|
export function getMethodColor(method: string): string {
|
||||||
return METHOD_CONFIG[method]?.color ?? 'bg-surface-3 text-text-secondary'
|
return (
|
||||||
|
METHOD_CONFIG[method]?.color ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EncounterMethodBadge({
|
export function EncounterMethodBadge({
|
||||||
@@ -95,7 +92,7 @@ export function EncounterMethodBadge({
|
|||||||
}) {
|
}) {
|
||||||
const config = METHOD_CONFIG[method]
|
const config = METHOD_CONFIG[method]
|
||||||
if (!config) return null
|
if (!config) return null
|
||||||
const sizeClass = size === 'xs' ? 'text-[10px] px-1.5 py-0.5' : 'text-xs px-2 py-0.5'
|
const sizeClass = size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5'
|
||||||
return (
|
return (
|
||||||
<span className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}>
|
<span className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}>
|
||||||
{config.label}
|
{config.label}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { api } from '../api/client'
|
|
||||||
import { useRoutePokemon } from '../hooks/useGames'
|
import { useRoutePokemon } from '../hooks/useGames'
|
||||||
import { useNameSuggestions } from '../hooks/useRuns'
|
import { useNameSuggestions } from '../hooks/useRuns'
|
||||||
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
|
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
|
||||||
import type {
|
import type { Route, EncounterDetail, EncounterStatus, RouteEncounterDetail } from '../types'
|
||||||
Route,
|
|
||||||
EncounterDetail,
|
|
||||||
EncounterStatus,
|
|
||||||
RouteEncounterDetail,
|
|
||||||
Pokemon,
|
|
||||||
} from '../types'
|
|
||||||
|
|
||||||
interface EncounterModalProps {
|
interface EncounterModalProps {
|
||||||
route: Route
|
route: Route
|
||||||
@@ -26,7 +19,6 @@ interface EncounterModalProps {
|
|||||||
nickname?: string | undefined
|
nickname?: string | undefined
|
||||||
status: EncounterStatus
|
status: EncounterStatus
|
||||||
catchLevel?: number | undefined
|
catchLevel?: number | undefined
|
||||||
origin?: string | undefined
|
|
||||||
}) => void
|
}) => void
|
||||||
onUpdate?:
|
onUpdate?:
|
||||||
| ((data: {
|
| ((data: {
|
||||||
@@ -41,9 +33,6 @@ interface EncounterModalProps {
|
|||||||
| undefined
|
| undefined
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
isPending: boolean
|
isPending: boolean
|
||||||
useAllPokemon?: boolean | undefined
|
|
||||||
staticClause?: boolean | undefined
|
|
||||||
allowedTypes?: string[] | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusOptions: {
|
const statusOptions: {
|
||||||
@@ -55,18 +44,19 @@ const statusOptions: {
|
|||||||
value: 'caught',
|
value: 'caught',
|
||||||
label: 'Caught',
|
label: 'Caught',
|
||||||
color:
|
color:
|
||||||
'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800 border-green-700 light:border-green-300',
|
'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'fainted',
|
value: 'fainted',
|
||||||
label: 'Fainted',
|
label: 'Fainted',
|
||||||
color:
|
color:
|
||||||
'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800 border-red-700 light:border-red-300',
|
'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'missed',
|
value: 'missed',
|
||||||
label: 'Missed / Ran',
|
label: 'Missed / Ran',
|
||||||
color: 'bg-surface-2 text-text-primary border-border-default',
|
color:
|
||||||
|
'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -134,8 +124,7 @@ function groupByMethod(
|
|||||||
} else {
|
} else {
|
||||||
// Determine the display rate
|
// Determine the display rate
|
||||||
let displayRate: number | null = null
|
let displayRate: number | null = null
|
||||||
const isSpecial =
|
const isSpecial = SPECIAL_METHODS.includes(rp.encounterMethod)
|
||||||
SPECIAL_METHODS.includes(rp.encounterMethod) || rp.encounterMethod === 'static'
|
|
||||||
if (!isSpecial) {
|
if (!isSpecial) {
|
||||||
if (selectedCondition) {
|
if (selectedCondition) {
|
||||||
const key = `${rp.pokemonId}:${rp.encounterMethod}`
|
const key = `${rp.pokemonId}:${rp.encounterMethod}`
|
||||||
@@ -200,14 +189,8 @@ export function EncounterModal({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
onClose,
|
onClose,
|
||||||
isPending,
|
isPending,
|
||||||
useAllPokemon,
|
|
||||||
staticClause = true,
|
|
||||||
allowedTypes,
|
|
||||||
}: EncounterModalProps) {
|
}: EncounterModalProps) {
|
||||||
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(route.id, gameId)
|
||||||
useAllPokemon ? null : route.id,
|
|
||||||
useAllPokemon ? undefined : gameId
|
|
||||||
)
|
|
||||||
|
|
||||||
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
|
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
|
||||||
const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught')
|
const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught')
|
||||||
@@ -217,8 +200,6 @@ export function EncounterModal({
|
|||||||
const [deathCause, setDeathCause] = useState('')
|
const [deathCause, setDeathCause] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedCondition, setSelectedCondition] = useState<string | null>(null)
|
const [selectedCondition, setSelectedCondition] = useState<string | null>(null)
|
||||||
const [allPokemonResults, setAllPokemonResults] = useState<Pokemon[]>([])
|
|
||||||
const [isSearchingAll, setIsSearchingAll] = useState(false)
|
|
||||||
|
|
||||||
const isEditing = !!existing
|
const isEditing = !!existing
|
||||||
|
|
||||||
@@ -238,41 +219,13 @@ export function EncounterModal({
|
|||||||
}
|
}
|
||||||
}, [existing, routePokemon])
|
}, [existing, routePokemon])
|
||||||
|
|
||||||
// Debounced all-Pokemon search (variant rules)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!useAllPokemon) return
|
|
||||||
|
|
||||||
if (search.length < 2) {
|
|
||||||
setAllPokemonResults([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
setIsSearchingAll(true)
|
|
||||||
try {
|
|
||||||
const data = await api.get<{ items: Pokemon[] }>(
|
|
||||||
`/pokemon?search=${encodeURIComponent(search)}&limit=20`
|
|
||||||
)
|
|
||||||
setAllPokemonResults(data.items)
|
|
||||||
} catch {
|
|
||||||
setAllPokemonResults([])
|
|
||||||
} finally {
|
|
||||||
setIsSearchingAll(false)
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [search, useAllPokemon])
|
|
||||||
|
|
||||||
const availableConditions = useMemo(
|
const availableConditions = useMemo(
|
||||||
() => (routePokemon ? getUniqueConditions(routePokemon) : []),
|
() => (routePokemon ? getUniqueConditions(routePokemon) : []),
|
||||||
[routePokemon]
|
[routePokemon]
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredPokemon = routePokemon?.filter(
|
const filteredPokemon = routePokemon?.filter((rp) =>
|
||||||
(rp) =>
|
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
|
||||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()) &&
|
|
||||||
(!allowedTypes?.length || rp.pokemon.types.some((t) => allowedTypes.includes(t)))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const groupedPokemon = useMemo(
|
const groupedPokemon = useMemo(
|
||||||
@@ -299,7 +252,6 @@ export function EncounterModal({
|
|||||||
nickname: nickname || undefined,
|
nickname: nickname || undefined,
|
||||||
status,
|
status,
|
||||||
catchLevel: catchLevel ? Number(catchLevel) : undefined,
|
catchLevel: catchLevel ? Number(catchLevel) : undefined,
|
||||||
origin: SPECIAL_METHODS.includes(selectedPokemon.encounterMethod) ? 'gift' : undefined,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,13 +261,16 @@ export function EncounterModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-surface-1 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="sticky top-0 bg-surface-1 border-b border-border-default px-6 py-4 rounded-t-xl">
|
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 rounded-t-xl">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{isEditing ? 'Edit Encounter' : 'Log Encounter'}
|
{isEditing ? 'Edit Encounter' : 'Log Encounter'}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="text-text-tertiary hover:text-text-primary">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -326,118 +281,17 @@ export function EncounterModal({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-tertiary mt-1">{route.name}</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{route.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
{/* Pokemon Selection (only for new encounters) */}
|
{/* Pokemon Selection (only for new encounters) */}
|
||||||
{!isEditing && useAllPokemon && (
|
{!isEditing && (
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-secondary mb-1">Pokemon</label>
|
|
||||||
{selectedPokemon ? (
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-accent-400 bg-accent-900/20">
|
|
||||||
{selectedPokemon.pokemon.spriteUrl ? (
|
|
||||||
<img
|
|
||||||
src={selectedPokemon.pokemon.spriteUrl}
|
|
||||||
alt={selectedPokemon.pokemon.name}
|
|
||||||
className="w-10 h-10"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
|
|
||||||
{selectedPokemon.pokemon.name[0]?.toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="font-medium text-text-primary capitalize">
|
|
||||||
{selectedPokemon.pokemon.name}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedPokemon(null)
|
|
||||||
setSearch('')
|
|
||||||
setAllPokemonResults([])
|
|
||||||
}}
|
|
||||||
className="ml-auto text-sm text-text-tertiary hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
Change
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search all pokemon by name..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-400"
|
|
||||||
/>
|
|
||||||
{isSearchingAll && (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<div className="w-6 h-6 border-2 border-accent-400 border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{allPokemonResults.length > 0 && (
|
|
||||||
<div className="mt-2 max-h-64 overflow-y-auto grid grid-cols-3 gap-2">
|
|
||||||
{allPokemonResults.map((p) => {
|
|
||||||
const isDuped = dupedPokemonIds?.has(p.id) ?? false
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!isDuped) {
|
|
||||||
setSelectedPokemon({
|
|
||||||
id: 0,
|
|
||||||
routeId: 0,
|
|
||||||
gameId: 0,
|
|
||||||
pokemonId: p.id,
|
|
||||||
pokemon: p,
|
|
||||||
encounterMethod: 'walking',
|
|
||||||
encounterRate: 0,
|
|
||||||
condition: '',
|
|
||||||
minLevel: 1,
|
|
||||||
maxLevel: 100,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isDuped}
|
|
||||||
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
|
||||||
isDuped
|
|
||||||
? 'opacity-40 cursor-not-allowed border-border-default'
|
|
||||||
: 'border-border-default hover:border-accent-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p.spriteUrl ? (
|
|
||||||
<img src={p.spriteUrl} alt={p.name} className="w-10 h-10" />
|
|
||||||
) : (
|
|
||||||
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-xs font-bold">
|
|
||||||
{p.name[0]?.toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-xs text-text-secondary mt-1 capitalize">
|
|
||||||
{p.name}
|
|
||||||
</span>
|
|
||||||
{isDuped && (
|
|
||||||
<span className="text-[10px] text-text-tertiary italic">
|
|
||||||
{retiredPokemonIds?.has(p.id) ? 'retired (HoF)' : 'already caught'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{search.length >= 2 && !isSearchingAll && allPokemonResults.length === 0 && (
|
|
||||||
<p className="text-sm text-text-tertiary py-2">No pokemon found</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isEditing && !useAllPokemon && (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<label className="block text-sm font-medium text-text-secondary">Pokemon</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Pokemon
|
||||||
|
</label>
|
||||||
{!loadingPokemon && routePokemon && routePokemon.length > 0 && (
|
{!loadingPokemon && routePokemon && routePokemon.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -450,17 +304,10 @@ export function EncounterModal({
|
|||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (routePokemon) {
|
if (routePokemon) {
|
||||||
const eligible = routePokemon
|
setSelectedPokemon(pickRandomPokemon(routePokemon, dupedPokemonIds))
|
||||||
.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 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"
|
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-300 dark:border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{selectedPokemon ? 'Re-roll' : 'Randomize'}
|
{selectedPokemon ? 'Re-roll' : 'Randomize'}
|
||||||
</button>
|
</button>
|
||||||
@@ -468,7 +315,7 @@ export function EncounterModal({
|
|||||||
</div>
|
</div>
|
||||||
{loadingPokemon ? (
|
{loadingPokemon ? (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
<div className="w-10 h-10 border-2 border-accent-400 border-t-transparent rounded-full animate-spin" />
|
<div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : filteredPokemon && filteredPokemon.length > 0 ? (
|
) : filteredPokemon && filteredPokemon.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -478,7 +325,7 @@ export function EncounterModal({
|
|||||||
placeholder="Search pokemon..."
|
placeholder="Search pokemon..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-full px-3 py-1.5 mb-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"
|
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{availableConditions.length > 0 && (
|
{availableConditions.length > 0 && (
|
||||||
@@ -488,8 +335,8 @@ export function EncounterModal({
|
|||||||
onClick={() => setSelectedCondition(null)}
|
onClick={() => setSelectedCondition(null)}
|
||||||
className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors ${
|
className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors ${
|
||||||
selectedCondition === null
|
selectedCondition === null
|
||||||
? 'bg-purple-900/40 border-purple-600 text-purple-300'
|
? 'bg-purple-100 border-purple-300 text-purple-800 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300'
|
||||||
: 'border-border-default text-text-tertiary hover:border-purple-600'
|
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-300 dark:hover:border-purple-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
@@ -501,8 +348,8 @@ export function EncounterModal({
|
|||||||
onClick={() => setSelectedCondition(cond)}
|
onClick={() => setSelectedCondition(cond)}
|
||||||
className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors capitalize ${
|
className={`px-2.5 py-1 text-xs font-medium rounded-full border transition-colors capitalize ${
|
||||||
selectedCondition === cond
|
selectedCondition === cond
|
||||||
? 'bg-purple-900/40 border-purple-600 text-purple-300'
|
? 'bg-purple-100 border-purple-300 text-purple-800 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300'
|
||||||
: 'border-border-default text-text-tertiary hover:border-purple-600'
|
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-300 dark:hover:border-purple-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{cond}
|
{cond}
|
||||||
@@ -513,18 +360,17 @@ export function EncounterModal({
|
|||||||
<div className="max-h-64 overflow-y-auto space-y-3">
|
<div className="max-h-64 overflow-y-auto space-y-3">
|
||||||
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
|
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
|
||||||
<div key={method}>
|
<div key={method}>
|
||||||
{groupIdx > 0 && <div className="border-t border-border-default mb-3" />}
|
{groupIdx > 0 && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 mb-3" />
|
||||||
|
)}
|
||||||
{hasMultipleGroups && (
|
{hasMultipleGroups && (
|
||||||
<div className="text-xs font-medium text-text-tertiary mb-1.5">
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
|
||||||
{getMethodLabel(method)}
|
{getMethodLabel(method)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{pokemon.map(({ encounter: rp, conditions, displayRate }) => {
|
{pokemon.map(({ encounter: rp, conditions, displayRate }) => {
|
||||||
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
|
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
|
||||||
const isStaticDisabled =
|
|
||||||
!staticClause && rp.encounterMethod === 'static'
|
|
||||||
const isDisabled = isDuped || isStaticDisabled
|
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedPokemon?.pokemonId === rp.pokemonId &&
|
selectedPokemon?.pokemonId === rp.pokemonId &&
|
||||||
selectedPokemon?.encounterMethod === rp.encounterMethod
|
selectedPokemon?.encounterMethod === rp.encounterMethod
|
||||||
@@ -532,14 +378,14 @@ export function EncounterModal({
|
|||||||
<button
|
<button
|
||||||
key={`${rp.encounterMethod}-${rp.pokemonId}`}
|
key={`${rp.encounterMethod}-${rp.pokemonId}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !isDisabled && setSelectedPokemon(rp)}
|
onClick={() => !isDuped && setSelectedPokemon(rp)}
|
||||||
disabled={isDisabled}
|
disabled={isDuped}
|
||||||
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
||||||
isDisabled
|
isDuped
|
||||||
? 'opacity-40 cursor-not-allowed border-border-default'
|
? 'opacity-40 cursor-not-allowed border-gray-200 dark:border-gray-700'
|
||||||
: isSelected
|
: isSelected
|
||||||
? 'border-accent-400 bg-accent-900/30'
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||||
: 'border-border-default hover:border-border-default'
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{rp.pokemon.spriteUrl ? (
|
{rp.pokemon.spriteUrl ? (
|
||||||
@@ -549,46 +395,37 @@ export function EncounterModal({
|
|||||||
className="w-10 h-10"
|
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">
|
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
||||||
{rp.pokemon.name[0]?.toUpperCase()}
|
{rp.pokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-text-secondary mt-1 capitalize">
|
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
{rp.pokemon.name}
|
{rp.pokemon.name}
|
||||||
</span>
|
</span>
|
||||||
{isDuped && (
|
{isDuped && (
|
||||||
<span className="text-[10px] text-text-tertiary italic">
|
<span className="text-[10px] text-gray-400 italic">
|
||||||
{retiredPokemonIds?.has(rp.pokemonId)
|
{retiredPokemonIds?.has(rp.pokemonId)
|
||||||
? 'retired (HoF)'
|
? 'retired (HoF)'
|
||||||
: 'already caught'}
|
: 'already caught'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isStaticDisabled && (
|
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
||||||
<span className="text-[10px] text-text-tertiary italic">
|
<EncounterMethodBadge method={rp.encounterMethod} />
|
||||||
static clause off
|
)}
|
||||||
|
{!isDuped && displayRate !== null && displayRate !== undefined && (
|
||||||
|
<span className="text-[10px] text-purple-500 dark:text-purple-400 font-medium">
|
||||||
|
{displayRate}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isDisabled &&
|
{!isDuped &&
|
||||||
(SPECIAL_METHODS.includes(rp.encounterMethod) ||
|
|
||||||
rp.encounterMethod === 'static') && (
|
|
||||||
<EncounterMethodBadge method={rp.encounterMethod} />
|
|
||||||
)}
|
|
||||||
{!isDisabled &&
|
|
||||||
displayRate !== null &&
|
|
||||||
displayRate !== undefined && (
|
|
||||||
<span className="text-[10px] text-purple-400 light:text-purple-700 font-medium">
|
|
||||||
{displayRate}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isDisabled &&
|
|
||||||
selectedCondition === null &&
|
selectedCondition === null &&
|
||||||
conditions.length > 0 && (
|
conditions.length > 0 && (
|
||||||
<span className="text-[10px] text-purple-400 light:text-purple-700">
|
<span className="text-[10px] text-purple-500 dark:text-purple-400">
|
||||||
{conditions.join(', ')}
|
{conditions.join(', ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isDisabled && (
|
{!isDuped && (
|
||||||
<span className="text-[10px] text-text-tertiary">
|
<span className="text-[10px] text-gray-400">
|
||||||
Lv. {rp.minLevel}
|
Lv. {rp.minLevel}
|
||||||
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
||||||
</span>
|
</span>
|
||||||
@@ -602,14 +439,16 @@ export function EncounterModal({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-text-tertiary py-2">No pokemon data for this route</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">
|
||||||
|
No pokemon data for this route
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Editing: show pokemon info */}
|
{/* Editing: show pokemon info */}
|
||||||
{isEditing && existing && (
|
{isEditing && existing && (
|
||||||
<div className="flex items-center gap-3 p-3 bg-surface-0/50 rounded-lg">
|
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||||
{existing.pokemon.spriteUrl ? (
|
{existing.pokemon.spriteUrl ? (
|
||||||
<img
|
<img
|
||||||
src={existing.pokemon.spriteUrl}
|
src={existing.pokemon.spriteUrl}
|
||||||
@@ -617,15 +456,15 @@ export function EncounterModal({
|
|||||||
className="w-12 h-12"
|
className="w-12 h-12"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 rounded-full bg-surface-3 flex items-center justify-center text-lg font-bold">
|
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
||||||
{existing.pokemon.name[0]?.toUpperCase()}
|
{existing.pokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-text-primary capitalize">
|
<div className="font-medium text-gray-900 dark:text-gray-100 capitalize">
|
||||||
{existing.pokemon.name}
|
{existing.pokemon.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-tertiary">
|
<div className="text-xs text-gray-500">
|
||||||
Caught at Lv. {existing.catchLevel ?? '?'}
|
Caught at Lv. {existing.catchLevel ?? '?'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -634,7 +473,9 @@ export function EncounterModal({
|
|||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-secondary mb-1">Status</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{statusOptions.map((opt) => (
|
{statusOptions.map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -644,7 +485,7 @@ export function EncounterModal({
|
|||||||
className={`flex-1 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
className={`flex-1 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
status === opt.value
|
status === opt.value
|
||||||
? opt.color
|
? opt.color
|
||||||
: 'border-border-default text-text-tertiary hover:border-border-accent'
|
: 'border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
@@ -658,7 +499,7 @@ export function EncounterModal({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="nickname"
|
htmlFor="nickname"
|
||||||
className="block text-sm font-medium text-text-secondary mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Nickname
|
Nickname
|
||||||
</label>
|
</label>
|
||||||
@@ -668,17 +509,19 @@ export function EncounterModal({
|
|||||||
value={nickname}
|
value={nickname}
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
placeholder="Give it a name..."
|
placeholder="Give it a name..."
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
{showSuggestions && suggestions && suggestions.length > 0 && (
|
{showSuggestions && suggestions && suggestions.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<span className="text-xs text-text-tertiary">Suggestions ({namingScheme})</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Suggestions ({namingScheme})
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => regenerate()}
|
onClick={() => regenerate()}
|
||||||
disabled={loadingSuggestions}
|
disabled={loadingSuggestions}
|
||||||
className="text-xs text-text-link hover:text-accent-300 disabled:opacity-50 transition-colors"
|
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{loadingSuggestions ? 'Loading...' : 'Regenerate'}
|
{loadingSuggestions ? 'Loading...' : 'Regenerate'}
|
||||||
</button>
|
</button>
|
||||||
@@ -691,8 +534,8 @@ export function EncounterModal({
|
|||||||
onClick={() => setNickname(name)}
|
onClick={() => setNickname(name)}
|
||||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||||
nickname === name
|
nickname === name
|
||||||
? 'bg-accent-900/40 border-accent-600 text-accent-300 light:bg-accent-100 light:text-accent-700'
|
? 'bg-blue-100 border-blue-300 text-blue-800 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-300'
|
||||||
: 'border-border-default text-text-secondary hover:border-accent-600 hover:bg-accent-900/20'
|
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-blue-300 dark:hover:border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
@@ -709,7 +552,7 @@ export function EncounterModal({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="catch-level"
|
htmlFor="catch-level"
|
||||||
className="block text-sm font-medium text-text-secondary mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Catch Level
|
Catch Level
|
||||||
</label>
|
</label>
|
||||||
@@ -725,7 +568,7 @@ export function EncounterModal({
|
|||||||
? `${selectedPokemon.minLevel}–${selectedPokemon.maxLevel}`
|
? `${selectedPokemon.minLevel}–${selectedPokemon.maxLevel}`
|
||||||
: 'Level'
|
: 'Level'
|
||||||
}
|
}
|
||||||
className="w-24 px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
|
className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -736,9 +579,9 @@ export function EncounterModal({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="faint-level"
|
htmlFor="faint-level"
|
||||||
className="block text-sm font-medium text-text-secondary mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Faint Level <span className="font-normal text-text-tertiary">(mark as dead)</span>
|
Faint Level <span className="font-normal text-gray-400">(mark as dead)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="faint-level"
|
id="faint-level"
|
||||||
@@ -748,15 +591,15 @@ export function EncounterModal({
|
|||||||
value={faintLevel}
|
value={faintLevel}
|
||||||
onChange={(e) => setFaintLevel(e.target.value)}
|
onChange={(e) => setFaintLevel(e.target.value)}
|
||||||
placeholder="Leave empty if still alive"
|
placeholder="Leave empty if still alive"
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="death-cause"
|
htmlFor="death-cause"
|
||||||
className="block text-sm font-medium text-text-secondary mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Cause of Death <span className="font-normal text-text-tertiary">(optional)</span>
|
Cause of Death <span className="font-normal text-gray-400">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="death-cause"
|
id="death-cause"
|
||||||
@@ -765,18 +608,18 @@ export function EncounterModal({
|
|||||||
value={deathCause}
|
value={deathCause}
|
||||||
onChange={(e) => setDeathCause(e.target.value)}
|
onChange={(e) => setDeathCause(e.target.value)}
|
||||||
placeholder="e.g. Crit from rival's Charizard"
|
placeholder="e.g. Crit from rival's Charizard"
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400"
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky bottom-0 bg-surface-1 border-t border-border-default px-6 py-4 rounded-b-xl flex justify-end gap-3">
|
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-text-secondary bg-surface-2 rounded-lg font-medium hover:bg-surface-3 transition-colors"
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -784,7 +627,7 @@ export function EncounterModal({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!canSubmit || isPending}
|
disabled={!canSubmit || isPending}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{isPending ? 'Saving...' : isEditing ? 'Update' : 'Log Encounter'}
|
{isPending ? 'Saving...' : isEditing ? 'Update' : 'Log Encounter'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -21,17 +21,17 @@ export function EndRunModal({ onConfirm, onClose, isPending, genlockeContext }:
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-md w-full mx-4">
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
<div className="px-6 py-4 border-b border-border-default">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold">End Run</h2>
|
<h2 className="text-lg font-semibold">End Run</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-6">
|
<div className="px-6 py-6">
|
||||||
<p className="text-text-tertiary mb-6">How did your run end?</p>
|
<p className="text-gray-600 dark:text-gray-400 mb-6">How did your run end?</p>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => onConfirm('completed')}
|
onClick={() => onConfirm('completed')}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-blue-800 bg-blue-900/20 text-blue-300 hover:border-blue-600 disabled:opacity-50 transition-colors"
|
className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 hover:border-blue-400 dark:hover:border-blue-600 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="font-semibold">Victory</div>
|
<div className="font-semibold">Victory</div>
|
||||||
<div className="text-sm opacity-80">{victoryDescription}</div>
|
<div className="text-sm opacity-80">{victoryDescription}</div>
|
||||||
@@ -39,18 +39,18 @@ export function EndRunModal({ onConfirm, onClose, isPending, genlockeContext }:
|
|||||||
<button
|
<button
|
||||||
onClick={() => onConfirm('failed')}
|
onClick={() => onConfirm('failed')}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-red-800 bg-status-failed-bg text-red-300 hover:border-red-600 disabled:opacity-50 transition-colors"
|
className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 hover:border-red-400 dark:hover:border-red-600 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="font-semibold">Defeat</div>
|
<div className="font-semibold">Defeat</div>
|
||||||
<div className="text-sm opacity-80">{defeatDescription}</div>
|
<div className="text-sm opacity-80">{defeatDescription}</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-border-default flex justify-end">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export function GameCard({ game, selected, onSelect }: GameCardProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(game)}
|
onClick={() => onSelect(game)}
|
||||||
className={`relative w-full rounded-lg overflow-hidden transition-all duration-200 hover:scale-105 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 focus:ring-offset-surface-0 ${
|
className={`relative w-full rounded-lg overflow-hidden transition-all duration-200 hover:scale-105 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${
|
||||||
selected ? 'ring-2 ring-accent-500 scale-105 shadow-lg' : 'shadow'
|
selected ? 'ring-2 ring-blue-500 scale-105 shadow-lg' : 'shadow'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{imgIdx < boxArtSrcs.length ? (
|
{imgIdx < boxArtSrcs.length ? (
|
||||||
@@ -38,14 +38,14 @@ export function GameCard({ game, selected, onSelect }: GameCardProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-3 bg-surface-1 text-left">
|
<div className="p-3 bg-white dark:bg-gray-800 text-left">
|
||||||
<h3 className="font-semibold text-text-primary">{game.name}</h3>
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{game.name}</h3>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-2 text-text-tertiary">
|
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||||
{game.region.charAt(0).toUpperCase() + game.region.slice(1)}
|
{game.region.charAt(0).toUpperCase() + game.region.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
{game.releaseYear && (
|
{game.releaseYear && (
|
||||||
<span className="text-xs text-text-tertiary">{game.releaseYear}</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400">{game.releaseYear}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -70,14 +70,16 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
|
|||||||
|
|
||||||
const pillClass = (active: boolean) =>
|
const pillClass = (active: boolean) =>
|
||||||
`px-3 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
`px-3 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||||
active ? 'bg-blue-600 text-white' : 'bg-surface-2 text-text-secondary hover:bg-surface-3'
|
active
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`
|
}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-medium text-text-tertiary mr-1">Gen:</span>
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">Gen:</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilter(null)}
|
onClick={() => setFilter(null)}
|
||||||
@@ -98,7 +100,7 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-medium text-text-tertiary mr-1">Region:</span>
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">Region:</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRegionFilter(null)}
|
onClick={() => setRegionFilter(null)}
|
||||||
@@ -120,21 +122,21 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
|
|||||||
|
|
||||||
{runs && (
|
{runs && (
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={hideWithActiveRun}
|
checked={hideWithActiveRun}
|
||||||
onChange={(e) => setHideWithActiveRun(e.target.checked)}
|
onChange={(e) => setHideWithActiveRun(e.target.checked)}
|
||||||
className="rounded border-border-default text-blue-600 focus:ring-accent-400"
|
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
Hide games with active run
|
Hide games with active run
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={hideCompleted}
|
checked={hideCompleted}
|
||||||
onChange={(e) => setHideCompleted(e.target.checked)}
|
onChange={(e) => setHideCompleted(e.target.checked)}
|
||||||
className="rounded border-border-default text-blue-600 focus:ring-accent-400"
|
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
Hide completed games
|
Hide completed games
|
||||||
</label>
|
</label>
|
||||||
@@ -144,7 +146,7 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
|
|||||||
|
|
||||||
{grouped.map(({ generation, games }) => (
|
{grouped.map(({ generation, games }) => (
|
||||||
<div key={generation}>
|
<div key={generation}>
|
||||||
<h3 className="text-lg font-semibold text-text-primary mb-3">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||||
{GENERATION_LABELS[generation] ?? `Generation ${generation}`}
|
{GENERATION_LABELS[generation] ?? `Generation ${generation}`}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
|
|||||||
const isEvolved = entry.currentPokemon !== null
|
const isEvolved = entry.currentPokemon !== null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-1 rounded-lg shadow p-4 flex flex-col items-center text-center opacity-60 grayscale">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex flex-col items-center text-center opacity-60 grayscale">
|
||||||
{displayPokemon.spriteUrl ? (
|
{displayPokemon.spriteUrl ? (
|
||||||
<img
|
<img
|
||||||
src={displayPokemon.spriteUrl}
|
src={displayPokemon.spriteUrl}
|
||||||
@@ -23,18 +23,20 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-25 h-25 rounded-full bg-surface-3 flex items-center justify-center text-xl font-bold text-text-secondary">
|
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
||||||
{displayPokemon.name[0]?.toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-1.5">
|
<div className="mt-2 flex items-center gap-1.5">
|
||||||
<span className="w-2 h-2 rounded-full shrink-0 bg-red-500" />
|
<span className="w-2 h-2 rounded-full shrink-0 bg-red-500" />
|
||||||
<span className="font-semibold text-text-primary text-sm">
|
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
|
||||||
{entry.nickname || displayPokemon.name}
|
{entry.nickname || displayPokemon.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{entry.nickname && <div className="text-xs text-text-tertiary">{displayPokemon.name}</div>}
|
{entry.nickname && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{displayPokemon.name}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-0.5 mt-1">
|
<div className="flex flex-col items-center gap-0.5 mt-1">
|
||||||
{displayPokemon.types.map((type) => (
|
{displayPokemon.types.map((type) => (
|
||||||
@@ -42,22 +44,24 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-text-tertiary mt-1">
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Lv. {entry.catchLevel} → {entry.faintLevel}
|
Lv. {entry.catchLevel} → {entry.faintLevel}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-text-muted mt-0.5">{entry.routeName}</div>
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{entry.routeName}</div>
|
||||||
|
|
||||||
<div className="text-[10px] text-purple-400 light:text-purple-700 mt-0.5 font-medium">
|
<div className="text-[10px] text-purple-600 dark:text-purple-400 mt-0.5 font-medium">
|
||||||
Leg {entry.legOrder} — {entry.gameName}
|
Leg {entry.legOrder} — {entry.gameName}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEvolved && (
|
{isEvolved && (
|
||||||
<div className="text-[10px] text-text-muted mt-0.5">Originally: {entry.pokemon.name}</div>
|
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
Originally: {entry.pokemon.name}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entry.deathCause && (
|
{entry.deathCause && (
|
||||||
<div className="text-[10px] italic text-text-muted mt-0.5 line-clamp-2">
|
<div className="text-[10px] italic text-gray-400 dark:text-gray-500 mt-0.5 line-clamp-2">
|
||||||
{entry.deathCause}
|
{entry.deathCause}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -103,7 +107,7 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-status-failed-bg p-4 text-status-failed">
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||||
Failed to load graveyard data.
|
Failed to load graveyard data.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -111,7 +115,7 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
|
|||||||
|
|
||||||
if (!data || data.totalDeaths === 0) {
|
if (!data || data.totalDeaths === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-surface-1/50 p-6 text-center text-text-tertiary">
|
<div className="rounded-lg bg-gray-50 dark:bg-gray-800/50 p-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
No deaths recorded across any leg.
|
No deaths recorded across any leg.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -121,11 +125,11 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Summary bar */}
|
{/* Summary bar */}
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
<span className="font-semibold text-text-primary">
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{data.totalDeaths} total death{data.totalDeaths !== 1 ? 's' : ''}
|
{data.totalDeaths} total death{data.totalDeaths !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
{data.deadliestLeg && (
|
{data.deadliestLeg && (
|
||||||
<span className="text-text-tertiary">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
Deadliest: Leg {data.deadliestLeg.legOrder} — {data.deadliestLeg.gameName} (
|
Deadliest: Leg {data.deadliestLeg.legOrder} — {data.deadliestLeg.gameName} (
|
||||||
{data.deadliestLeg.deathCount})
|
{data.deadliestLeg.deathCount})
|
||||||
</span>
|
</span>
|
||||||
@@ -137,7 +141,7 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
|
|||||||
<select
|
<select
|
||||||
value={filterLeg ?? ''}
|
value={filterLeg ?? ''}
|
||||||
onChange={(e) => setFilterLeg(e.target.value ? Number(e.target.value) : null)}
|
onChange={(e) => setFilterLeg(e.target.value ? Number(e.target.value) : null)}
|
||||||
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<option value="">All Legs</option>
|
<option value="">All Legs</option>
|
||||||
{data.deathsPerLeg.map((leg) => (
|
{data.deathsPerLeg.map((leg) => (
|
||||||
@@ -150,7 +154,7 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
|
|||||||
<select
|
<select
|
||||||
value={sortKey}
|
value={sortKey}
|
||||||
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
||||||
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<option value="leg">Sort by Leg</option>
|
<option value="leg">Sort by Leg</option>
|
||||||
<option value="level">Sort by Level</option>
|
<option value="level">Sort by Level</option>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
|
|||||||
return (
|
return (
|
||||||
<div className="group relative flex flex-col items-center">
|
<div className="group relative flex flex-col items-center">
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 rounded-full ${color} ring-2 ring-offset-1 ring-offset-surface-1 ring-border-default`}
|
className={`w-4 h-4 rounded-full ${color} ring-2 ring-offset-1 ring-offset-white dark:ring-offset-gray-800 ring-gray-200 dark:ring-gray-600`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{/* Tooltip */}
|
||||||
@@ -48,18 +48,18 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
|
|||||||
<div
|
<div
|
||||||
className={`font-medium ${
|
className={`font-medium ${
|
||||||
leg.faintLevel !== null
|
leg.faintLevel !== null
|
||||||
? 'text-red-300 light:text-red-700'
|
? 'text-red-300'
|
||||||
: leg.wasTransferred
|
: leg.wasTransferred
|
||||||
? 'text-blue-300 light:text-blue-700'
|
? 'text-blue-300'
|
||||||
: leg.enteredHof
|
: leg.enteredHof
|
||||||
? 'text-yellow-300 light:text-amber-700'
|
? 'text-yellow-300'
|
||||||
: 'text-green-300 light:text-green-700'
|
: 'text-green-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
{leg.enteredHof && leg.faintLevel === null && (
|
{leg.enteredHof && leg.faintLevel === null && (
|
||||||
<div className="text-yellow-300 light:text-amber-700">Hall of Fame</div>
|
<div className="text-yellow-300">Hall of Fame</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45 -mt-1" />
|
<div className="w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45 -mt-1" />
|
||||||
@@ -97,11 +97,11 @@ function TimelineGrid({
|
|||||||
<div key={legOrder} className="flex justify-center relative" style={{ height: '20px' }}>
|
<div key={legOrder} className="flex justify-center relative" style={{ height: '20px' }}>
|
||||||
{/* Left half connector */}
|
{/* Left half connector */}
|
||||||
{showLeftLine && (
|
{showLeftLine && (
|
||||||
<div className="absolute top-[9px] left-0 right-1/2 h-0.5 bg-surface-3" />
|
<div className="absolute top-[9px] left-0 right-1/2 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||||
)}
|
)}
|
||||||
{/* Right half connector */}
|
{/* Right half connector */}
|
||||||
{showRightLine && (
|
{showRightLine && (
|
||||||
<div className="absolute top-[9px] left-1/2 right-0 h-0.5 bg-surface-3" />
|
<div className="absolute top-[9px] left-1/2 right-0 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||||
)}
|
)}
|
||||||
{/* Dot or empty */}
|
{/* Dot or empty */}
|
||||||
{leg ? (
|
{leg ? (
|
||||||
@@ -123,7 +123,7 @@ function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegO
|
|||||||
const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon
|
const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-1 rounded-lg shadow p-4 flex items-center gap-4">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex items-center gap-4">
|
||||||
{/* Left: Pokemon sprite + nickname */}
|
{/* Left: Pokemon sprite + nickname */}
|
||||||
<div className="flex flex-col items-center min-w-[80px]">
|
<div className="flex flex-col items-center min-w-[80px]">
|
||||||
{displayPokemon.spriteUrl ? (
|
{displayPokemon.spriteUrl ? (
|
||||||
@@ -134,15 +134,17 @@ function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegO
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-16 h-16 rounded-full bg-surface-3 flex items-center justify-center text-xl font-bold text-text-secondary">
|
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
||||||
{displayPokemon.name[0]?.toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-semibold text-text-primary mt-1 text-center">
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-1 text-center">
|
||||||
{lineage.nickname || lineage.pokemon.name}
|
{lineage.nickname || lineage.pokemon.name}
|
||||||
</span>
|
</span>
|
||||||
{lineage.nickname && (
|
{lineage.nickname && (
|
||||||
<span className="text-[10px] text-text-tertiary">{lineage.pokemon.name}</span>
|
<span className="text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
|
{lineage.pokemon.name}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -156,8 +158,8 @@ function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegO
|
|||||||
<span
|
<span
|
||||||
className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||||
lineage.status === 'alive'
|
lineage.status === 'alive'
|
||||||
? 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800'
|
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
|
||||||
: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800'
|
: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{lineage.status === 'alive' ? 'Alive' : 'Dead'}
|
{lineage.status === 'alive' ? 'Alive' : 'Dead'}
|
||||||
@@ -198,7 +200,7 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-status-failed-bg p-4 text-status-failed">
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||||
Failed to load lineage data.
|
Failed to load lineage data.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -206,7 +208,7 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
|
|||||||
|
|
||||||
if (!data || data.totalLineages === 0) {
|
if (!data || data.totalLineages === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-surface-1/50 p-6 text-center text-text-tertiary">
|
<div className="rounded-lg bg-gray-50 dark:bg-gray-800/50 p-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
No Pokemon have been transferred between legs yet.
|
No Pokemon have been transferred between legs yet.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -216,7 +218,7 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Summary bar */}
|
{/* Summary bar */}
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
<span className="font-semibold text-text-primary">
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''} across{' '}
|
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''} across{' '}
|
||||||
{allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
|
{allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
@@ -235,10 +237,10 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
|
|||||||
>
|
>
|
||||||
{allLegOrders.map((legOrder) => (
|
{allLegOrders.map((legOrder) => (
|
||||||
<div key={legOrder} className="flex flex-col items-center">
|
<div key={legOrder} className="flex flex-col items-center">
|
||||||
<span className="text-[10px] font-medium text-text-tertiary whitespace-nowrap">
|
<span className="text-[10px] font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
Leg {legOrder}
|
Leg {legOrder}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-text-muted whitespace-nowrap truncate max-w-[48px]">
|
<span className="text-[9px] text-gray-400 dark:text-gray-500 whitespace-nowrap truncate max-w-[48px]">
|
||||||
{legGameNames.get(legOrder)}
|
{legGameNames.get(legOrder)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModa
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black/50" />
|
<div className="fixed inset-0 bg-black/50" />
|
||||||
<div className="relative bg-surface-1 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="sticky top-0 bg-surface-1 border-b border-border-default px-6 py-4 rounded-t-xl">
|
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 rounded-t-xl">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">Hall of Fame Team</h2>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
<p className="text-sm text-text-tertiary mt-1">
|
Hall of Fame Team
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Select the Pokemon that entered the Hall of Fame (max 6)
|
Select the Pokemon that entered the Hall of Fame (max 6)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,10 +55,10 @@ export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModa
|
|||||||
disabled={atMax}
|
disabled={atMax}
|
||||||
className={`flex flex-col items-center p-3 rounded-lg border-2 text-center transition-colors ${
|
className={`flex flex-col items-center p-3 rounded-lg border-2 text-center transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-yellow-500 bg-yellow-900/20'
|
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20'
|
||||||
: atMax
|
: atMax
|
||||||
? 'border-border-default opacity-40 cursor-not-allowed'
|
? 'border-gray-200 dark:border-gray-700 opacity-40 cursor-not-allowed'
|
||||||
: 'border-border-default hover:border-border-default'
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{displayPokemon.spriteUrl ? (
|
{displayPokemon.spriteUrl ? (
|
||||||
@@ -66,15 +68,15 @@ export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModa
|
|||||||
className="w-14 h-14"
|
className="w-14 h-14"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-14 h-14 rounded-full bg-surface-3 flex items-center justify-center text-lg font-bold">
|
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
||||||
{displayPokemon.name[0]?.toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium text-text-secondary mt-1 capitalize">
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
{enc.nickname || displayPokemon.name}
|
{enc.nickname || displayPokemon.name}
|
||||||
</span>
|
</span>
|
||||||
{enc.nickname && (
|
{enc.nickname && (
|
||||||
<span className="text-[10px] text-text-tertiary">{displayPokemon.name}</span>
|
<span className="text-[10px] text-gray-400">{displayPokemon.name}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -82,22 +84,24 @@ export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky bottom-0 bg-surface-1 border-t border-border-default px-6 py-4 rounded-b-xl flex items-center justify-between">
|
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSkip}
|
onClick={onSkip}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="text-sm text-text-tertiary hover:text-text-primary disabled:opacity-50"
|
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-text-muted">{selected.size}/6 selected</span>
|
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
{selected.size}/6 selected
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={selected.size === 0 || isPending}
|
disabled={selected.size === 0 || isPending}
|
||||||
onClick={() => onSubmit([...selected])}
|
onClick={() => onSubmit([...selected])}
|
||||||
className="px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{isPending ? 'Saving...' : 'Confirm'}
|
{isPending ? 'Saving...' : 'Confirm'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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,119 +1,61 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
import { Link, Outlet } from 'react-router-dom'
|
||||||
import { useTheme } from '../hooks/useTheme'
|
|
||||||
|
|
||||||
const navLinks = [
|
|
||||||
{ to: '/runs/new', label: 'New Run' },
|
|
||||||
{ to: '/runs', label: 'My Runs' },
|
|
||||||
{ to: '/genlockes', label: 'Genlockes' },
|
|
||||||
{ to: '/stats', label: 'Stats' },
|
|
||||||
{ to: '/admin', label: 'Admin' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function NavLink({
|
|
||||||
to,
|
|
||||||
active,
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
className = '',
|
|
||||||
}: {
|
|
||||||
to: string
|
|
||||||
active: boolean
|
|
||||||
children: React.ReactNode
|
|
||||||
onClick?: () => void
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={to}
|
|
||||||
onClick={onClick}
|
|
||||||
className={`${className} px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
active
|
|
||||||
? 'bg-accent-600/20 text-accent-300'
|
|
||||||
: 'text-text-secondary hover:text-text-primary hover:bg-surface-3'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThemeToggle() {
|
|
||||||
const { theme, toggle } = useTheme()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggle}
|
|
||||||
className="p-2 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
|
||||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
||||||
>
|
|
||||||
{theme === 'dark' ? (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 3v1m0 16v1m8.66-13.66l-.71.71M4.05 19.95l-.71.71M21 12h-1M4 12H3m16.66 7.66l-.71-.71M4.05 4.05l-.71-.71M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
function isActive(to: string) {
|
|
||||||
if (to === '/runs/new') return location.pathname === '/runs/new'
|
|
||||||
return location.pathname.startsWith(to)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-surface-0 text-text-primary">
|
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||||
<nav className="sticky top-0 z-40 bg-surface-1/80 backdrop-blur-lg border-b border-border-default">
|
<nav className="bg-white dark:bg-gray-800 shadow-sm">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-14">
|
<div className="flex justify-between h-16">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center">
|
||||||
<Link to="/" className="flex items-center gap-2 group">
|
<Link to="/" className="text-xl font-bold">
|
||||||
<img
|
ANT
|
||||||
src="/favicon.svg"
|
|
||||||
alt=""
|
|
||||||
className="w-7 h-7 transition-transform group-hover:scale-110"
|
|
||||||
/>
|
|
||||||
<span className="text-lg font-bold tracking-tight text-text-primary">ANT</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{/* Desktop nav */}
|
{/* Desktop nav */}
|
||||||
<div className="hidden sm:flex items-center gap-1">
|
<div className="hidden sm:flex items-center space-x-4">
|
||||||
{navLinks.map((link) => (
|
<Link
|
||||||
<NavLink key={link.to} to={link.to} active={isActive(link.to)}>
|
to="/runs/new"
|
||||||
{link.label}
|
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
</NavLink>
|
>
|
||||||
))}
|
New Run
|
||||||
<ThemeToggle />
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/runs"
|
||||||
|
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
My Runs
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/genlockes"
|
||||||
|
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Genlockes
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/stats"
|
||||||
|
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Stats
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{/* Mobile hamburger */}
|
{/* Mobile hamburger */}
|
||||||
<div className="flex items-center gap-1 sm:hidden">
|
<div className="flex items-center sm:hidden">
|
||||||
<ThemeToggle />
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
className="p-2 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{menuOpen ? (
|
{menuOpen ? (
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -136,19 +78,43 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Mobile dropdown */}
|
{/* Mobile dropdown */}
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="sm:hidden border-t border-border-default">
|
<div className="sm:hidden border-t border-gray-200 dark:border-gray-700">
|
||||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||||
{navLinks.map((link) => (
|
<Link
|
||||||
<NavLink
|
to="/runs/new"
|
||||||
key={link.to}
|
onClick={() => setMenuOpen(false)}
|
||||||
to={link.to}
|
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
active={isActive(link.to)}
|
>
|
||||||
onClick={() => setMenuOpen(false)}
|
New Run
|
||||||
className="block"
|
</Link>
|
||||||
>
|
<Link
|
||||||
{link.label}
|
to="/runs"
|
||||||
</NavLink>
|
onClick={() => setMenuOpen(false)}
|
||||||
))}
|
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
My Runs
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/genlockes"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Genlockes
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/stats"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Stats
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -156,12 +122,12 @@ export function Layout() {
|
|||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<footer className="border-t border-border-default bg-surface-1/50">
|
<footer className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 text-center text-xs text-text-tertiary">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||||
Encounter data from{' '}
|
Pokémon encounter data from{' '}
|
||||||
<a
|
<a
|
||||||
href="https://pokedb.org"
|
href="https://pokedb.org"
|
||||||
className="underline hover:text-text-secondary transition-colors"
|
className="underline hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,27 +16,29 @@ export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardP
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`bg-surface-1 rounded-xl border border-border-default p-4 flex flex-col items-center text-center transition-all ${
|
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex flex-col items-center text-center ${
|
||||||
isDead ? 'opacity-50 grayscale' : ''
|
isDead ? 'opacity-60 grayscale' : ''
|
||||||
} ${onClick ? 'cursor-pointer hover:border-accent-400/30 hover:-translate-y-0.5' : ''}`}
|
} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-blue-400 transition-shadow' : ''}`}
|
||||||
>
|
>
|
||||||
{displayPokemon.spriteUrl ? (
|
{displayPokemon.spriteUrl ? (
|
||||||
<img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-25 h-25" />
|
<img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-25 h-25" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-25 h-25 rounded-full bg-surface-3 flex items-center justify-center text-xl font-bold text-text-secondary">
|
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
||||||
{displayPokemon.name[0]?.toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-1.5">
|
<div className="mt-2 flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className={`w-2 h-2 rounded-full shrink-0 ${isDead ? 'bg-status-dead' : 'bg-status-alive'}`}
|
className={`w-2 h-2 rounded-full shrink-0 ${isDead ? 'bg-red-500' : 'bg-green-500'}`}
|
||||||
/>
|
/>
|
||||||
<span className="font-semibold text-text-primary text-sm">
|
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
|
||||||
{nickname || displayPokemon.name}
|
{nickname || displayPokemon.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{nickname && <div className="text-xs text-text-secondary">{displayPokemon.name}</div>}
|
{nickname && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{displayPokemon.name}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-0.5 mt-1">
|
<div className="flex flex-col items-center gap-0.5 mt-1">
|
||||||
{displayPokemon.types.map((type) => (
|
{displayPokemon.types.map((type) => (
|
||||||
@@ -44,20 +46,22 @@ export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardP
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs font-mono text-text-secondary mt-1">
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
{showFaintLevel && isDead
|
{showFaintLevel && isDead
|
||||||
? `Lv. ${catchLevel} → ${faintLevel}`
|
? `Lv. ${catchLevel} → ${faintLevel}`
|
||||||
: `Lv. ${catchLevel ?? '?'}`}
|
: `Lv. ${catchLevel ?? '?'}`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-text-tertiary mt-0.5">{route.name}</div>
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{route.name}</div>
|
||||||
|
|
||||||
{isEvolved && (
|
{isEvolved && (
|
||||||
<div className="text-[10px] text-text-tertiary mt-0.5">Originally: {pokemon.name}</div>
|
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
Originally: {pokemon.name}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDead && deathCause && (
|
{isDead && deathCause && (
|
||||||
<div className="text-[10px] italic text-text-tertiary mt-0.5 line-clamp-2">
|
<div className="text-[10px] italic text-gray-400 dark:text-gray-500 mt-0.5 line-clamp-2">
|
||||||
{deathCause}
|
{deathCause}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { NuzlockeRules } from '../types'
|
import type { NuzlockeRules } from '../types'
|
||||||
import { RULE_DEFINITIONS } from '../types/rules'
|
import { RULE_DEFINITIONS } from '../types/rules'
|
||||||
import { TypeBadge } from './TypeBadge'
|
|
||||||
|
|
||||||
interface RuleBadgesProps {
|
interface RuleBadgesProps {
|
||||||
rules: NuzlockeRules
|
rules: NuzlockeRules
|
||||||
@@ -8,10 +7,9 @@ interface RuleBadgesProps {
|
|||||||
|
|
||||||
export function RuleBadges({ rules }: RuleBadgesProps) {
|
export function RuleBadges({ rules }: RuleBadgesProps) {
|
||||||
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
|
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
|
||||||
const allowedTypes = rules.allowedTypes ?? []
|
|
||||||
|
|
||||||
if (enabledRules.length === 0 && allowedTypes.length === 0) {
|
if (enabledRules.length === 0) {
|
||||||
return <span className="text-sm text-text-tertiary">No rules enabled</span>
|
return <span className="text-sm text-gray-500 dark:text-gray-400">No rules enabled</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,26 +20,15 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
|
|||||||
title={def.description}
|
title={def.description}
|
||||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
def.category === 'core'
|
def.category === 'core'
|
||||||
? 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700'
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
|
||||||
: def.category === 'variant'
|
: def.category === 'completion'
|
||||||
? 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700'
|
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
|
||||||
: 'bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-700'
|
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{def.name}
|
{def.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{allowedTypes.length > 0 && (
|
|
||||||
<span
|
|
||||||
title={`Type restriction: ${allowedTypes.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(', ')}`}
|
|
||||||
className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-700 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<span>Type Restriction</span>
|
|
||||||
{allowedTypes.map((t) => (
|
|
||||||
<TypeBadge key={t} type={t} size="sm" />
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ export function RuleToggle({ name, description, enabled, onChange }: RuleToggleP
|
|||||||
const [showTooltip, setShowTooltip] = useState(false)
|
const [showTooltip, setShowTooltip] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-3 border-b border-border-default last:border-0">
|
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||||
<div className="flex-1 pr-4">
|
<div className="flex-1 pr-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-text-primary">{name}</span>
|
<span className="font-medium text-gray-900 dark:text-gray-100">{name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-text-tertiary hover:text-text-secondary"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
onMouseEnter={() => setShowTooltip(true)}
|
onMouseEnter={() => setShowTooltip(true)}
|
||||||
onMouseLeave={() => setShowTooltip(false)}
|
onMouseLeave={() => setShowTooltip(false)}
|
||||||
onClick={() => setShowTooltip(!showTooltip)}
|
onClick={() => setShowTooltip(!showTooltip)}
|
||||||
@@ -33,15 +33,17 @@ export function RuleToggle({ name, description, enabled, onChange }: RuleToggleP
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{showTooltip && <p className="mt-1 text-sm text-text-tertiary">{description}</p>}
|
{showTooltip && (
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={enabled}
|
aria-checked={enabled}
|
||||||
onClick={() => onChange(!enabled)}
|
onClick={() => onChange(!enabled)}
|
||||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 ${
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
|
||||||
enabled ? 'bg-blue-600' : 'bg-surface-3'
|
enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
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,28 +1,6 @@
|
|||||||
import type { NuzlockeRules } from '../types/rules'
|
import type { NuzlockeRules } from '../types/rules'
|
||||||
import { RULE_DEFINITIONS, DEFAULT_RULES } from '../types/rules'
|
import { RULE_DEFINITIONS, DEFAULT_RULES } from '../types/rules'
|
||||||
import { RuleToggle } from './RuleToggle'
|
import { RuleToggle } from './RuleToggle'
|
||||||
import { TypeBadge } from './TypeBadge'
|
|
||||||
|
|
||||||
const POKEMON_TYPES = [
|
|
||||||
'bug',
|
|
||||||
'dark',
|
|
||||||
'dragon',
|
|
||||||
'electric',
|
|
||||||
'fairy',
|
|
||||||
'fighting',
|
|
||||||
'fire',
|
|
||||||
'flying',
|
|
||||||
'ghost',
|
|
||||||
'grass',
|
|
||||||
'ground',
|
|
||||||
'ice',
|
|
||||||
'normal',
|
|
||||||
'poison',
|
|
||||||
'psychic',
|
|
||||||
'rock',
|
|
||||||
'steel',
|
|
||||||
'water',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
interface RulesConfigurationProps {
|
interface RulesConfigurationProps {
|
||||||
rules: NuzlockeRules
|
rules: NuzlockeRules
|
||||||
@@ -41,8 +19,8 @@ export function RulesConfiguration({
|
|||||||
? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key))
|
? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key))
|
||||||
: RULE_DEFINITIONS
|
: RULE_DEFINITIONS
|
||||||
const coreRules = visibleRules.filter((r) => r.category === 'core')
|
const coreRules = visibleRules.filter((r) => r.category === 'core')
|
||||||
const playstyleRules = visibleRules.filter((r) => r.category === 'playstyle')
|
const difficultyRules = visibleRules.filter((r) => r.category === 'difficulty')
|
||||||
const variantRules = visibleRules.filter((r) => r.category === 'variant')
|
const completionRules = visibleRules.filter((r) => r.category === 'completion')
|
||||||
|
|
||||||
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
|
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
|
||||||
onChange({ ...rules, [key]: value })
|
onChange({ ...rules, [key]: value })
|
||||||
@@ -53,41 +31,33 @@ export function RulesConfiguration({
|
|||||||
onReset?.()
|
onReset?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedTypes = rules.allowedTypes ?? []
|
const enabledCount = visibleRules.filter((r) => rules[r.key]).length
|
||||||
|
const totalCount = visibleRules.length
|
||||||
const toggleType = (type: string) => {
|
|
||||||
const next = allowedTypes.includes(type)
|
|
||||||
? allowedTypes.filter((t) => t !== type)
|
|
||||||
: [...allowedTypes, type]
|
|
||||||
onChange({ ...rules, allowedTypes: next })
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabledCount =
|
|
||||||
visibleRules.filter((r) => rules[r.key]).length + (allowedTypes.length > 0 ? 1 : 0)
|
|
||||||
const totalCount = visibleRules.length + 1 // +1 for type restriction
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-text-primary">Rules Configuration</h2>
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
<p className="text-sm text-text-tertiary">
|
Rules Configuration
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{enabledCount} of {totalCount} rules enabled
|
{enabledCount} of {totalCount} rules enabled
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleResetToDefault}
|
onClick={handleResetToDefault}
|
||||||
className="text-sm text-text-link hover:text-accent-300"
|
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
>
|
>
|
||||||
Reset to Default
|
Reset to Default
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-surface-1 rounded-lg shadow">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
<div className="px-4 py-3 border-b border-border-default">
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 className="text-lg font-medium text-text-primary">Core Rules</h3>
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Core Rules</h3>
|
||||||
<p className="text-sm text-text-tertiary">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
The fundamental rules of a Nuzlocke challenge
|
The fundamental rules of a Nuzlocke challenge
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,15 +74,17 @@ export function RulesConfiguration({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-surface-1 rounded-lg shadow">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
<div className="px-4 py-3 border-b border-border-default">
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 className="text-lg font-medium text-text-primary">Playstyle</h3>
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
<p className="text-sm text-text-tertiary">
|
Difficulty Modifiers
|
||||||
Describe how you're playing — doesn't affect tracker behavior
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Optional rules to increase the challenge
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
{playstyleRules.map((rule) => (
|
{difficultyRules.map((rule) => (
|
||||||
<RuleToggle
|
<RuleToggle
|
||||||
key={rule.key}
|
key={rule.key}
|
||||||
name={rule.name}
|
name={rule.name}
|
||||||
@@ -124,63 +96,27 @@ export function RulesConfiguration({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-surface-1 rounded-lg shadow">
|
{completionRules.length > 0 && (
|
||||||
<div className="px-4 py-3 border-b border-border-default">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
<h3 className="text-lg font-medium text-text-primary">Run Variant</h3>
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
<p className="text-sm text-text-tertiary">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Completion</h3>
|
||||||
Changes which Pokémon can appear — affects the encounter selector
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
When is the run considered complete
|
||||||
</div>
|
</p>
|
||||||
<div className="px-4">
|
</div>
|
||||||
{variantRules.map((rule) => (
|
<div className="px-4">
|
||||||
<RuleToggle
|
{completionRules.map((rule) => (
|
||||||
key={rule.key}
|
<RuleToggle
|
||||||
name={rule.name}
|
key={rule.key}
|
||||||
description={rule.description}
|
name={rule.name}
|
||||||
enabled={rules[rule.key]}
|
description={rule.description}
|
||||||
onChange={(value) => handleRuleChange(rule.key, value)}
|
enabled={rules[rule.key]}
|
||||||
/>
|
onChange={(value) => handleRuleChange(rule.key, value)}
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-surface-1 rounded-lg shadow">
|
|
||||||
<div className="px-4 py-3 border-b border-border-default">
|
|
||||||
<h3 className="text-lg font-medium text-text-primary">Type Restriction</h3>
|
|
||||||
<p className="text-sm text-text-tertiary">
|
|
||||||
Monolocke and variants. Select allowed types — a Pokémon qualifies if it shares at least
|
|
||||||
one type. Leave all deselected to disable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="px-4 py-4">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{POKEMON_TYPES.map((type) => (
|
|
||||||
<button
|
|
||||||
key={type}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleType(type)}
|
|
||||||
title={type.charAt(0).toUpperCase() + type.slice(1)}
|
|
||||||
className={`p-1.5 rounded-lg border-2 transition-colors ${
|
|
||||||
allowedTypes.includes(type)
|
|
||||||
? 'border-accent-400 bg-accent-900/20'
|
|
||||||
: 'border-transparent opacity-40 hover:opacity-70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TypeBadge type={type} size="md" />
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{allowedTypes.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange({ ...rules, allowedTypes: [] })}
|
|
||||||
className="mt-3 text-xs text-text-tertiary hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
Clear selection
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ interface ShinyBoxProps {
|
|||||||
|
|
||||||
export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
|
export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
|
||||||
return (
|
return (
|
||||||
<div className="border-2 border-yellow-600 rounded-lg p-4">
|
<div className="border-2 border-yellow-400 dark:border-yellow-600 rounded-lg p-4">
|
||||||
<h3 className="text-sm font-semibold text-yellow-400 light:text-amber-700 mb-3 flex items-center gap-1.5">
|
<h3 className="text-sm font-semibold text-yellow-600 dark:text-yellow-400 mb-3 flex items-center gap-1.5">
|
||||||
<span>✦</span>
|
<span>✦</span>
|
||||||
Shiny Box
|
Shiny Box
|
||||||
<span className="text-xs font-normal text-text-muted ml-1">
|
<span className="text-xs font-normal text-gray-400 dark:text-gray-500 ml-1">
|
||||||
{encounters.length} {encounters.length === 1 ? 'shiny' : 'shinies'}
|
{encounters.length} {encounters.length === 1 ? 'shiny' : 'shinies'}
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
@@ -27,7 +27,9 @@ export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-text-muted text-center py-2">No shinies found yet</p>
|
<p className="text-sm text-gray-400 dark:text-gray-500 text-center py-2">
|
||||||
|
No shinies found yet
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,14 +92,17 @@ export function ShinyEncounterModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-surface-1 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="sticky top-0 bg-surface-1 border-b border-yellow-600 px-6 py-4 rounded-t-xl">
|
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-yellow-300 dark:border-yellow-600 px-6 py-4 rounded-t-xl">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
<span className="text-yellow-500">✦</span>
|
<span className="text-yellow-500">✦</span>
|
||||||
Log Shiny Encounter
|
Log Shiny Encounter
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="text-text-tertiary hover:text-text-primary">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -110,7 +113,7 @@ export function ShinyEncounterModal({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-yellow-400 light:text-amber-700 mt-1">
|
<p className="text-sm text-yellow-600 dark:text-yellow-400 mt-1">
|
||||||
Shiny catches bypass the one-per-route rule
|
Shiny catches bypass the one-per-route rule
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,11 +121,13 @@ export function ShinyEncounterModal({
|
|||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
{/* Route selector */}
|
{/* Route selector */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-secondary mb-1">Route</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Route
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={selectedRouteId ?? ''}
|
value={selectedRouteId ?? ''}
|
||||||
onChange={(e) => handleRouteChange(Number(e.target.value))}
|
onChange={(e) => handleRouteChange(Number(e.target.value))}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-yellow-500"
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500"
|
||||||
>
|
>
|
||||||
<option value="">Select a route...</option>
|
<option value="">Select a route...</option>
|
||||||
{leafRoutes.map((r) => (
|
{leafRoutes.map((r) => (
|
||||||
@@ -136,7 +141,9 @@ export function ShinyEncounterModal({
|
|||||||
{/* Pokemon Selection */}
|
{/* Pokemon Selection */}
|
||||||
{selectedRouteId && (
|
{selectedRouteId && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-secondary mb-1">Pokemon</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Pokemon
|
||||||
|
</label>
|
||||||
{loadingPokemon ? (
|
{loadingPokemon ? (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
<div className="w-6 h-6 border-2 border-yellow-500 border-t-transparent rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-yellow-500 border-t-transparent rounded-full animate-spin" />
|
||||||
@@ -149,15 +156,17 @@ export function ShinyEncounterModal({
|
|||||||
placeholder="Search pokemon..."
|
placeholder="Search pokemon..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-border-default bg-surface-2 text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-yellow-500"
|
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-yellow-500"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="max-h-64 overflow-y-auto space-y-3">
|
<div className="max-h-64 overflow-y-auto space-y-3">
|
||||||
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
|
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
|
||||||
<div key={method}>
|
<div key={method}>
|
||||||
{groupIdx > 0 && <div className="border-t border-border-default mb-3" />}
|
{groupIdx > 0 && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 mb-3" />
|
||||||
|
)}
|
||||||
{hasMultipleGroups && (
|
{hasMultipleGroups && (
|
||||||
<div className="text-xs font-medium text-text-tertiary mb-1.5">
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
|
||||||
{getMethodLabel(method)}
|
{getMethodLabel(method)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -169,8 +178,8 @@ export function ShinyEncounterModal({
|
|||||||
onClick={() => setSelectedPokemon(rp)}
|
onClick={() => setSelectedPokemon(rp)}
|
||||||
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
||||||
selectedPokemon?.id === rp.id
|
selectedPokemon?.id === rp.id
|
||||||
? 'border-yellow-500 bg-yellow-900/30'
|
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/30'
|
||||||
: 'border-border-default hover:border-border-default'
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{rp.pokemon.spriteUrl ? (
|
{rp.pokemon.spriteUrl ? (
|
||||||
@@ -180,17 +189,17 @@ export function ShinyEncounterModal({
|
|||||||
className="w-10 h-10"
|
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">
|
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
||||||
{rp.pokemon.name[0]?.toUpperCase()}
|
{rp.pokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-text-secondary mt-1 capitalize">
|
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
{rp.pokemon.name}
|
{rp.pokemon.name}
|
||||||
</span>
|
</span>
|
||||||
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
||||||
<EncounterMethodBadge method={rp.encounterMethod} />
|
<EncounterMethodBadge method={rp.encounterMethod} />
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-text-tertiary">
|
<span className="text-[10px] text-gray-400">
|
||||||
Lv. {rp.minLevel}
|
Lv. {rp.minLevel}
|
||||||
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
||||||
</span>
|
</span>
|
||||||
@@ -202,7 +211,9 @@ export function ShinyEncounterModal({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-text-tertiary py-2">No pokemon data for this route</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">
|
||||||
|
No pokemon data for this route
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -212,7 +223,7 @@ export function ShinyEncounterModal({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="shiny-nickname"
|
htmlFor="shiny-nickname"
|
||||||
className="block text-sm font-medium text-text-secondary mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Nickname
|
Nickname
|
||||||
</label>
|
</label>
|
||||||
@@ -222,7 +233,7 @@ export function ShinyEncounterModal({
|
|||||||
value={nickname}
|
value={nickname}
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
placeholder="Give it a name..."
|
placeholder="Give it a name..."
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-yellow-500"
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -232,7 +243,7 @@ export function ShinyEncounterModal({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="shiny-catch-level"
|
htmlFor="shiny-catch-level"
|
||||||
className="block text-sm font-medium text-text-secondary mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Catch Level
|
Catch Level
|
||||||
</label>
|
</label>
|
||||||
@@ -248,17 +259,17 @@ export function ShinyEncounterModal({
|
|||||||
? `${selectedPokemon.minLevel}–${selectedPokemon.maxLevel}`
|
? `${selectedPokemon.minLevel}–${selectedPokemon.maxLevel}`
|
||||||
: 'Level'
|
: 'Level'
|
||||||
}
|
}
|
||||||
className="w-24 px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-yellow-500"
|
className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-yellow-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky bottom-0 bg-surface-1 border-t border-border-default px-6 py-4 rounded-b-xl flex justify-end gap-3">
|
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-text-secondary bg-surface-2 rounded-lg font-medium hover:bg-surface-3 transition-colors"
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,26 +6,26 @@ interface StatCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const colorClasses: Record<string, string> = {
|
const colorClasses: Record<string, string> = {
|
||||||
blue: 'border-status-completed',
|
blue: 'border-blue-500',
|
||||||
green: 'border-status-alive',
|
green: 'border-green-500',
|
||||||
red: 'border-status-failed',
|
red: 'border-red-500',
|
||||||
purple: 'border-purple-500',
|
purple: 'border-purple-500',
|
||||||
amber: 'border-amber-500',
|
amber: 'border-amber-500',
|
||||||
gray: 'border-text-tertiary',
|
gray: 'border-gray-500',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatCard({ label, value, total, color }: StatCardProps) {
|
export function StatCard({ label, value, total, color }: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`bg-surface-1 rounded-lg border border-border-default p-4 border-l-4 ${colorClasses[color] ?? 'border-text-tertiary'}`}
|
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 ${colorClasses[color] ?? 'border-gray-500'}`}
|
||||||
>
|
>
|
||||||
<div className="text-2xl font-bold font-mono text-text-primary">
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{value}
|
{value}
|
||||||
{total !== undefined && (
|
{total !== undefined && (
|
||||||
<span className="text-sm font-normal font-sans text-text-secondary"> / {total}</span>
|
<span className="text-sm font-normal text-gray-500 dark:text-gray-400"> / {total}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-text-secondary">{label}</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,13 +91,16 @@ export function StatusChangeModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-surface-1 rounded-xl shadow-xl max-w-sm w-full">
|
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-sm w-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border-default">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{isDead ? 'Death Details' : 'Pokemon Status'}
|
{isDead ? 'Death Details' : 'Pokemon Status'}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="text-text-tertiary hover:text-text-primary">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -119,46 +122,51 @@ export function StatusChangeModal({
|
|||||||
className={`w-16 h-16 ${isDead ? 'grayscale opacity-60' : ''}`}
|
className={`w-16 h-16 ${isDead ? 'grayscale opacity-60' : ''}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-16 h-16 rounded-full bg-surface-3 flex items-center justify-center text-xl font-bold text-text-secondary">
|
<div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
||||||
{displayPokemon.name[0]?.toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-text-primary">
|
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{nickname || displayPokemon.name}
|
{nickname || displayPokemon.name}
|
||||||
</div>
|
</div>
|
||||||
{nickname && (
|
{nickname && (
|
||||||
<div className="text-xs text-text-tertiary capitalize">{displayPokemon.name}</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||||
|
{displayPokemon.name}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col items-start gap-0.5 mt-1">
|
<div className="flex flex-col items-start gap-0.5 mt-1">
|
||||||
{displayPokemon.types.map((type) => (
|
{displayPokemon.types.map((type) => (
|
||||||
<TypeBadge key={type} type={type} />
|
<TypeBadge key={type} type={type} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-tertiary mt-1">
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Lv. {catchLevel ?? '?'} · {route.name}
|
Lv. {catchLevel ?? '?'} · {route.name}
|
||||||
</div>
|
</div>
|
||||||
{currentPokemon && (
|
{currentPokemon && (
|
||||||
<div className="text-[10px] text-text-muted mt-0.5">Originally: {pokemon.name}</div>
|
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
Originally: {pokemon.name}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dead pokemon: view-only details */}
|
{/* Dead pokemon: view-only details */}
|
||||||
{isDead && (
|
{isDead && (
|
||||||
<div className="bg-status-failed-bg rounded-lg p-4 space-y-2">
|
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-status-failed font-medium text-sm">
|
<div className="flex items-center gap-2 text-red-700 dark:text-red-400 font-medium text-sm">
|
||||||
<span className="w-2 h-2 rounded-full bg-red-500" />
|
<span className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
Deceased
|
Deceased
|
||||||
</div>
|
</div>
|
||||||
{faintLevel !== null && (
|
{faintLevel !== null && (
|
||||||
<div className="text-sm text-text-secondary">
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
<span className="text-text-tertiary">Level at death:</span> {faintLevel}
|
<span className="text-gray-500 dark:text-gray-400">Level at death:</span>{' '}
|
||||||
|
{faintLevel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{deathCause && (
|
{deathCause && (
|
||||||
<div className="text-sm text-text-secondary">
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
<span className="text-text-tertiary">Cause:</span> {deathCause}
|
<span className="text-gray-500 dark:text-gray-400">Cause:</span> {deathCause}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +178,7 @@ export function StatusChangeModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowEvolve(true)}
|
onClick={() => setShowEvolve(true)}
|
||||||
className="flex-1 px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 transition-colors"
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Evolve
|
Evolve
|
||||||
</button>
|
</button>
|
||||||
@@ -197,20 +205,22 @@ export function StatusChangeModal({
|
|||||||
{!isDead && showEvolve && (
|
{!isDead && showEvolve && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-text-secondary">Evolve into:</h3>
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Evolve into:
|
||||||
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowEvolve(false)}
|
onClick={() => setShowEvolve(false)}
|
||||||
className="text-xs text-text-tertiary hover:text-text-secondary"
|
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{evolutionsLoading && (
|
{evolutionsLoading && (
|
||||||
<p className="text-sm text-text-tertiary">Loading evolutions...</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Loading evolutions...</p>
|
||||||
)}
|
)}
|
||||||
{!evolutionsLoading && normalEvolutions.length === 0 && (
|
{!evolutionsLoading && normalEvolutions.length === 0 && (
|
||||||
<p className="text-sm text-text-tertiary">No evolutions available</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions available</p>
|
||||||
)}
|
)}
|
||||||
{!evolutionsLoading && normalEvolutions.length > 0 && (
|
{!evolutionsLoading && normalEvolutions.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -220,7 +230,7 @@ export function StatusChangeModal({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => handleEvolve(evo.toPokemon.id)}
|
onClick={() => handleEvolve(evo.toPokemon.id)}
|
||||||
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border-default hover:bg-accent-900/20 hover:border-accent-600 transition-colors disabled:opacity-50"
|
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-300 dark:hover:border-blue-600 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{evo.toPokemon.spriteUrl ? (
|
{evo.toPokemon.spriteUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -229,15 +239,15 @@ export function StatusChangeModal({
|
|||||||
className="w-10 h-10"
|
className="w-10 h-10"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-sm font-bold text-text-secondary">
|
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
|
||||||
{evo.toPokemon.name[0]?.toUpperCase()}
|
{evo.toPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-medium text-text-primary text-sm">
|
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm">
|
||||||
{evo.toPokemon.name}
|
{evo.toPokemon.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-tertiary">
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{formatEvolutionMethod(evo)}
|
{formatEvolutionMethod(evo)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +262,9 @@ export function StatusChangeModal({
|
|||||||
{!isDead && showShedConfirm && shedCompanion && (
|
{!isDead && showShedConfirm && shedCompanion && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-text-secondary">Shed Evolution</h3>
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Shed Evolution
|
||||||
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -261,12 +273,12 @@ export function StatusChangeModal({
|
|||||||
setShedNickname('')
|
setShedNickname('')
|
||||||
setShowEvolve(true)
|
setShowEvolve(true)
|
||||||
}}
|
}}
|
||||||
className="text-xs text-text-tertiary hover:text-text-secondary"
|
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-amber-900/20 border border-amber-700 rounded-lg p-3">
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{shedCompanion.toPokemon.spriteUrl ? (
|
{shedCompanion.toPokemon.spriteUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -275,11 +287,11 @@ export function StatusChangeModal({
|
|||||||
className="w-12 h-12"
|
className="w-12 h-12"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 rounded-full bg-surface-3 flex items-center justify-center text-sm font-bold text-text-secondary">
|
<div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
|
||||||
{shedCompanion.toPokemon.name[0]?.toUpperCase()}
|
{shedCompanion.toPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-amber-300">
|
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
{displayPokemon.name} shed its shell! Would you also like to add{' '}
|
{displayPokemon.name} shed its shell! Would you also like to add{' '}
|
||||||
<span className="font-semibold">{shedCompanion.toPokemon.name}</span>?
|
<span className="font-semibold">{shedCompanion.toPokemon.name}</span>?
|
||||||
</p>
|
</p>
|
||||||
@@ -288,9 +300,9 @@ export function StatusChangeModal({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="shed-nickname"
|
htmlFor="shed-nickname"
|
||||||
className="block text-sm font-medium text-text-secondary mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Nickname <span className="font-normal text-text-tertiary">(optional)</span>
|
Nickname <span className="font-normal text-gray-400">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="shed-nickname"
|
id="shed-nickname"
|
||||||
@@ -299,7 +311,7 @@ export function StatusChangeModal({
|
|||||||
value={shedNickname}
|
value={shedNickname}
|
||||||
onChange={(e) => setShedNickname(e.target.value)}
|
onChange={(e) => setShedNickname(e.target.value)}
|
||||||
placeholder={shedCompanion.toPokemon.name}
|
placeholder={shedCompanion.toPokemon.name}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-amber-500"
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-1">
|
<div className="flex gap-3 pt-1">
|
||||||
@@ -307,7 +319,7 @@ export function StatusChangeModal({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => applyEvolution(false)}
|
onClick={() => applyEvolution(false)}
|
||||||
className="flex-1 px-4 py-2 bg-surface-2 text-text-secondary rounded-lg font-medium hover:bg-surface-3 disabled:opacity-50 transition-colors"
|
className="flex-1 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
</button>
|
</button>
|
||||||
@@ -327,11 +339,13 @@ export function StatusChangeModal({
|
|||||||
{!isDead && showFormChange && (
|
{!isDead && showFormChange && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-text-secondary">Change form to:</h3>
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Change form to:
|
||||||
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowFormChange(false)}
|
onClick={() => setShowFormChange(false)}
|
||||||
className="text-xs text-text-tertiary hover:text-text-secondary"
|
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
@@ -344,17 +358,19 @@ export function StatusChangeModal({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => handleEvolve(form.id)}
|
onClick={() => handleEvolve(form.id)}
|
||||||
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border-default hover:bg-purple-900/20 hover:border-purple-600 transition-colors disabled:opacity-50"
|
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:border-purple-300 dark:hover:border-purple-600 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{form.spriteUrl ? (
|
{form.spriteUrl ? (
|
||||||
<img src={form.spriteUrl} alt={form.name} className="w-10 h-10" />
|
<img src={form.spriteUrl} alt={form.name} className="w-10 h-10" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-sm font-bold text-text-secondary">
|
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
|
||||||
{form.name[0]?.toUpperCase()}
|
{form.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-medium text-text-primary text-sm">{form.name}</div>
|
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm">
|
||||||
|
{form.name}
|
||||||
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{form.types.map((type) => (
|
{form.types.map((type) => (
|
||||||
<TypeBadge key={type} type={type} />
|
<TypeBadge key={type} type={type} />
|
||||||
@@ -371,8 +387,8 @@ export function StatusChangeModal({
|
|||||||
{/* Confirmation form */}
|
{/* Confirmation form */}
|
||||||
{!isDead && showConfirm && (
|
{!isDead && showConfirm && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="bg-status-failed-bg rounded-lg p-3">
|
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||||
<p className="text-sm text-status-failed font-medium">
|
<p className="text-sm text-red-700 dark:text-red-400 font-medium">
|
||||||
This cannot be undone (Nuzlocke rules).
|
This cannot be undone (Nuzlocke rules).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -380,9 +396,9 @@ export function StatusChangeModal({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="death-level"
|
htmlFor="death-level"
|
||||||
className="block text-sm font-medium text-text-secondary mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Level at Death <span className="font-normal text-text-tertiary">(optional)</span>
|
Level at Death <span className="font-normal text-gray-400">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="death-level"
|
id="death-level"
|
||||||
@@ -392,16 +408,16 @@ export function StatusChangeModal({
|
|||||||
value={deathLevel}
|
value={deathLevel}
|
||||||
onChange={(e) => setDeathLevel(e.target.value)}
|
onChange={(e) => setDeathLevel(e.target.value)}
|
||||||
placeholder="Level"
|
placeholder="Level"
|
||||||
className="w-24 px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-red-500"
|
className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="death-cause"
|
htmlFor="death-cause"
|
||||||
className="block text-sm font-medium text-text-secondary mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Cause of Death <span className="font-normal text-text-tertiary">(optional)</span>
|
Cause of Death <span className="font-normal text-gray-400">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="death-cause"
|
id="death-cause"
|
||||||
@@ -410,7 +426,7 @@ export function StatusChangeModal({
|
|||||||
value={cause}
|
value={cause}
|
||||||
onChange={(e) => setCause(e.target.value)}
|
onChange={(e) => setCause(e.target.value)}
|
||||||
placeholder="e.g. Crit from rival's Charizard"
|
placeholder="e.g. Crit from rival's Charizard"
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-red-500"
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -418,7 +434,7 @@ export function StatusChangeModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowConfirm(false)}
|
onClick={() => setShowConfirm(false)}
|
||||||
className="flex-1 px-4 py-2 bg-surface-2 text-text-secondary rounded-lg font-medium hover:bg-surface-3 transition-colors"
|
className="flex-1 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -438,11 +454,11 @@ export function StatusChangeModal({
|
|||||||
{/* Footer for dead/no-confirm/no-evolve views */}
|
{/* Footer for dead/no-confirm/no-evolve views */}
|
||||||
{(isDead ||
|
{(isDead ||
|
||||||
(!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm)) && (
|
(!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm)) && (
|
||||||
<div className="px-6 py-4 border-t border-border-default flex justify-end">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 bg-surface-2 text-text-secondary rounded-lg font-medium hover:bg-surface-3 transition-colors"
|
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ export function StepIndicator({
|
|||||||
disabled={!isCompleted}
|
disabled={!isCompleted}
|
||||||
className={`flex items-center gap-2 text-sm font-medium ${
|
className={`flex items-center gap-2 text-sm font-medium ${
|
||||||
isCompleted
|
isCompleted
|
||||||
? 'text-text-link cursor-pointer hover:text-accent-300'
|
? 'text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300'
|
||||||
: isCurrent
|
: isCurrent
|
||||||
? 'text-text-primary'
|
? 'text-gray-900 dark:text-gray-100'
|
||||||
: 'text-text-muted cursor-default'
|
: 'text-gray-400 dark:text-gray-500 cursor-default'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -38,8 +38,8 @@ export function StepIndicator({
|
|||||||
isCompleted
|
isCompleted
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: isCurrent
|
: isCurrent
|
||||||
? 'border-2 border-accent-400 text-accent-400'
|
? 'border-2 border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||||
: 'border-2 border-border-default text-text-muted'
|
: 'border-2 border-gray-300 dark:border-gray-600 text-gray-400 dark:text-gray-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
@@ -61,7 +61,7 @@ export function StepIndicator({
|
|||||||
{i < steps.length - 1 && (
|
{i < steps.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className={`flex-1 h-0.5 mx-3 ${
|
className={`flex-1 h-0.5 mx-3 ${
|
||||||
step < currentStep ? 'bg-blue-600' : 'bg-surface-3'
|
step < currentStep ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: Transfer
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black/50" />
|
<div className="fixed inset-0 bg-black/50" />
|
||||||
<div className="relative bg-surface-1 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="sticky top-0 bg-surface-1 border-b border-border-default px-6 py-4 rounded-t-xl">
|
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 rounded-t-xl">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">Transfer Pokemon to Next Leg</h2>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
<p className="text-sm text-text-tertiary mt-1">
|
Transfer Pokemon to Next Leg
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Selected Pokemon will be bred down to their base form and appear as level 1 encounters
|
Selected Pokemon will be bred down to their base form and appear as level 1 encounters
|
||||||
in the next leg.
|
in the next leg.
|
||||||
</p>
|
</p>
|
||||||
@@ -48,8 +50,8 @@ export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: Transfer
|
|||||||
onClick={() => toggle(enc.id)}
|
onClick={() => toggle(enc.id)}
|
||||||
className={`flex flex-col items-center p-3 rounded-lg border-2 text-center transition-colors ${
|
className={`flex flex-col items-center p-3 rounded-lg border-2 text-center transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-indigo-500 bg-indigo-900/20'
|
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
|
||||||
: 'border-border-default hover:border-border-default'
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{displayPokemon.spriteUrl ? (
|
{displayPokemon.spriteUrl ? (
|
||||||
@@ -59,34 +61,34 @@ export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: Transfer
|
|||||||
className="w-14 h-14"
|
className="w-14 h-14"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-14 h-14 rounded-full bg-surface-3 flex items-center justify-center text-lg font-bold">
|
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
||||||
{displayPokemon.name[0]?.toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium text-text-secondary mt-1 capitalize">
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
{enc.nickname || displayPokemon.name}
|
{enc.nickname || displayPokemon.name}
|
||||||
</span>
|
</span>
|
||||||
{enc.nickname && (
|
{enc.nickname && (
|
||||||
<span className="text-[10px] text-text-tertiary">{displayPokemon.name}</span>
|
<span className="text-[10px] text-gray-400">{displayPokemon.name}</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-text-tertiary mt-0.5">{enc.route.name}</span>
|
<span className="text-[10px] text-gray-400 mt-0.5">{enc.route.name}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky bottom-0 bg-surface-1 border-t border-border-default px-6 py-4 rounded-b-xl flex items-center justify-between">
|
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSkip}
|
onClick={onSkip}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="text-sm text-text-tertiary hover:text-text-primary disabled:opacity-50"
|
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Skip (No Transfers)
|
Skip (No Transfers)
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-text-muted">
|
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
{selected.size}/{hofTeam.length} selected
|
{selected.size}/{hofTeam.length} selected
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export function AdminLayout() {
|
|||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`block px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap ${
|
`block px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent-900/40 text-accent-300 light:bg-accent-100 light:text-accent-700'
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
|
||||||
: 'hover:bg-surface-2'
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -61,26 +61,26 @@ export function AdminTable<T>({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="border border-border-default rounded-lg overflow-hidden">
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-border-default">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-surface-1">
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<th
|
<th
|
||||||
key={col.header}
|
key={col.header}
|
||||||
className={`px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider ${col.className ?? ''}`}
|
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''}`}
|
||||||
>
|
>
|
||||||
{col.header}
|
{col.header}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-surface-0 divide-y divide-border-default">
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}>
|
<td key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}>
|
||||||
<div className="h-4 bg-surface-3 rounded animate-pulse" />
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -93,17 +93,17 @@ export function AdminTable<T>({
|
|||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8 text-text-tertiary border border-border-default rounded-lg">
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-border-default rounded-lg overflow-hidden">
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-border-default">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-surface-1">
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
const sortable = !!col.sortKey
|
const sortable = !!col.sortKey
|
||||||
@@ -112,7 +112,7 @@ export function AdminTable<T>({
|
|||||||
<th
|
<th
|
||||||
key={col.header}
|
key={col.header}
|
||||||
onClick={sortable ? () => handleSort(col.header) : undefined}
|
onClick={sortable ? () => handleSort(col.header) : undefined}
|
||||||
className={`px-4 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-text-primary' : ''}`}
|
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{col.header}
|
{col.header}
|
||||||
@@ -127,12 +127,14 @@ export function AdminTable<T>({
|
|||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-surface-0 divide-y divide-border-default">
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{sortedData.map((row) => (
|
{sortedData.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={keyFn(row)}
|
key={keyFn(row)}
|
||||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
className={onRowClick ? 'cursor-pointer hover:bg-surface-2' : ''}
|
className={
|
||||||
|
onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td
|
<td
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function BossBattleFormModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEditTeam}
|
onClick={onEditTeam}
|
||||||
className="text-sm text-text-link hover:underline"
|
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
>
|
>
|
||||||
Edit Team ({boss?.pokemon.length ?? 0})
|
Edit Team ({boss?.pokemon.length ?? 0})
|
||||||
</button>
|
</button>
|
||||||
@@ -123,7 +123,7 @@ export function BossBattleFormModal({
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g. Brock"
|
placeholder="e.g. Brock"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -131,7 +131,7 @@ export function BossBattleFormModal({
|
|||||||
<select
|
<select
|
||||||
value={bossType}
|
value={bossType}
|
||||||
onChange={(e) => setBossType(e.target.value as typeof bossType)}
|
onChange={(e) => setBossType(e.target.value as typeof bossType)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
>
|
>
|
||||||
{BOSS_TYPES.map((t) => (
|
{BOSS_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={t.value} value={t.value}>
|
||||||
@@ -145,7 +145,7 @@ export function BossBattleFormModal({
|
|||||||
<select
|
<select
|
||||||
value={specialtyType}
|
value={specialtyType}
|
||||||
onChange={(e) => setSpecialtyType(e.target.value)}
|
onChange={(e) => setSpecialtyType(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default capitalize"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600 capitalize"
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{POKEMON_TYPES.map((t) => (
|
{POKEMON_TYPES.map((t) => (
|
||||||
@@ -165,7 +165,7 @@ export function BossBattleFormModal({
|
|||||||
value={location}
|
value={location}
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
placeholder="e.g. Pewter City Gym"
|
placeholder="e.g. Pewter City Gym"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ export function BossBattleFormModal({
|
|||||||
min={1}
|
min={1}
|
||||||
value={levelCap}
|
value={levelCap}
|
||||||
onChange={(e) => setLevelCap(e.target.value)}
|
onChange={(e) => setLevelCap(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -189,7 +189,7 @@ export function BossBattleFormModal({
|
|||||||
min={1}
|
min={1}
|
||||||
value={order}
|
value={order}
|
||||||
onChange={(e) => setOrder(e.target.value)}
|
onChange={(e) => setOrder(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +202,7 @@ export function BossBattleFormModal({
|
|||||||
value={section}
|
value={section}
|
||||||
onChange={(e) => setSection(e.target.value)}
|
onChange={(e) => setSection(e.target.value)}
|
||||||
placeholder="e.g. Main Story, Endgame"
|
placeholder="e.g. Main Story, Endgame"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{games && games.length > 1 && (
|
{games && games.length > 1 && (
|
||||||
@@ -211,7 +211,7 @@ export function BossBattleFormModal({
|
|||||||
<select
|
<select
|
||||||
value={gameId}
|
value={gameId}
|
||||||
onChange={(e) => setGameId(e.target.value)}
|
onChange={(e) => setGameId(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
>
|
>
|
||||||
<option value="">All games</option>
|
<option value="">All games</option>
|
||||||
{games.map((g) => (
|
{games.map((g) => (
|
||||||
@@ -229,7 +229,7 @@ export function BossBattleFormModal({
|
|||||||
<select
|
<select
|
||||||
value={afterRouteId}
|
value={afterRouteId}
|
||||||
onChange={(e) => setAfterRouteId(e.target.value)}
|
onChange={(e) => setAfterRouteId(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{sortedRoutes.map((r) => (
|
{sortedRoutes.map((r) => (
|
||||||
@@ -248,7 +248,7 @@ export function BossBattleFormModal({
|
|||||||
value={badgeName}
|
value={badgeName}
|
||||||
onChange={(e) => setBadgeName(e.target.value)}
|
onChange={(e) => setBadgeName(e.target.value)}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -258,7 +258,7 @@ export function BossBattleFormModal({
|
|||||||
value={badgeImageUrl}
|
value={badgeImageUrl}
|
||||||
onChange={(e) => setBadgeImageUrl(e.target.value)}
|
onChange={(e) => setBadgeImageUrl(e.target.value)}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,7 +270,7 @@ export function BossBattleFormModal({
|
|||||||
value={spriteUrl}
|
value={spriteUrl}
|
||||||
onChange={(e) => setSpriteUrl(e.target.value)}
|
onChange={(e) => setSpriteUrl(e.target.value)}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|||||||
@@ -150,13 +150,13 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<div className="px-6 py-4 border-b border-border-default">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
|
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Variant tabs */}
|
{/* Variant tabs */}
|
||||||
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap border-b border-border-default">
|
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap border-b border-gray-200 dark:border-gray-700">
|
||||||
{variants.map((v, i) => (
|
{variants.map((v, i) => (
|
||||||
<button
|
<button
|
||||||
key={v.label ?? '__default'}
|
key={v.label ?? '__default'}
|
||||||
@@ -164,8 +164,8 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
onClick={() => setActiveTab(i)}
|
onClick={() => setActiveTab(i)}
|
||||||
className={`px-3 py-1.5 text-sm font-medium rounded-t-md border border-b-0 transition-colors ${
|
className={`px-3 py-1.5 text-sm font-medium rounded-t-md border border-b-0 transition-colors ${
|
||||||
activeTab === i
|
activeTab === i
|
||||||
? 'bg-surface-1 border-border-default text-text-primary'
|
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100'
|
||||||
: 'bg-surface-2 border-transparent text-text-tertiary hover:text-text-secondary'
|
: 'bg-gray-50 dark:bg-gray-700 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{v.label ?? 'Default'}
|
{v.label ?? 'Default'}
|
||||||
@@ -175,7 +175,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
removeVariant(i)
|
removeVariant(i)
|
||||||
}}
|
}}
|
||||||
className="ml-1.5 text-text-tertiary hover:text-red-500 cursor-pointer"
|
className="ml-1.5 text-gray-400 hover:text-red-500 cursor-pointer"
|
||||||
title="Remove variant"
|
title="Remove variant"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -187,7 +187,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAddVariant(true)}
|
onClick={() => setShowAddVariant(true)}
|
||||||
className="px-2 py-1.5 text-sm text-text-link hover:text-accent-300"
|
className="px-2 py-1.5 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||||
title="Add variant"
|
title="Add variant"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
@@ -206,20 +206,20 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
if (e.key === 'Escape') setShowAddVariant(false)
|
if (e.key === 'Escape') setShowAddVariant(false)
|
||||||
}}
|
}}
|
||||||
placeholder="Variant name..."
|
placeholder="Variant name..."
|
||||||
className="px-2 py-1 text-sm border rounded bg-surface-2 border-border-default w-40"
|
className="px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 w-40"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addVariant}
|
onClick={addVariant}
|
||||||
className="px-2 py-1 text-sm text-text-link"
|
className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAddVariant(false)}
|
onClick={() => setShowAddVariant(false)}
|
||||||
className="px-1 py-1 text-sm text-text-tertiary"
|
className="px-1 py-1 text-sm text-gray-400"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -247,7 +247,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
max={100}
|
max={100}
|
||||||
value={slot.level}
|
value={slot.level}
|
||||||
onChange={(e) => updateSlot(index, 'level', e.target.value)}
|
onChange={(e) => updateSlot(index, 'level', e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -265,25 +265,25 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addSlot}
|
onClick={addSlot}
|
||||||
className="w-full py-2 text-sm text-text-link border border-dashed border-border-default rounded-md hover:bg-surface-2"
|
className="w-full py-2 text-sm text-blue-600 dark:text-blue-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
+ Add Pokemon
|
+ Add Pokemon
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-t border-border-default flex justify-end gap-3">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500 disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSaving ? 'Saving...' : 'Save Team'}
|
{isSaving ? 'Saving...' : 'Save Team'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ export function BulkImportModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<div className="px-6 py-4 border-b border-border-default">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold">{title}</h2>
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -66,23 +66,23 @@ export function BulkImportModal({
|
|||||||
value={json}
|
value={json}
|
||||||
onChange={(e) => setJson(e.target.value)}
|
onChange={(e) => setJson(e.target.value)}
|
||||||
placeholder={example}
|
placeholder={example}
|
||||||
className="w-full px-3 py-2 border rounded-md font-mono text-sm bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md font-mono text-sm dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-status-failed-bg text-status-failed rounded-md text-sm">
|
<div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-md text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="p-3 bg-green-900/30 text-green-300 light:bg-green-100 light:text-green-800 rounded-md text-sm">
|
<div className="p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-md text-sm">
|
||||||
<p>
|
<p>
|
||||||
{createdLabel}: {result.created}, {updatedLabel}: {result.updated}
|
{createdLabel}: {result.created}, {updatedLabel}: {result.updated}
|
||||||
</p>
|
</p>
|
||||||
{result.errors.length > 0 && (
|
{result.errors.length > 0 && (
|
||||||
<ul className="mt-2 list-disc list-inside text-status-failed">
|
<ul className="mt-2 list-disc list-inside text-red-600 dark:text-red-400">
|
||||||
{result.errors.map((err, i) => (
|
{result.errors.map((err, i) => (
|
||||||
<li key={i}>{err}</li>
|
<li key={i}>{err}</li>
|
||||||
))}
|
))}
|
||||||
@@ -91,18 +91,18 @@ export function BulkImportModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-border-default flex justify-end gap-3">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !json.trim()}
|
disabled={isSubmitting || !json.trim()}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500 disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Importing...' : 'Import'}
|
{isSubmitting ? 'Importing...' : 'Import'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,17 +18,17 @@ export function DeleteConfirmModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onCancel} />
|
<div className="fixed inset-0 bg-black/50" onClick={onCancel} />
|
||||||
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-md w-full mx-4">
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-status-failed">{title}</h2>
|
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400">{title}</h2>
|
||||||
<p className="mt-2 text-sm text-text-secondary">{message}</p>
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">{message}</p>
|
||||||
{error && <p className="mt-2 text-sm text-status-failed">{error}</p>}
|
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-border-default flex justify-end gap-3">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function EvolutionFormModal({
|
|||||||
<select
|
<select
|
||||||
value={trigger}
|
value={trigger}
|
||||||
onChange={(e) => setTrigger(e.target.value)}
|
onChange={(e) => setTrigger(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
>
|
>
|
||||||
{TRIGGER_OPTIONS.map((t) => (
|
{TRIGGER_OPTIONS.map((t) => (
|
||||||
<option key={t} value={t}>
|
<option key={t} value={t}>
|
||||||
@@ -92,7 +92,7 @@ export function EvolutionFormModal({
|
|||||||
value={minLevel}
|
value={minLevel}
|
||||||
onChange={(e) => setMinLevel(e.target.value)}
|
onChange={(e) => setMinLevel(e.target.value)}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -102,7 +102,7 @@ export function EvolutionFormModal({
|
|||||||
value={item}
|
value={item}
|
||||||
onChange={(e) => setItem(e.target.value)}
|
onChange={(e) => setItem(e.target.value)}
|
||||||
placeholder="e.g. thunder-stone"
|
placeholder="e.g. thunder-stone"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -112,7 +112,7 @@ export function EvolutionFormModal({
|
|||||||
value={heldItem}
|
value={heldItem}
|
||||||
onChange={(e) => setHeldItem(e.target.value)}
|
onChange={(e) => setHeldItem(e.target.value)}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -122,7 +122,7 @@ export function EvolutionFormModal({
|
|||||||
value={condition}
|
value={condition}
|
||||||
onChange={(e) => setCondition(e.target.value)}
|
onChange={(e) => setCondition(e.target.value)}
|
||||||
placeholder="e.g. high-happiness, daytime"
|
placeholder="e.g. high-happiness, daytime"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -132,7 +132,7 @@ export function EvolutionFormModal({
|
|||||||
value={region}
|
value={region}
|
||||||
onChange={(e) => setRegion(e.target.value)}
|
onChange={(e) => setRegion(e.target.value)}
|
||||||
placeholder="e.g. alola (blank = all regions)"
|
placeholder="e.g. alola (blank = all regions)"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ export function FormModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<div className="px-6 py-4 border-b border-border-default">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold">{title}</h2>
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
{headerExtra}
|
{headerExtra}
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<div className="px-6 py-4 space-y-4">{children}</div>
|
<div className="px-6 py-4 space-y-4">{children}</div>
|
||||||
<div className="px-6 py-4 border-t border-border-default flex items-center gap-3">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -53,7 +53,7 @@ export function FormModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => setConfirmingDelete(false)}
|
onBlur={() => setConfirmingDelete(false)}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md text-status-failed border border-red-600 hover:bg-red-900/20 disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
@@ -62,14 +62,14 @@ export function FormModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500 disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Saving...' : submitLabel}
|
{isSubmitting ? 'Saving...' : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function GameFormModal({
|
|||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
detailUrl ? (
|
detailUrl ? (
|
||||||
<Link to={detailUrl} className="text-sm text-text-link hover:underline">
|
<Link to={detailUrl} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
View Routes & Bosses
|
View Routes & Bosses
|
||||||
</Link>
|
</Link>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -76,7 +76,7 @@ export function GameFormModal({
|
|||||||
required
|
required
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -89,7 +89,7 @@ export function GameFormModal({
|
|||||||
setSlug(e.target.value)
|
setSlug(e.target.value)
|
||||||
setAutoSlug(false)
|
setAutoSlug(false)
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -101,7 +101,7 @@ export function GameFormModal({
|
|||||||
min={1}
|
min={1}
|
||||||
value={generation}
|
value={generation}
|
||||||
onChange={(e) => setGeneration(e.target.value)}
|
onChange={(e) => setGeneration(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -111,7 +111,7 @@ export function GameFormModal({
|
|||||||
required
|
required
|
||||||
value={region}
|
value={region}
|
||||||
onChange={(e) => setRegion(e.target.value)}
|
onChange={(e) => setRegion(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +122,7 @@ export function GameFormModal({
|
|||||||
value={boxArtUrl}
|
value={boxArtUrl}
|
||||||
onChange={(e) => setBoxArtUrl(e.target.value)}
|
onChange={(e) => setBoxArtUrl(e.target.value)}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -132,7 +132,7 @@ export function GameFormModal({
|
|||||||
value={releaseYear}
|
value={releaseYear}
|
||||||
onChange={(e) => setReleaseYear(e.target.value)}
|
onChange={(e) => setReleaseYear(e.target.value)}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|||||||
@@ -85,17 +85,17 @@ export function PokemonFormModal({
|
|||||||
const tabClass = (tab: Tab) =>
|
const tabClass = (tab: Tab) =>
|
||||||
`px-3 py-1.5 text-sm font-medium rounded-t-md border-b-2 transition-colors ${
|
`px-3 py-1.5 text-sm font-medium rounded-t-md border-b-2 transition-colors ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'border-accent-400 text-accent-400'
|
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||||
: 'border-transparent text-text-tertiary hover:text-text-secondary'
|
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||||
}`
|
}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-border-default shrink-0">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
|
||||||
<h2 className="text-lg font-semibold">{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}</h2>
|
<h2 className="text-lg font-semibold">{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}</h2>
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<div className="flex gap-1 mt-2">
|
<div className="flex gap-1 mt-2">
|
||||||
@@ -125,7 +125,7 @@ export function PokemonFormModal({
|
|||||||
min={1}
|
min={1}
|
||||||
value={pokeapiId}
|
value={pokeapiId}
|
||||||
onChange={(e) => setPokeapiId(e.target.value)}
|
onChange={(e) => setPokeapiId(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -136,7 +136,7 @@ export function PokemonFormModal({
|
|||||||
min={1}
|
min={1}
|
||||||
value={nationalDex}
|
value={nationalDex}
|
||||||
onChange={(e) => setNationalDex(e.target.value)}
|
onChange={(e) => setNationalDex(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -146,7 +146,7 @@ export function PokemonFormModal({
|
|||||||
required
|
required
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -157,7 +157,7 @@ export function PokemonFormModal({
|
|||||||
value={types}
|
value={types}
|
||||||
onChange={(e) => setTypes(e.target.value)}
|
onChange={(e) => setTypes(e.target.value)}
|
||||||
placeholder="Fire, Flying"
|
placeholder="Fire, Flying"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -167,11 +167,11 @@ export function PokemonFormModal({
|
|||||||
value={spriteUrl}
|
value={spriteUrl}
|
||||||
onChange={(e) => setSpriteUrl(e.target.value)}
|
onChange={(e) => setSpriteUrl(e.target.value)}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-border-default flex items-center gap-3 shrink-0">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-3 shrink-0">
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -184,7 +184,7 @@ export function PokemonFormModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => setConfirmingDelete(false)}
|
onBlur={() => setConfirmingDelete(false)}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md text-status-failed border border-red-600 hover:bg-red-900/20 disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
@@ -193,14 +193,14 @@ export function PokemonFormModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500 disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Saving...' : 'Save'}
|
{isSubmitting ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
@@ -212,9 +212,11 @@ export function PokemonFormModal({
|
|||||||
{activeTab === 'evolutions' && (
|
{activeTab === 'evolutions' && (
|
||||||
<div className="flex flex-col min-h-0 flex-1">
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
<div className="px-6 py-4 overflow-y-auto">
|
<div className="px-6 py-4 overflow-y-auto">
|
||||||
{evolutionsLoading && <p className="text-sm text-text-tertiary">Loading...</p>}
|
{evolutionsLoading && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||||
|
)}
|
||||||
{!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && (
|
{!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && (
|
||||||
<p className="text-sm text-text-tertiary">No evolutions</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions</p>
|
||||||
)}
|
)}
|
||||||
{!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && (
|
{!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -223,20 +225,22 @@ export function PokemonFormModal({
|
|||||||
key={evo.id}
|
key={evo.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditingEvolution(evo)}
|
onClick={() => setEditingEvolution(evo)}
|
||||||
className="w-full text-left text-sm text-text-tertiary hover:bg-surface-2 rounded px-2 py-1.5 -mx-2 transition-colors"
|
className="w-full text-left text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-2 py-1.5 -mx-2 transition-colors"
|
||||||
>
|
>
|
||||||
{evo.fromPokemon.name} → {evo.toPokemon.name}{' '}
|
{evo.fromPokemon.name} → {evo.toPokemon.name}{' '}
|
||||||
<span className="text-text-muted">({formatEvolutionMethod(evo)})</span>
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
|
({formatEvolutionMethod(evo)})
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-border-default flex justify-end shrink-0">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
@@ -248,30 +252,32 @@ export function PokemonFormModal({
|
|||||||
{activeTab === 'encounters' && (
|
{activeTab === 'encounters' && (
|
||||||
<div className="flex flex-col min-h-0 flex-1">
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
<div className="px-6 py-4 overflow-y-auto">
|
<div className="px-6 py-4 overflow-y-auto">
|
||||||
{encountersLoading && <p className="text-sm text-text-tertiary">Loading...</p>}
|
{encountersLoading && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||||
|
)}
|
||||||
{!encountersLoading && (!encounterLocations || encounterLocations.length === 0) && (
|
{!encountersLoading && (!encounterLocations || encounterLocations.length === 0) && (
|
||||||
<p className="text-sm text-text-tertiary">No encounters</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">No encounters</p>
|
||||||
)}
|
)}
|
||||||
{!encountersLoading && encounterLocations && encounterLocations.length > 0 && (
|
{!encountersLoading && encounterLocations && encounterLocations.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{encounterLocations.map((game) => (
|
{encounterLocations.map((game) => (
|
||||||
<div key={game.gameId}>
|
<div key={game.gameId}>
|
||||||
<div className="text-sm font-medium text-text-secondary mb-1">
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
{game.gameName}
|
{game.gameName}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5 pl-2">
|
<div className="space-y-0.5 pl-2">
|
||||||
{game.encounters.map((enc, i) => (
|
{game.encounters.map((enc, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="text-sm text-text-tertiary flex items-center gap-1"
|
className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
|
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
|
||||||
className="text-text-link hover:underline"
|
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
>
|
>
|
||||||
{enc.routeName}
|
{enc.routeName}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-text-muted">
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
— {enc.encounterMethod}, Lv. {enc.minLevel}–{enc.maxLevel}
|
— {enc.encounterMethod}, Lv. {enc.minLevel}–{enc.maxLevel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,11 +288,11 @@ export function PokemonFormModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-border-default flex justify-end shrink-0">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ export function PokemonSelector({
|
|||||||
}}
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
placeholder="Search pokemon..."
|
placeholder="Search pokemon..."
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
{selectedId && <input type="hidden" name={label} value={selectedId} required />}
|
{selectedId && <input type="hidden" name={label} value={selectedId} required />}
|
||||||
{open && pokemon.length > 0 && (
|
{open && pokemon.length > 0 && (
|
||||||
<ul className="absolute z-10 mt-1 w-full bg-surface-1 border border-border-default rounded-md shadow-lg max-h-48 overflow-y-auto">
|
<ul className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||||
{pokemon.map((p) => (
|
{pokemon.map((p) => (
|
||||||
<li
|
<li
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@@ -57,8 +57,8 @@ export function PokemonSelector({
|
|||||||
setSearch(p.name)
|
setSearch(p.name)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
className={`px-3 py-2 cursor-pointer hover:bg-surface-2 text-sm flex items-center gap-2 ${
|
className={`px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2 ${
|
||||||
p.id === selectedId ? 'bg-accent-900/30' : ''
|
p.id === selectedId ? 'bg-blue-50 dark:bg-blue-900/30' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p.spriteUrl && <img src={p.spriteUrl} alt="" className="w-6 h-6" />}
|
{p.spriteUrl && <img src={p.spriteUrl} alt="" className="w-6 h-6" />}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function RouteEncounterFormModal({
|
|||||||
setSelectedMethod(e.target.value)
|
setSelectedMethod(e.target.value)
|
||||||
if (e.target.value !== 'other') setCustomMethod('')
|
if (e.target.value !== 'other') setCustomMethod('')
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
>
|
>
|
||||||
<option value="">Select method...</option>
|
<option value="">Select method...</option>
|
||||||
{METHOD_ORDER.map((m) => (
|
{METHOD_ORDER.map((m) => (
|
||||||
@@ -108,7 +108,7 @@ export function RouteEncounterFormModal({
|
|||||||
value={customMethod}
|
value={customMethod}
|
||||||
onChange={(e) => setCustomMethod(e.target.value)}
|
onChange={(e) => setCustomMethod(e.target.value)}
|
||||||
placeholder="Custom method name"
|
placeholder="Custom method name"
|
||||||
className="w-full mt-2 px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full mt-2 px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +121,7 @@ export function RouteEncounterFormModal({
|
|||||||
max={100}
|
max={100}
|
||||||
value={encounterRate}
|
value={encounterRate}
|
||||||
onChange={(e) => setEncounterRate(e.target.value)}
|
onChange={(e) => setEncounterRate(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -134,7 +134,7 @@ export function RouteEncounterFormModal({
|
|||||||
max={100}
|
max={100}
|
||||||
value={minLevel}
|
value={minLevel}
|
||||||
onChange={(e) => setMinLevel(e.target.value)}
|
onChange={(e) => setMinLevel(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -146,7 +146,7 @@ export function RouteEncounterFormModal({
|
|||||||
max={100}
|
max={100}
|
||||||
value={maxLevel}
|
value={maxLevel}
|
||||||
onChange={(e) => setMaxLevel(e.target.value)}
|
onChange={(e) => setMaxLevel(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function RouteFormModal({
|
|||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
detailUrl ? (
|
detailUrl ? (
|
||||||
<Link to={detailUrl} className="text-sm text-text-link hover:underline">
|
<Link to={detailUrl} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
View Encounters
|
View Encounters
|
||||||
</Link>
|
</Link>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -62,7 +62,7 @@ export function RouteFormModal({
|
|||||||
required
|
required
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -73,7 +73,7 @@ export function RouteFormModal({
|
|||||||
min={0}
|
min={0}
|
||||||
value={order}
|
value={order}
|
||||||
onChange={(e) => setOrder(e.target.value)}
|
onChange={(e) => setOrder(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -84,9 +84,9 @@ export function RouteFormModal({
|
|||||||
value={pinwheelZone}
|
value={pinwheelZone}
|
||||||
onChange={(e) => setPinwheelZone(e.target.value)}
|
onChange={(e) => setPinwheelZone(e.target.value)}
|
||||||
placeholder="None"
|
placeholder="None"
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-text-tertiary mt-1">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Routes in the same zone share an encounter when the Pinwheel Clause is active
|
Routes in the same zone share an encounter when the Pinwheel Clause is active
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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'] })
|
|
||||||
})
|
|
||||||
})
|
|
||||||