diff --git a/src/controllers/.gitkeep b/src/controllers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/controllers/pokemonController.test.ts b/src/controllers/pokemonController.test.ts new file mode 100644 index 0000000..1baacd7 --- /dev/null +++ b/src/controllers/pokemonController.test.ts @@ -0,0 +1,129 @@ +// generated with AI +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + matchesFilter, + GetNext, + GetPrev, + GetNextFiltered, + GetPrevFiltered, + type PokemonData, + type FilterRules, +} from "./pokemonController"; +import * as pokemonApi from "../api/pokemon"; + +vi.mock("../api/pokemon", () => ({ + getPokemonByID: vi.fn(), +})); + +describe("pokemonController", () => { + const mockPikachu: PokemonData = { + id: 25, + name: "pikachu", + sprites: { front_default: "pikachu.png" }, + stats: [ + { base_stat: 35, stat: { name: "hp" } }, + { base_stat: 55, stat: { name: "attack" } }, + { base_stat: 40, stat: { name: "defense" } }, + { base_stat: 50, stat: { name: "special-attack" } }, + { base_stat: 50, stat: { name: "special-defense" } }, + { base_stat: 90, stat: { name: "speed" } }, + ], + height: 4, // 0.4 m + weight: 60, // 6.0 kg + types: [{ type: { name: "electric" } }], + abilities: [ + { ability: { name: "static" }, is_hidden: false }, + { ability: { name: "lightning-rod" }, is_hidden: true }, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("matchesFilter", () => { + it("dovrebbe restituire true quando nessun filtro è applicato", () => { + expect(matchesFilter(mockPikachu, {})).toBe(true); + }); + + it("dovrebbe filtrare correttamente per tipo", () => { + expect(matchesFilter(mockPikachu, { type: ["electric"] })).toBe(true); + expect(matchesFilter(mockPikachu, { type: ["fire"] })).toBe(false); + }); + + it("dovrebbe filtrare correttamente per abilità", () => { + expect(matchesFilter(mockPikachu, { ability: "static" })).toBe(true); + expect(matchesFilter(mockPikachu, { ability: "blaze" })).toBe(false); + }); + + it("dovrebbe verificare i range numerici di altezza e peso", () => { + const validRules: FilterRules = { height: "0-1", weight: "5-10" }; + const invalidRules: FilterRules = { height: "1-2", weight: "5-10" }; + + expect(matchesFilter(mockPikachu, validRules)).toBe(true); + expect(matchesFilter(mockPikachu, invalidRules)).toBe(false); + }); + + it("dovrebbe calcolare e filtrare correttamente le statistiche totali", () => { + expect(matchesFilter(mockPikachu, { total: "300-350" })).toBe(true); + expect(matchesFilter(mockPikachu, { total: "400-500" })).toBe(false); + }); + }); + + describe("GetNext / GetPrev", () => { + it("dovrebbe recuperare il Pokémon successivo", async () => { + vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce(mockPikachu); + + const result = await GetNext(24); + expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(25); + expect(result).toEqual(mockPikachu); + }); + + it("dovrebbe restituire null se si supera il limite MAX_POKEMON_ID", async () => { + const result = await GetNext(1025); + expect(result).toBeNull(); + expect(pokemonApi.getPokemonByID).not.toHaveBeenCalled(); + }); + + it("dovrebbe restituire null se currentId <= 1 per GetPrev", async () => { + const result = await GetPrev(1); + expect(result).toBeNull(); + expect(pokemonApi.getPokemonByID).not.toHaveBeenCalled(); + }); + + it("dovrebbe gestire gli errori API restituendo null", async () => { + vi.spyOn(pokemonApi, "getPokemonByID").mockRejectedValueOnce(new Error("API Error")); + + const result = await GetNext(10); + expect(result).toBeNull(); + }); + }); + + describe("GetNextFiltered / GetPrevFiltered", () => { + it("dovrebbe trovare il prossimo Pokémon che rispetta i filtri", async () => { + const Charmander: PokemonData = { ...mockPikachu, id: 4, types: [{ type: { name: "fire" } }] }; + + vi.spyOn(pokemonApi, "getPokemonByID") + .mockResolvedValueOnce(mockPikachu) + .mockResolvedValueOnce(Charmander); + + const result = await GetNextFiltered(2, { type: ["fire"] }); + + expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(2); + expect(result).toEqual(Charmander); + }); + + it("dovrebbe trovare il Pokémon precedente che rispetta i filtri", async () => { + const Bulbasaur: PokemonData = { ...mockPikachu, id: 1, types: [{ type: { name: "grass" } }] }; + + vi.spyOn(pokemonApi, "getPokemonByID") + .mockResolvedValueOnce(mockPikachu) + .mockResolvedValueOnce(Bulbasaur); + + const result = await GetPrevFiltered(3, { type: ["grass"] }); + + expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(2); + expect(result).toEqual(Bulbasaur); + }); + }); +}); \ No newline at end of file diff --git a/src/controllers/pokemonController.ts b/src/controllers/pokemonController.ts new file mode 100644 index 0000000..18926a7 --- /dev/null +++ b/src/controllers/pokemonController.ts @@ -0,0 +1,122 @@ +import { getPokemonByID } from "../api/pokemon"; + +export type PokemonData = { + id: number; + name: string; + sprites: { front_default: string }; + stats: Array<{ base_stat: number; stat: { name: string } }>; + height: number; + weight: number; + types: Array<{ type: { name: string } }>; + abilities: Array<{ ability: { name: string }; is_hidden: boolean }>; +}; + +export interface FilterRules { + type?: string[]; + ability?: string; + height?: string; + weight?: string; + hp?: string; + attack?: string; + defense?: string; + specialAttack?: string; + specialDefense?: string; + speed?: string; + total?: string; +} + +const MAX_POKEMON_ID = 1025; + +function isInRange(val: number, rangeStr?: string): boolean { + if (!rangeStr) return true; + const [min, max] = rangeStr.split("-").map(Number); + return val >= min && val <= max; +} + +// Validation logic based on provided rules +export function matchesFilter(pokemon: PokemonData, rules: FilterRules): boolean { + if (rules.type && rules.type.length > 0) { + const hasType = pokemon.types.some((t) => rules.type?.includes(t.type.name)); + if (!hasType) return false; + } + + if (rules.ability && rules.ability.trim() !== "") { + const hasAbility = pokemon.abilities.some((a) => + a.ability.name.toLowerCase().includes(rules.ability!.toLowerCase()) + ); + if (!hasAbility) return false; + } + + if (!isInRange(pokemon.height / 10, rules.height)) return false; + if (!isInRange(pokemon.weight / 10, rules.weight)) return false; + + const getStat = (name: string) => + pokemon.stats.find((s) => s.stat.name === name)?.base_stat || 0; + + if (!isInRange(getStat("hp"), rules.hp)) return false; + if (!isInRange(getStat("attack"), rules.attack)) return false; + if (!isInRange(getStat("defense"), rules.defense)) return false; + if (!isInRange(getStat("special-attack"), rules.specialAttack)) return false; + if (!isInRange(getStat("special-defense"), rules.specialDefense)) return false; + if (!isInRange(getStat("speed"), rules.speed)) return false; + + const totalStats = pokemon.stats.reduce((acc, s) => acc + s.base_stat, 0); + if (!isInRange(totalStats , rules.total)) return false; + + return true; +} + +// fetches a pokemon by id and returns null in case of an error +async function fetchSafe(id: number): Promise { + try { + return await getPokemonByID(id); + } catch { + return null; + } +} + + + +export async function GetNext(currentId: number): Promise { + if (currentId >= MAX_POKEMON_ID) return null; + return await fetchSafe(currentId + 1); +} + +export async function GetPrev(currentId: number): Promise { + if (currentId <= 1) return null; + return await fetchSafe(currentId - 1); +} + + +export async function GetPrevFiltered( + currentId: number, + filters: FilterRules +): Promise { + let prevId = currentId - 1; + + while (prevId >= 1) { + const pokemon = await fetchSafe(prevId); + if (pokemon && matchesFilter(pokemon, filters)) { + return pokemon; + } + prevId--; + } + return null; +} + +export async function GetNextFiltered( + currentId: number, + filters: FilterRules +): Promise { + let nextId = currentId + 1; + + while (nextId <= MAX_POKEMON_ID) { + const pokemon = await fetchSafe(nextId); + if (pokemon && matchesFilter(pokemon, filters)) { + return pokemon; + } + nextId++; + } + return null; +} +