Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.6.0
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=24.6.0",
"npm": ">=11.0.0"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
Expand Down
75 changes: 74 additions & 1 deletion src/api/pokemon.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getPokemonByID } from "./pokemon";
import { getPokemonByID, getPokemonIdsByType } from "./pokemon";
import { DUMMY_DATA } from "../utils/testData";

// Nothing here is allowed to reach the network, so fetch is replaced
Expand Down Expand Up @@ -45,6 +45,21 @@ describe("getPokemonByID", () => {
expect(fetchMock).not.toHaveBeenCalled();
});

test("rejects id 0, there is no pokemon 0", async () => {
await expect(getPokemonByID(0)).rejects.toThrow("Invalid parameter");
expect(fetchMock).not.toHaveBeenCalled();
});

test("accepts the first valid id", async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => DUMMY_DATA[0] });

await getPokemonByID(1);

expect(fetchMock).toHaveBeenCalledWith(
"https://pokeapi.co/api/v2/pokemon/1",
);
});

test("accepts the last valid id", async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => DUMMY_DATA[0] });

Expand All @@ -55,3 +70,61 @@ describe("getPokemonByID", () => {
);
});
});

describe("getPokemonIdsByType", () => {
function typeResponse(urls: string[]) {
return {
ok: true,
json: async () => ({
pokemon: urls.map((url) => ({ pokemon: { url } })),
}),
};
}

test("pulls the ids out of the urls the api returns", async () => {
fetchMock.mockResolvedValue(
typeResponse([
"https://pokeapi.co/api/v2/pokemon/25/",
"https://pokeapi.co/api/v2/pokemon/26/",
]),
);

const ids = await getPokemonIdsByType("electric");

expect(fetchMock).toHaveBeenCalledWith(
"https://pokeapi.co/api/v2/type/electric",
);
expect(ids).toEqual([25, 26]);
});

test("returns the ids in order", async () => {
fetchMock.mockResolvedValue(
typeResponse([
"https://pokeapi.co/api/v2/pokemon/135/",
"https://pokeapi.co/api/v2/pokemon/25/",
"https://pokeapi.co/api/v2/pokemon/81/",
]),
);

expect(await getPokemonIdsByType("electric")).toEqual([25, 81, 135]);
});

test("drops the forms that sit above the highest id", async () => {
fetchMock.mockResolvedValue(
typeResponse([
"https://pokeapi.co/api/v2/pokemon/25/",
"https://pokeapi.co/api/v2/pokemon/10080/",
]),
);

expect(await getPokemonIdsByType("electric")).toEqual([25]);
});

test("throws when the api answers with an error", async () => {
fetchMock.mockResolvedValue({ ok: false, json: async () => ({}) });

await expect(getPokemonIdsByType("electric")).rejects.toThrow(
"Failed to fetch type",
);
});
});
25 changes: 25 additions & 0 deletions src/api/pokemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,28 @@ export async function getPokemonByID(id: number) {

return res.json();
}

const MAX_POKEMON_ID = 1025;

// Every pokemon of one type in a single request, so a type filter does not
// have to be found by fetching the whole pokedex and checking each one
export async function getPokemonIdsByType(type: string): Promise<number[]> {
const res = await fetch(`https://pokeapi.co/api/v2/type/${type}`);

if (!res.ok) {
throw new Error("Failed to fetch type");
}

const data = await res.json();

return (data.pokemon ?? [])
.map((entry: { pokemon: { url: string } }) => idFromUrl(entry.pokemon.url))
.filter((id: number) => id >= 1 && id <= MAX_POKEMON_ID)
.sort((a: number, b: number) => a - b);
}

// The api gives related resources as urls ending in the id
function idFromUrl(url: string): number {
const match = url.match(/\/(\d+)\/?$/);
return match ? Number(match[1]) : 0;
}
59 changes: 52 additions & 7 deletions src/controllers/pokemonController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@ import {
GetPrev,
GetNextFiltered,
GetPrevFiltered,
resetTypeCache,
} from "./pokemonController";
import * as pokemonApi from "../api/pokemon";
import { DUMMY_DATA } from "../utils/testData";
import type { FilterRules } from "../models/filters";

vi.mock("../api/pokemon", () => ({
getPokemonByID: vi.fn(),
getPokemonIdsByType: vi.fn(),
}));

describe("pokemonController", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
resetTypeCache();
});

