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
Empty file removed src/controllers/.gitkeep
Empty file.
129 changes: 129 additions & 0 deletions src/controllers/pokemonController.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
122 changes: 122 additions & 0 deletions src/controllers/pokemonController.ts
Original file line number Diff line number Diff line change
@@ -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<PokemonData | null> {
try {
return await getPokemonByID(id);
} catch {
return null;
}
}



export async function GetNext(currentId: number): Promise<PokemonData | null> {
if (currentId >= MAX_POKEMON_ID) return null;
return await fetchSafe(currentId + 1);
}

export async function GetPrev(currentId: number): Promise<PokemonData | null> {
if (currentId <= 1) return null;
return await fetchSafe(currentId - 1);
}


export async function GetPrevFiltered(
currentId: number,
filters: FilterRules
): Promise<PokemonData | null> {
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<PokemonData | null> {
let nextId = currentId + 1;

while (nextId <= MAX_POKEMON_ID) {
const pokemon = await fetchSafe(nextId);
if (pokemon && matchesFilter(pokemon, filters)) {
return pokemon;
}
nextId++;
}
return null;
}