From 995aa43e42f7f7ebbccdf3e7813f3952fe8c28b5 Mon Sep 17 00:00:00 2001 From: CJ Rivas Date: Fri, 21 Aug 2026 08:40:53 -0400 Subject: [PATCH 01/14] PMX: Drop always-true clause from `encountersToEntries` - `METHOD_MAP` has no entry mapping to `'special'` - so `method === 'special'` already implies `!METHOD_MAP[slug]` - second clause could never be false when the first was true --- src/obtain/pokeapi.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/obtain/pokeapi.ts b/src/obtain/pokeapi.ts index b9982ba..80008e3 100644 --- a/src/obtain/pokeapi.ts +++ b/src/obtain/pokeapi.ts @@ -58,7 +58,9 @@ export function encountersToEntries(areas: ApiEncounterArea[]): Map c.name).sort(); if (ROD_METHODS.has(d.method.name)) conditions.unshift(d.method.name); - if (method === 'special' && !METHOD_MAP[d.method.name]) conditions.unshift(d.method.name); + // Nothing in `METHOD_MAP` maps to 'special', so this is exactly the + // unmapped case — keep the raw slug so the UI can still name it. + if (method === 'special') conditions.unshift(d.method.name); const key = `${location}|${method}|${conditions.join(',')}`; const prev = slots.get(key); if (prev) { From 8f8a2185e45ca3ee15601a703efff502e0cb337e Mon Sep 17 00:00:00 2001 From: CJ Rivas Date: Fri, 21 Aug 2026 08:41:41 -0400 Subject: [PATCH 02/14] PMX: Make `isObtainFile` validate the shape its consumers index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - derive `ObtainMethod` from a new `OBTAIN_METHODS` list — one vocabulary - guard now checks `games[].gen`/`versionGroup`/`versions`/`entries` - rejects entries whose `method` is not a known method - previously `ObtainMethods.tsx` indexed `METHOD_LABEL` on unvalidated strings - verified all 1351 files in `public/obtain/` still pass --- src/__tests__/obtainTypes.test.ts | 31 ++++++++++++++++++ src/obtain/types.ts | 54 +++++++++++++++++++++++-------- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/__tests__/obtainTypes.test.ts b/src/__tests__/obtainTypes.test.ts index 4a36dee..71056d2 100644 --- a/src/__tests__/obtainTypes.test.ts +++ b/src/__tests__/obtainTypes.test.ts @@ -33,4 +33,35 @@ describe('isObtainFile', () => { expect(isObtainFile(null)).toBe(false); expect(isObtainFile({ pokemonId: 'x' })).toBe(false); }); + + it('accepts a file whose games carry known methods', () => { + const game = { + gen: 1, + versionGroup: 'red-blue', + versions: ['red'], + entries: [{ method: 'grass', location: 'Route 1' }], + }; + expect(isObtainFile({ pokemonId: 25, name: 'pikachu', breeding: null, games: [game] })).toBe( + true, + ); + }); + + it('rejects a file whose entries carry an unknown method', () => { + const game = { + gen: 1, + versionGroup: 'red-blue', + versions: ['red'], + entries: [{ method: 'teleported-in', location: 'Route 1' }], + }; + expect(isObtainFile({ pokemonId: 25, name: 'pikachu', breeding: null, games: [game] })).toBe( + false, + ); + }); + + it('rejects a file whose games are missing required fields', () => { + const game = { versionGroup: 'red-blue', entries: [] }; + expect(isObtainFile({ pokemonId: 25, name: 'pikachu', breeding: null, games: [game] })).toBe( + false, + ); + }); }); diff --git a/src/obtain/types.ts b/src/obtain/types.ts index c130e3a..c652495 100644 --- a/src/obtain/types.ts +++ b/src/obtain/types.ts @@ -1,19 +1,24 @@ import { GENERATIONS } from '@/generations'; -export type ObtainMethod = - | 'grass' - | 'surf' - | 'fish' - | 'cave' - | 'wild' - | 'static' - | 'gift' - | 'trade' - | 'egg' - | 'evolve' - | 'transfer' - | 'unavailable' - | 'special'; +// Single source for the method vocabulary — the union, the runtime guard, and +// the label/color tables in `@/obtain/labels` all derive from this list. +export const OBTAIN_METHODS = [ + 'grass', + 'surf', + 'fish', + 'cave', + 'wild', + 'static', + 'gift', + 'trade', + 'egg', + 'evolve', + 'transfer', + 'unavailable', + 'special', +] as const; + +export type ObtainMethod = (typeof OBTAIN_METHODS)[number]; export interface ObtainEntry { method: ObtainMethod; @@ -86,6 +91,26 @@ export const GROUP_GEN: Record = Object.fromEntries( GENERATIONS.flatMap((g) => g.versionGroups.map((vg) => [vg, g.num])), ); +const METHOD_SET: ReadonlySet = new Set(OBTAIN_METHODS); + +function isObtainEntry(v: unknown): v is ObtainEntry { + if (typeof v !== 'object' || v === null) return false; + const e: Record = { ...v }; + return typeof e.method === 'string' && METHOD_SET.has(e.method); +} + +function isObtainGame(v: unknown): v is ObtainGame { + if (typeof v !== 'object' || v === null) return false; + const g: Record = { ...v }; + return ( + typeof g.gen === 'number' && + typeof g.versionGroup === 'string' && + Array.isArray(g.versions) && + Array.isArray(g.entries) && + g.entries.every(isObtainEntry) + ); +} + export function isObtainFile(v: unknown): v is ObtainFile { if (typeof v !== 'object' || v === null) return false; const o: Record = { ...v }; @@ -93,6 +118,7 @@ export function isObtainFile(v: unknown): v is ObtainFile { typeof o.pokemonId === 'number' && typeof o.name === 'string' && Array.isArray(o.games) && + o.games.every(isObtainGame) && 'breeding' in o ); } From 2644b211ce6c13b65c3d3bd6d5c0fa98d88b4c9f Mon Sep 17 00:00:00 2001 From: CJ Rivas Date: Fri, 21 Aug 2026 08:44:18 -0400 Subject: [PATCH 03/14] PMX: Give the region model one owner in `generations.ts` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new `REGIONS` + `REGION_OF_VERSION_GROUP` map every version group to its setting - `GAMES_BY_REGION` now derives from `REGIONS` instead of restating it - `ObtainMethods.tsx` drops `VG_REGION`, `REGION_ORDER`, `REGION_LABELS` - kills the `getGen(gen).region` fallback, which was wrong for every remake and was the reason `VG_REGION` had to exist as a patch table - Hisui's note comes from `REGIONS`, not a one-entry label override - rename `crt-obtain-gen`/`crt-obtain-gen-toggle` — the rows are regions, not gens - new `regions.test.ts` pins coverage and ordering against `GENERATIONS` --- src/__tests__/regions.test.ts | 40 +++++++++++++++++++++++ src/components/ObtainMethods.tsx | 55 ++++++++------------------------ src/generations.ts | 36 +++++++++++++++++++++ src/styles/crt.css | 2 +- src/trainers.ts | 48 +++++++++++++++++----------- 5 files changed, 120 insertions(+), 61 deletions(-) create mode 100644 src/__tests__/regions.test.ts diff --git a/src/__tests__/regions.test.ts b/src/__tests__/regions.test.ts new file mode 100644 index 0000000..39dc933 --- /dev/null +++ b/src/__tests__/regions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { GENERATIONS, REGIONS, REGION_OF_VERSION_GROUP } from '@/generations'; +import { GAMES_BY_REGION, GAME_LABELS } from '@/trainers'; + +describe('REGIONS', () => { + it('assigns every version group in GENERATIONS to exactly one region', () => { + const assigned = REGIONS.flatMap((r) => r.versionGroups); + expect(new Set(assigned).size).toBe(assigned.length); + for (const g of GENERATIONS) { + for (const vg of g.versionGroups) { + expect(REGION_OF_VERSION_GROUP[vg], vg).toBeDefined(); + } + } + }); + + it('lists no version group that GENERATIONS does not know about', () => { + const known = new Set(GENERATIONS.flatMap((g) => g.versionGroups)); + for (const vg of REGIONS.flatMap((r) => r.versionGroups)) { + expect(known.has(vg), vg).toBe(true); + } + }); + + it('places Hisui directly after Sinnoh', () => { + const names = REGIONS.map((r) => r.name); + expect(names.indexOf('Hisui')).toBe(names.indexOf('Sinnoh') + 1); + expect(REGIONS.find((r) => r.name === 'Hisui')?.note).toBe('Ancient Sinnoh'); + }); +}); + +describe('GAMES_BY_REGION', () => { + it('derives from REGIONS in the same order', () => { + expect(GAMES_BY_REGION.map((r) => r.region)).toEqual(REGIONS.map((r) => r.name)); + }); + + it('covers every GameId exactly once', () => { + const games = GAMES_BY_REGION.flatMap((r) => r.games); + expect(new Set(games).size).toBe(games.length); + expect(games.sort()).toEqual(Object.keys(GAME_LABELS).sort()); + }); +}); diff --git a/src/components/ObtainMethods.tsx b/src/components/ObtainMethods.tsx index 7703e30..7ec0b5c 100644 --- a/src/components/ObtainMethods.tsx +++ b/src/components/ObtainMethods.tsx @@ -1,5 +1,5 @@ import { useState, type SyntheticEvent } from 'react'; -import { getGen } from '@/generations'; +import { getGen, REGIONS, REGION_OF_VERSION_GROUP } from '@/generations'; import type { ObtainEntry, ObtainFile, ObtainGame } from '@/obtain/types'; import { TYPE_COLORS } from '@/typeChart'; @@ -46,44 +46,15 @@ const METHOD_COLOR: Record = { special: TYPE_COLORS.steel, }; -// Version groups whose home region differs from their generation's default -// region (see `generations.ts`) — remakes, spin-offs, and DLC pairs that -// don't share their gen-mates' setting. -const VG_REGION: Record = { - 'firered-leafgreen': 'Kanto', - 'heartgold-soulsilver': 'Johto', - 'omega-ruby-alpha-sapphire': 'Hoenn', - 'lets-go-pikachu-lets-go-eevee': 'Kanto', - 'brilliant-diamond-shining-pearl': 'Sinnoh', - 'legends-arceus': 'Hisui', -}; - // Groups are by REGION, not generation — BDSP belongs with the other Sinnoh -// games regardless of when it was released. -// Hisui sits directly under Sinnoh (it's the same land, ancient era). -const REGION_ORDER = [ - 'Kanto', - 'Johto', - 'Hoenn', - 'Sinnoh', - 'Hisui', - 'Unova', - 'Kalos', - 'Alola', - 'Galar', - 'Paldea', -]; - -const REGION_LABELS: Record = { - Hisui: 'HISUI · ANCIENT SINNOH', -}; - -function regionLabel(region: string): string { - return REGION_LABELS[region] ?? region.toUpperCase(); +// games regardless of when it shipped, and Hisui sits under Sinnoh. Both facts +// live in the canonical `REGIONS` model. +function regionLabel({ name, note }: { name: string; note?: string }): string { + return note ? `${name.toUpperCase()} · ${note.toUpperCase()}` : name.toUpperCase(); } function regionOf(game: ObtainGame): string { - return VG_REGION[game.versionGroup] ?? getGen(game.gen).region; + return REGION_OF_VERSION_GROUP[game.versionGroup]; } function prettyVersions(versions: string[]): string { @@ -362,12 +333,12 @@ export default function ObtainMethods({ data, loading, error, currentGen, enable } if (error || !data) return
OBTAIN DATA UNAVAILABLE
; - const regions = REGION_ORDER.filter((r) => data.games.some((g) => regionOf(g) === r)); + const regions = REGIONS.filter((r) => data.games.some((g) => regionOf(g) === r.name)); // Regional forms can carry a `currentGen` whose home region the file's // games never reach (e.g. Alolan Vulpix is gen 1, but its file starts in // Alola) — default to the first region actually present. const homeRegion = getGen(currentGen).region; - const defaultRegion = regions.includes(homeRegion) ? homeRegion : regions[0]; + const defaultRegion = regions.some((r) => r.name === homeRegion) ? homeRegion : regions[0]?.name; const expanded = userExpanded ?? new Set(defaultRegion === undefined ? [] : [defaultRegion]); const toggle = (region: string) => setUserExpanded((prev) => { @@ -389,15 +360,15 @@ export default function ObtainMethods({ data, loading, error, currentGen, enable )} {regions.map((region) => { - const open = expanded.has(region); - const gamesInRegion = data.games.filter((g) => regionOf(g) === region); + const open = expanded.has(region.name); + const gamesInRegion = data.games.filter((g) => regionOf(g) === region.name); return ( -
+
diff --git a/src/generations.ts b/src/generations.ts index 9ae78b5..4cab174 100644 --- a/src/generations.ts +++ b/src/generations.ts @@ -25,3 +25,39 @@ export const GENERATIONS: GenerationMeta[] = [ export function getGen(num: number): GenerationMeta { return GENERATIONS.find((g) => g.num === num) ?? GENERATIONS[7]; } + +export interface RegionMeta { + name: string; + /** Shown beside the name when the region needs context ("Ancient Sinnoh"). */ + note?: string; + /** Version groups set in this region, in release order. */ + versionGroups: string[]; +} + +/** + * The canonical region model: every version group grouped by the region it is + * SET in, regions in first-appearance order. This is deliberately not the same + * as `GenerationMeta.region` — a remake belongs to its setting, not to the + * generation that shipped it (BDSP is Sinnoh, not Galar). Hisui is its own + * region placed right after Sinnoh, since it's Sinnoh's ancient past. + * + * `GAMES_BY_REGION` (`trainers.ts`) and the obtain panel both derive from this + * — `obtainTypes.test.ts` pins the coverage against `GENERATIONS`. + */ +// prettier-ignore +export const REGIONS: RegionMeta[] = [ + { name: 'Kanto', versionGroups: ['red-blue', 'yellow', 'firered-leafgreen', 'lets-go-pikachu-lets-go-eevee'] }, + { name: 'Johto', versionGroups: ['gold-silver', 'crystal', 'heartgold-soulsilver'] }, + { name: 'Hoenn', versionGroups: ['ruby-sapphire', 'emerald', 'omega-ruby-alpha-sapphire'] }, + { name: 'Sinnoh', versionGroups: ['diamond-pearl', 'platinum', 'brilliant-diamond-shining-pearl'] }, + { name: 'Hisui', note: 'Ancient Sinnoh', versionGroups: ['legends-arceus'] }, + { name: 'Unova', versionGroups: ['black-white', 'black-2-white-2'] }, + { name: 'Kalos', versionGroups: ['x-y'] }, + { name: 'Alola', versionGroups: ['sun-moon', 'ultra-sun-ultra-moon'] }, + { name: 'Galar', versionGroups: ['sword-shield'] }, + { name: 'Paldea', versionGroups: ['scarlet-violet'] }, +]; + +export const REGION_OF_VERSION_GROUP: Record = Object.fromEntries( + REGIONS.flatMap((r) => r.versionGroups.map((vg) => [vg, r.name])), +); diff --git a/src/styles/crt.css b/src/styles/crt.css index 068c048..eedc849 100644 --- a/src/styles/crt.css +++ b/src/styles/crt.css @@ -1710,7 +1710,7 @@ button.crt-type:hover, font-size: 0.75rem; margin-bottom: 8px; } -.crt-obtain-gen-toggle { +.crt-obtain-region-toggle { background: none; border: none; color: var(--primary); diff --git a/src/trainers.ts b/src/trainers.ts index e3fa227..a9b8fe4 100644 --- a/src/trainers.ts +++ b/src/trainers.ts @@ -1,3 +1,5 @@ +import { REGIONS } from '@/generations'; + // Trainer browser data — types + curated fixture set. // // All fields below are the canonical contract. Other modules (`TrainerGrid`, @@ -51,26 +53,36 @@ export const GAME_LABELS: Record = { 'scarlet-violet': 'Scarlet / Violet', }; +const GAME_IDS: ReadonlySet = new Set(Object.keys(GAME_LABELS)); + +function isGameId(v: string): v is GameId { + return GAME_IDS.has(v); +} + +// Every `GameId` is already its PokéAPI version-group slug except the Let's Go +// pair, which the trainer browser shortened before the obtain dataset existed. +const VERSION_GROUP_GAME: Record = { + 'lets-go-pikachu-lets-go-eevee': 'lets-go', +}; + +function gameIdForVersionGroup(vg: string): GameId | null { + const aliased = VERSION_GROUP_GAME[vg]; + if (aliased) return aliased; + return isGameId(vg) ? vg : null; +} + /** - * Games grouped by the region they take place in — regions in first-appearance - * order, games within a region in release order. Hisui gets its own group - * (with a note) placed right after Sinnoh, since it's Sinnoh's ancient past. + * Games grouped by the region they take place in. Derived from the canonical + * `REGIONS` model in `generations.ts` so the trainer browser, the teams + * browser, and the obtain panel can never disagree on region order. */ -export const GAMES_BY_REGION: { region: string; note?: string; games: GameId[] }[] = [ - { region: 'Kanto', games: ['red-blue', 'yellow', 'firered-leafgreen', 'lets-go'] }, - { region: 'Johto', games: ['gold-silver', 'crystal', 'heartgold-soulsilver'] }, - { region: 'Hoenn', games: ['ruby-sapphire', 'emerald', 'omega-ruby-alpha-sapphire'] }, - { - region: 'Sinnoh', - games: ['diamond-pearl', 'platinum', 'brilliant-diamond-shining-pearl'], - }, - { region: 'Hisui', note: 'Ancient Sinnoh', games: ['legends-arceus'] }, - { region: 'Unova', games: ['black-white', 'black-2-white-2'] }, - { region: 'Kalos', games: ['x-y'] }, - { region: 'Alola', games: ['sun-moon', 'ultra-sun-ultra-moon'] }, - { region: 'Galar', games: ['sword-shield'] }, - { region: 'Paldea', games: ['scarlet-violet'] }, -]; +export const GAMES_BY_REGION: { region: string; note?: string; games: GameId[] }[] = REGIONS.map( + ({ name, note, versionGroups }) => ({ + region: name, + note, + games: versionGroups.map(gameIdForVersionGroup).filter((g): g is GameId => g !== null), + }), +).filter(({ games }) => games.length > 0); /** Best available portrait — animated APNG when the trainer has one, else the static VS sprite. */ export function trainerPortraitUrl(t: Trainer): string | undefined { From ba954e31cf748524840f1719cd3fe1a87a6afabb Mon Sep 17 00:00:00 2001 From: CJ Rivas Date: Fri, 21 Aug 2026 08:47:33 -0400 Subject: [PATCH 04/14] PMX: Collapse the obtain loading contract into one status union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `useObtainData` returns `ObtainState` — `idle` / `loading` / `error` / `ready` - the enabled-but-not-yet-started frame now reports `loading` from the hook - drops the compensating `loading || (enabled && !data && !error)` in the view - drops the stale-id reconciliation — `settled.id` scopes failures to their id - `ObtainMethods` takes `state` + `currentGen`, down from five props - `enabled` no longer passed twice from `PokemonCard.tsx` - ready branch split into `ObtainRegions` so the data is non-null by type - loading/idle tests move to `useObtainData.test.ts` where the inputs live --- src/__tests__/ObtainMethods.test.tsx | 42 ++++-------- src/__tests__/useObtainData.test.ts | 42 ++++++++++-- src/components/ObtainMethods.tsx | 38 +++++------ src/components/PokemonCard.tsx | 11 +--- src/hooks/useObtainData.ts | 95 ++++++++++++++-------------- 5 files changed, 120 insertions(+), 108 deletions(-) diff --git a/src/__tests__/ObtainMethods.test.tsx b/src/__tests__/ObtainMethods.test.tsx index 91c0d7a..b4767d0 100644 --- a/src/__tests__/ObtainMethods.test.tsx +++ b/src/__tests__/ObtainMethods.test.tsx @@ -2,8 +2,11 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import ObtainMethods from '@/components/ObtainMethods'; +import type { ObtainState } from '@/hooks/useObtainData'; import type { ObtainFile } from '@/obtain/types'; +const ready = (file: ObtainFile): ObtainState => ({ status: 'ready', file }); + const FILE: ObtainFile = { pokemonId: 25, name: 'pikachu', @@ -52,9 +55,7 @@ const FILE: ObtainFile = { describe('ObtainMethods', () => { it('shows breeding info and the current gen expanded', () => { - const { container } = render( - , - ); + const { container } = render(); expect(screen.getByText(/FIELD\/FAIRY/)).toBeInTheDocument(); expect(screen.getByText(/2,560 STEPS/)).toBeInTheDocument(); // Gen 1 expanded: entries visible @@ -72,7 +73,7 @@ describe('ObtainMethods', () => { }); it('gives a weather condition chip its icon', () => { - render(); + render(); // weather-intense-sun renders with the sun-specific weather glyph // (U+2600 + U+FE0E text-presentation selector) const sunIcon = '☀︎'; @@ -91,9 +92,7 @@ describe('ObtainMethods', () => { }, ], }; - const { container } = render( - , - ); + const { container } = render(); // The legend also glosses OTHER for `special`, so scope to the entry chip. const entryTag = container.querySelector('.crt-obtain-entries .crt-obtain-tag'); expect(entryTag).toHaveTextContent('OTHER'); @@ -101,7 +100,7 @@ describe('ObtainMethods', () => { }); it('renders a collapsible legend that expands to show glosses', async () => { - render(); + render(); const summary = screen.getByText(/LEGEND/); expect(summary).not.toHaveTextContent('?'); const details = summary.closest('details'); @@ -139,7 +138,7 @@ describe('ObtainMethods', () => { }, ], }; - render(); + render(); // Region tabs, no generation numbers; Hisui is its own tab right after // Sinnoh, labeled as ancient Sinnoh expect(screen.getByRole('button', { name: /GALAR/ })).toBeInTheDocument(); @@ -157,7 +156,7 @@ describe('ObtainMethods', () => { }); it('collapses other gens until toggled', async () => { - render(); + render(); expect(screen.queryByText(/Trade\/migrate/)).not.toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: /UNOVA/ })); expect(screen.getByText(/Trade\/migrate/)).toBeInTheDocument(); @@ -165,32 +164,19 @@ describe('ObtainMethods', () => { it('renders the unavailable state on error', () => { render( - , + , ); expect(screen.getByText('OBTAIN DATA UNAVAILABLE')).toBeInTheDocument(); }); it('renders a loading line', () => { - render(); - expect(screen.getByText(/LOADING/)).toBeInTheDocument(); - }); - - it('shows loading, not unavailable, the instant it is enabled but data has not arrived', () => { - render(); + render(); expect(screen.getByText(/LOADING/)).toBeInTheDocument(); expect(screen.queryByText('OBTAIN DATA UNAVAILABLE')).not.toBeInTheDocument(); }); - it('stays unavailable (not loading) when disabled with no data', () => { - render( - , - ); + it('renders nothing useful before anything has been asked for', () => { + render(); expect(screen.getByText('OBTAIN DATA UNAVAILABLE')).toBeInTheDocument(); }); @@ -210,7 +196,7 @@ describe('ObtainMethods', () => { }; // Alolan Vulpix: currentGen is 1 (its national dex gen) but the file // only has gen-7-and-later games. - render(); + render(); expect(screen.getByText('Mount Lanakila')).toBeInTheDocument(); }); }); diff --git a/src/__tests__/useObtainData.test.ts b/src/__tests__/useObtainData.test.ts index f593bda..083bf45 100644 --- a/src/__tests__/useObtainData.test.ts +++ b/src/__tests__/useObtainData.test.ts @@ -16,28 +16,58 @@ describe('useObtainData', () => { it('does not fetch until enabled', () => { const spy = vi.fn(); vi.stubGlobal('fetch', spy); - renderHook(() => useObtainData(25, false)); + const { result } = renderHook(() => useObtainData(25, false)); + expect(result.current.status).toBe('idle'); expect(spy).not.toHaveBeenCalled(); }); + it('reports loading on the very render that enables it', () => { + vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {}))); + const { result } = renderHook(() => useObtainData(4242, true)); + // The fetch effect has not run yet — this must not read as an error state. + expect(result.current.status).toBe('loading'); + }); + it('fetches once enabled and caches per id', async () => { const spy = vi.fn().mockResolvedValue(okResponse(FILE)); vi.stubGlobal('fetch', spy); const { result } = renderHook(() => useObtainData(25, true)); - await waitFor(() => expect(result.current.data).not.toBeNull()); - expect(result.current.data?.name).toBe('pikachu'); + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(result.current.status === 'ready' && result.current.file.name).toBe('pikachu'); expect(spy).toHaveBeenCalledTimes(1); expect(String(spy.mock.calls[0][0])).toContain('obtain/25.json'); const again = renderHook(() => useObtainData(25, true)); - await waitFor(() => expect(again.result.current.data).not.toBeNull()); + await waitFor(() => expect(again.result.current.status).toBe('ready')); expect(spy).toHaveBeenCalledTimes(1); // served from module cache }); + it('serves a cached file even while disabled', () => { + vi.stubGlobal('fetch', vi.fn()); + // Id 25 was cached by the previous test. + const { result } = renderHook(() => useObtainData(25, false)); + expect(result.current.status).toBe('ready'); + }); + it('reports an error on 404', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 404 }))); const { result } = renderHook(() => useObtainData(31337, true)); - await waitFor(() => expect(result.current.error).not.toBeNull()); - expect(result.current.data).toBeNull(); + await waitFor(() => expect(result.current.status).toBe('error')); + expect(result.current.status === 'error' && result.current.message).toContain('404'); + }); + + it('never surfaces one id error under another id', async () => { + const spy = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 404 })) + .mockResolvedValue(okResponse({ ...FILE, pokemonId: 77, name: 'seaking' })); + vi.stubGlobal('fetch', spy); + const { result, rerender } = renderHook(({ id }) => useObtainData(id, true), { + initialProps: { id: 6001 }, + }); + await waitFor(() => expect(result.current.status).toBe('error')); + rerender({ id: 6002 }); + expect(result.current.status).not.toBe('error'); + await waitFor(() => expect(result.current.status).toBe('ready')); }); }); diff --git a/src/components/ObtainMethods.tsx b/src/components/ObtainMethods.tsx index 7ec0b5c..1322f1f 100644 --- a/src/components/ObtainMethods.tsx +++ b/src/components/ObtainMethods.tsx @@ -1,15 +1,12 @@ import { useState, type SyntheticEvent } from 'react'; import { getGen, REGIONS, REGION_OF_VERSION_GROUP } from '@/generations'; +import type { ObtainState } from '@/hooks/useObtainData'; import type { ObtainEntry, ObtainFile, ObtainGame } from '@/obtain/types'; import { TYPE_COLORS } from '@/typeChart'; interface Props { - data: ObtainFile | null; - loading: boolean; - error: string | null; + state: ObtainState; currentGen: number; - /** Whether the HOW TO OBTAIN section is open (data fetch is gated on this). */ - enabled: boolean; } const METHOD_LABEL: Record = { @@ -323,17 +320,10 @@ function Legend() { ); } -export default function ObtainMethods({ data, loading, error, currentGen, enabled }: Props) { +function ObtainRegions({ file, currentGen }: { file: ObtainFile; currentGen: number }) { const [userExpanded, setUserExpanded] = useState | null>(null); - // Between `enabled` flipping true and the fetch effect's first state update, - // `loading` is still false — treat that gap as loading too so there's no - // one-frame "UNAVAILABLE" flash before the request even starts. - if (loading || (enabled && !data && !error)) { - return
LOADING OBTAIN DATA…
; - } - if (error || !data) return
OBTAIN DATA UNAVAILABLE
; - const regions = REGIONS.filter((r) => data.games.some((g) => regionOf(g) === r.name)); + const regions = REGIONS.filter((r) => file.games.some((g) => regionOf(g) === r.name)); // Regional forms can carry a `currentGen` whose home region the file's // games never reach (e.g. Alolan Vulpix is gen 1, but its file starts in // Alola) — default to the first region actually present. @@ -350,18 +340,18 @@ export default function ObtainMethods({ data, loading, error, currentGen, enable return (
- {data.breeding && ( + {file.breeding && (
- EGG GROUPS: {data.breeding.eggGroups.map((g) => g.toUpperCase()).join('/')} - {data.breeding.breedable - ? ` · HATCH: ${data.breeding.hatchCycles} CYCLES (${data.breeding.steps.toLocaleString('en-US')} STEPS)` + EGG GROUPS: {file.breeding.eggGroups.map((g) => g.toUpperCase()).join('/')} + {file.breeding.breedable + ? ` · HATCH: ${file.breeding.hatchCycles} CYCLES (${file.breeding.steps.toLocaleString('en-US')} STEPS)` : ' · CANNOT BREED'}
)} {regions.map((region) => { const open = expanded.has(region.name); - const gamesInRegion = data.games.filter((g) => regionOf(g) === region.name); + const gamesInRegion = file.games.filter((g) => regionOf(g) === region.name); return (