describe("matchesFilter", () => {
Expand Down Expand Up @@ -94,16 +97,58 @@ describe("pokemonController", () => {
expect(result).toEqual(DUMMY_DATA[1]);
});

it("should skip past the pokemon that do not match the rules", async () => {
vi.spyOn(pokemonApi, "getPokemonByID")
.mockResolvedValueOnce(DUMMY_DATA[0]) // grass, skipped
.mockResolvedValueOnce(DUMMY_DATA[1]) // fire, skipped
.mockResolvedValueOnce(DUMMY_DATA[2]); // electric, kept
it("should only fetch the members of the type it was asked for", async () => {
vi.spyOn(pokemonApi, "getPokemonIdsByType").mockResolvedValue([25]);
vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce(
DUMMY_DATA[2],
);

const result = await GetNextFiltered(0, { type: ["electric"] });

expect(result).toEqual(DUMMY_DATA[2]);
expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(3);
expect(pokemonApi.getPokemonIdsByType).toHaveBeenCalledWith("electric");
// the 24 pokemon before pikachu are never asked for
expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(1);
expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(25);
});

it("should merge the members when several types are chosen", async () => {
vi.spyOn(pokemonApi, "getPokemonIdsByType").mockImplementation(
async (type) => (type === "fire" ? [4] : [25]),
);
vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce(
DUMMY_DATA[1],
);

const result = await GetNextFiltered(1, { type: ["electric", "fire"] });

// 4 comes before 25 once the two lists are merged and sorted
expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(4);
expect(result).toEqual(DUMMY_DATA[1]);
});

it("should look up each type once even across several calls", async () => {
const typeSpy = vi
.spyOn(pokemonApi, "getPokemonIdsByType")
.mockResolvedValue([25]);
vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValue(DUMMY_DATA[2]);

await GetNextFiltered(0, { type: ["electric"] });
await GetNextFiltered(0, { type: ["electric"] });

expect(typeSpy).toHaveBeenCalledTimes(1);
});

it("should walk the candidates backwards for GetPrevFiltered", async () => {
vi.spyOn(pokemonApi, "getPokemonIdsByType").mockResolvedValue([4, 25]);
vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce(
DUMMY_DATA[1],
);

const result = await GetPrevFiltered(25, { type: ["fire"] });

expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(4);
expect(result).toEqual(DUMMY_DATA[1]);
});

it("should walk backwards for GetPrevFiltered", async () => {
Expand Down
64 changes: 63 additions & 1 deletion src/controllers/pokemonController.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getPokemonByID } from "../api/pokemon";
import { getPokemonByID, getPokemonIdsByType } from "../api/pokemon";
import type { FilterRules } from "../models/filters";

export type PokemonData = {
Expand Down Expand Up @@ -93,6 +93,18 @@ export async function GetPrevFiltered(
currentId: number,
rules: FilterRules,
): Promise<PokemonData | null> {
const candidates = await candidateIds(rules);

if (candidates) {
for (let i = candidates.length - 1; i >= 0; i--) {
const id = candidates[i];
if (id >= currentId) continue;
const pokemon = await fetchSafe(id);
if (pokemon && matchesFilter(pokemon, rules)) return pokemon;
}
return null;
}

let prevId = currentId - 1;

while (prevId >= 1) {
Expand All @@ -109,6 +121,17 @@ export async function GetNextFiltered(
currentId: number,
rules: FilterRules,
): Promise<PokemonData | null> {
const candidates = await candidateIds(rules);

if (candidates) {
for (const id of candidates) {
if (id <= currentId) continue;
const pokemon = await fetchSafe(id);
if (pokemon && matchesFilter(pokemon, rules)) return pokemon;
}
return null;
}

let nextId = currentId + 1;

while (nextId <= MAX_POKEMON_ID) {
Expand All @@ -120,3 +143,42 @@ export async function GetNextFiltered(
}
return null;
}

// The ids worth looking at for these rules, or null when every id qualifies.
// A type rule narrows it to the members of those types, which turns a walk
// through the whole pokedex into a walk through a short list
export async function candidateIds(
rules: FilterRules,
): Promise<number[] | null> {
if (!rules.type || rules.type.length === 0) {
return null;
}

const lists = await Promise.all(rules.type.map(idsForType));

// matchesFilter keeps a pokemon that has any of the chosen types, so the
// lists are merged rather than intersected
return [...new Set(lists.flat())].sort((a, b) => a - b);
}

// The members of a type never change during a session, so the id list is
// held on to. The pokemon themselves are still fetched on demand
const typeIds = new Map<string, number[]>();

// Only needed so one test cannot leave a type cached for the next
export function resetTypeCache() {
typeIds.clear();
}

async function idsForType(type: string): Promise<number[]> {
const known = typeIds.get(type);
if (known) return known;

try {
const ids = await getPokemonIdsByType(type);
typeIds.set(type, ids);
return ids;
} catch {
return [];
}
}
Loading