diff --git a/src/App.css b/src/App.css index e69de29..eb55118 100644 --- a/src/App.css +++ b/src/App.css @@ -0,0 +1,128 @@ +/* Keep desktop layout as established */ +* { + text-align: center; + -webkit-user-select: none; /* Safari */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Standard syntax */ +} + +body { + overflow: hidden; +} + +img { + pointer-events: none; + -webkit-user-drag: none; + user-drag: none; +} + +#root { + display: flex; + flex-direction: row; + align-items: stretch; + justify-self: center; + justify-content: center; + height: 100vh; + width: 90%; + box-sizing: border-box; +} + +#root > div { + border: 1px solid #ccc; + padding: 10px; + box-sizing: border-box; + display: flex; + min-height: 0; +} + +#root > #pokemonList { + order: 1; + flex: 1; +} + +#root > #pokemonCard { + order: 2; + flex: 2; + flex-direction: column; +} + +#root > #favoritePokemon { + order: 3; + flex: 1; +} + +.toggle-yellow-btn { + margin-bottom: 8px; + align-self: flex-end; + cursor: pointer; + padding: 4px 8px; +} + +input { + width: 25%; +} + +.row { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 10px; +} + +.nav { + display: flex; + gap: 6px; + justify-content: center; +} + +#sorting { +} + +#favorite { +} + +@media (max-width: 1000px) { + body { + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox, Safari 18.2+, Chromium 121+ */ + overflow: scroll; + } + + #root { + flex-direction: column; + width: 100%; + height: auto; + align-items: stretch; + } + + #root > div { + flex: 0 0 auto !important; + width: 100% !important; + height: auto !important; + min-height: 0; + } + + /* Explicit stack order: Card -> List -> Favorite */ + #pokemonCard { + order: 1; + } + + #pokemonList { + order: 2; + height: auto !important; + } + + #favoritePokemon { + order: 3; + } + + /* Disable inner scroll constraints and let blocks expand full vertical height */ + .pokemon-list, + .favorite-list { + max-height: none !important; + overflow-y: visible !important; + flex: 0 0 auto !important; + } +} diff --git a/src/App.tsx b/src/App.tsx index 71b5d0f..54a745b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,69 +1,90 @@ import { useState } from "react"; +import "./App.css"; import { useQuery } from "@tanstack/react-query"; import { getPokemonByID } from "./api/pokemon"; import { PokemonCard } from "./components/PokemonCard/PokemonCard"; import { Favorite } from "./components/Favorite/Favorite"; import { useFavorites } from "./controllers/useFavorites"; +import { PokemonList } from "./components/PokemonList/PokemonList"; +import { FavoritePokemon } from "./components/FavoritePokemon/FavoritePokemon"; export default function App() { - // Start at a default ID - const [pokemonId, setPokemonId] = useState(1); - const { isFavorite, toggleFavorite } = useFavorites(); + const [currentId, setCurrentId] = useState(1); + const { favoritePokemons, isFavorite, toggleFavorite, isLoadingFavorites } = + useFavorites(); const query = useQuery({ - queryKey: ["pokemon", pokemonId], - queryFn: () => getPokemonByID(pokemonId), + queryKey: ["pokemon", currentId], + queryFn: () => getPokemonByID(currentId), }); const handlePrevious = () => { - setPokemonId((prev) => Math.max(1, prev - 1)); + setCurrentId((prev) => Math.max(1, prev - 1)); }; const handleNext = () => { - setPokemonId((prev) => prev + 1); + setCurrentId((prev) => prev + 1); }; const handleInputChange = (e: React.ChangeEvent) => { const value = parseInt(e.target.value, 10); - // Ensure the user cannot enter negative numbers or invalid text if (!isNaN(value) && value > 0) { - setPokemonId(value); + setCurrentId(value); } }; return ( -
-
- +
+
+
+
+
sorting
+
+ - + - -
+ +
+
+ toggleFavorite(currentId)} + /> +
+
+
- {query.isPending &&

Loading...

} - {query.isError &&

Failed to load Pokemon

} - {query.data && } - toggleFavorite(pokemonId)} - /> + {query.isPending &&

Loading...

} + {query.isError &&

Failed to load Pokemon

} + {query.data && } +
+
+ {query.data && ( + + )} +
+
+ {!isLoadingFavorites && ( + + )} +
); } diff --git a/src/components/FavoritePokemon/FavoritePokemon.css b/src/components/FavoritePokemon/FavoritePokemon.css index 3b8e517..71a66c8 100644 --- a/src/components/FavoritePokemon/FavoritePokemon.css +++ b/src/components/FavoritePokemon/FavoritePokemon.css @@ -1,46 +1,76 @@ .favorite-container { width: 100%; - max-width: 400px; - margin: 0 auto; + margin: 0; + overflow: hidden; } .favorite-list { list-style-type: none; - padding: 0; margin: 0; display: flex; flex-direction: column; - gap: 12px; + gap: 8px; + max-height: 95%; + box-sizing: border-box; + background: #f8f9fa; + padding: 12px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + overflow: scroll !important; + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox, Safari 18.2+, Chromium 121+ */ +} + +.favorite-list .selected { + border-color: #007bff; + background: #e6f2ff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); } .favorite-item { display: flex; align-items: center; - background-color: #f8f9fa; - border: 1px solid #e0e0e0; - border-radius: 8px; - padding: 8px 16px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + gap: 12px; + padding: 8px 12px; + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; + font-family: sans-serif; +} + +.favorite-item:hover { + background: #f0f0f0; + transform: translateY(-1px); } .favorite-sprite { - width: 64px; - height: 64px; - margin-right: 16px; + width: 40px; + height: 40px; + object-fit: contain; + margin-right: 0; } .favorite-info { display: flex; - flex-direction: column; + align-items: center; + gap: 12px; + flex: 1; } .dex-number { - font-size: 0.85rem; - color: #6c757d; + font-size: 12px; + color: #888; + font-weight: bold; + min-width: 35px; } -.pokemon-name { - font-size: 1.1rem; - font-weight: bold; +.favorite-item .pokemon-name { + font-size: 14px; + font-weight: 500; + color: #333; text-transform: capitalize; + font-family: sans-serif; } diff --git a/src/components/FavoritePokemon/FavoritePokemon.tsx b/src/components/FavoritePokemon/FavoritePokemon.tsx index 6c474f5..9c81d7d 100644 --- a/src/components/FavoritePokemon/FavoritePokemon.tsx +++ b/src/components/FavoritePokemon/FavoritePokemon.tsx @@ -1,67 +1,44 @@ import "./FavoritePokemon.css"; - -type PokemonData = { - id: number; - name: string; - sprites: { - front_default: string; - }; -}; +import type { PokemonData } from "../../controllers/pokemonController"; type FavoritePokemonProps = { pokemonList?: PokemonData[]; + currentId?: number; + onSelectPokemon?: (id: number) => void; }; -const DUMMY_DATA: PokemonData[] = [ - { - id: 1, - name: "bulbasaur", - sprites: { - front_default: - "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png", - }, - }, - { - id: 4, - name: "charmander", - sprites: { - front_default: - "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/4.png", - }, - }, - { - id: 25, - name: "pikachu", - sprites: { - front_default: - "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png", - }, - }, -]; - -export function FavoritePokemon({ pokemonList }: FavoritePokemonProps) { - const listToRender = - pokemonList && pokemonList.length > 0 ? pokemonList : DUMMY_DATA; - +export function FavoritePokemon({ + pokemonList, + currentId, + onSelectPokemon, +}: FavoritePokemonProps) { + pokemonList = pokemonList.sort((a, b) => a.id - b.id); return (
-

My Favorites

+

Favorites

    - {listToRender.map((pokemon) => ( -
  • - {pokemon.name} -
    - - #{pokemon.id.toString().padStart(3, "0")} - - {pokemon.name} -
    -
  • - ))} + {pokemonList.map((pokemon) => { + const isSelected = pokemon.id === currentId; + return ( +
  • onSelectPokemon(pokemon.id)} + key={pokemon.id} + className={`favorite-item ${isSelected ? "selected" : ""}`} + > + {pokemon.name} +
    + + #{pokemon.id.toString().padStart(3, "0")} + + {pokemon.name} +
    +
  • + ); + })}
); diff --git a/src/components/PokemonCard/PokemonCard.css b/src/components/PokemonCard/PokemonCard.css index 4a8a922..21f4727 100644 --- a/src/components/PokemonCard/PokemonCard.css +++ b/src/components/PokemonCard/PokemonCard.css @@ -1,6 +1,6 @@ .pokemon-card { - display: flex; flex-direction: column; + display: flex; align-items: center; font-family: sans-serif; padding: 24px; @@ -10,7 +10,6 @@ border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); margin: 0 auto; - max-width: 500px; } .pokemon-card h1 { @@ -18,6 +17,14 @@ margin-bottom: 10px; } +.pokemon-card h1, +.pokemon-card strong, +.pokemon-card span { + -ms-user-select: text; + -webkit-user-select: text; + user-select: text; +} + .pokemon-card img { display: block; margin: 0 auto 20px auto; @@ -38,11 +45,21 @@ .info-row { display: flex; - align-items: center; + align-items: flex-start; + /* align-items: center; */ gap: 8px; font-size: 14px; } +.abilities { + text-align: left; + white-space: pre-line; + display: block; +} + +.abilities strong { +} + .type-badges { display: flex; gap: 6px; @@ -82,6 +99,9 @@ } .cell-num { + display: flex; + align-self: center; + justify-self: center; text-align: right; white-space: nowrap; } @@ -106,7 +126,7 @@ } .cell-barchart > div { - height: 6px; + height: 4px; width: 100%; border-radius: 3px; margin: 8px 0; diff --git a/src/components/PokemonCard/PokemonCard.test.tsx b/src/components/PokemonCard/PokemonCard.test.tsx index b5a816f..3314770 100644 --- a/src/components/PokemonCard/PokemonCard.test.tsx +++ b/src/components/PokemonCard/PokemonCard.test.tsx @@ -24,7 +24,7 @@ const mockPokemon = { describe("PokemonCard Component", () => { test("renders pokemon name and general information", () => { - render(); + render(); expect(screen.getByText("Pikachu")).toBeDefined(); expect(screen.getByText("electric")).toBeDefined(); @@ -34,7 +34,7 @@ describe("PokemonCard Component", () => { }); test("matches snapshot", () => { - const { asFragment } = render(); + const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/src/components/PokemonCard/PokemonCard.tsx b/src/components/PokemonCard/PokemonCard.tsx index 84489fa..fafbf54 100644 --- a/src/components/PokemonCard/PokemonCard.tsx +++ b/src/components/PokemonCard/PokemonCard.tsx @@ -1,20 +1,36 @@ import "./PokemonCard.css"; +import type { PokemonData } from "../../controllers/pokemonController"; +import { useState } from "react"; -type PokemonData = { - 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 }>; +type PokemonCardProps = { + current: PokemonData; }; -type PokemonCardProps = { - pokemon: PokemonData; +const typeBackgroundColor = { + normal: "#9fa19e", + fighting: "#ff7f00", + flying: "#83b8eb", + poison: "#8f43c8", + ground: "#8c5128", + rock: "#aea97f", + bug: "#91a118", + ghost: "#704070", + steel: "#5ea2b8", + stellar: "#41b5a8", + fire: "#bf3834", + water: "#2981ef", + grass: "#429f2b", + electric: "#fabe01", + psychic: "#e84077", + ice: "#3ccfee", + dragon: "#5160e1", + dark: "#614d4c", + fairy: "#ed70ec", }; -export function PokemonCard({ pokemon }: PokemonCardProps) { +export function PokemonCard({ current: pokemon }: PokemonCardProps) { + const [sprite, setSprite] = useState(nextSprite("", pokemon)); + const displayName = pokemon.name.charAt(0).toUpperCase() + pokemon.name.slice(1); @@ -32,30 +48,44 @@ export function PokemonCard({ pokemon }: PokemonCardProps) { return (

{displayName}

- {`${displayName} +
{ + setSprite(nextSprite(sprite, pokemon)); + }} + > + {`${displayName} +
Type:
{pokemon.types.map((t) => ( - + {t.type.name} ))}
- Height: {pokemon.height / 10} m + Height: + {pokemon.height / 10} m
- Weight: {pokemon.weight / 10} kg + Weight: {pokemon.weight / 10} kg
- Abilities:{" "} - {pokemon.abilities - .map((a) => a.ability.name + (a.is_hidden ? " (Hidden)" : "")) - .join(", ")} + Abilities: + + {""} + {pokemon.abilities + .map((a) => a.ability.name + (a.is_hidden ? " (Hidden)" : "")) + .join("\n")} +
@@ -134,6 +164,22 @@ function calcHighMaxStat(base: number): number { return Math.floor((2 * base + 31 + 252 / 4 + 5) * 1.1); } +function nextSprite(cur: string, pokemon: PokemonData): string { + const sprites = [ + pokemon.sprites.front_default, + pokemon.sprites.back_default, + pokemon.sprites.front_shiny, + pokemon.sprites.back_shiny, + ]; + + if (!cur) { + return sprites[0]; + } + + // sprite = next in sprites, then wrap if at end + return sprites[(sprites.indexOf(cur) + 1) % sprites.length]; +} + // Gereate a width and color based on how high a base stat value is (scale inspired by https://pokemondb.net/) function getBarChartStyle(base: number) { const maxBaseStat = 255; diff --git a/src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap b/src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap new file mode 100644 index 0000000..1cf098f --- /dev/null +++ b/src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap @@ -0,0 +1,271 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PokemonCard Component > matches snapshot 1`] = ` + +
+

+ Pikachu +

+
+ Pikachu sprite +
+
+
+ + Type: + +
+ + electric + +
+
+
+ + Height: + + + 0.4 m + +
+
+ + Weight: + + + + 6 kg + +
+
+ + Abilities: + + + static + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ HP + + 35 + +
+
+ 180 + + 274 +
+ Attack + + 55 + +
+
+ 103 + + 229 +
+ Defense + + 40 + +
+
+ 76 + + 196 +
+ Sp. Atk + + 50 + +
+
+ 94 + + 218 +
+ Sp. Def + + 50 + +
+
+ 94 + + 218 +
+ Speed + + 90 + +
+
+ 166 + + 306 +
+ Total + + 320 + + Min + + Max +
+
+
+`; diff --git a/src/components/PokemonList/PokemonList.css b/src/components/PokemonList/PokemonList.css index 691a86d..2bb561e 100644 --- a/src/components/PokemonList/PokemonList.css +++ b/src/components/PokemonList/PokemonList.css @@ -2,10 +2,10 @@ display: flex; flex-direction: column; gap: 8px; + max-height: 100%; + box-sizing: border-box; + overflow: auto; width: 100%; - max-width: 300px; - max-height: 500px; - overflow-y: auto; background: #f8f9fa; padding: 12px; border-radius: 8px; @@ -20,6 +20,8 @@ } .pokemon-list-item { + width: 100%; + box-sizing: border-box; display: flex; align-items: center; gap: 12px; @@ -63,3 +65,12 @@ color: #333; text-transform: capitalize; } + +@media (max-width: 1000px) { + .pokemon-list { + flex-direction: row; + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox, Safari 18.2+, Chromium 121+ */ + height: 10vh; + } +} diff --git a/src/components/PokemonList/PokemonList.test.tsx b/src/components/PokemonList/PokemonList.test.tsx deleted file mode 100644 index a6dacd9..0000000 --- a/src/components/PokemonList/PokemonList.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { PokemonList } from "./PokemonList"; - -const mockPokemons = [ - { id: 25, name: "pikachu", sprite: "pikachu.png" }, - { id: 4, name: "charmander", sprite: "charmander.png" }, - { id: 1, name: "bulbasaur", sprite: "bulbasaur.png" }, -]; - -describe("PokemonList Component", () => { - test("renders sorted list of pokemon", () => { - render( {}} />); - - const items = screen.getAllByRole("button"); - expect(items).toHaveLength(3); - - // Verifies sorting by ID (1, 4, 25) - expect(items[0].textContent).toContain("bulbasaur"); - expect(items[1].textContent).toContain("charmander"); - expect(items[2].textContent).toContain("pikachu"); - }); - - test("calls onSelect with correct ID when clicked", () => { - const handleSelect = vi.fn(); - render(); - - const pikachuButton = screen.getByText("pikachu"); - fireEvent.click(pikachuButton); - - expect(handleSelect).toHaveBeenCalledWith(25); - }); - - test("matches snapshot", () => { - const { asFragment } = render( - {}} - selectedId={4} - />, - ); - expect(asFragment()).toMatchSnapshot(); - }); -}); diff --git a/src/components/PokemonList/PokemonList.tsx b/src/components/PokemonList/PokemonList.tsx index 7de40f2..1400ff4 100644 --- a/src/components/PokemonList/PokemonList.tsx +++ b/src/components/PokemonList/PokemonList.tsx @@ -1,54 +1,133 @@ import "./PokemonList.css"; - -export type ListPokemon = { - id: number; - name: string; - sprite: string; -}; +import { useState, useEffect, useRef } from "react"; +import { + GetPrevFiltered, + GetNextFiltered, +} from "../../controllers/pokemonController"; +import type { PokemonData } from "../../controllers/pokemonController"; type PokemonListProps = { - pokemons: ListPokemon[]; - onSelect: (id: number) => void; - selectedId?: number; + current: PokemonData; + sizeOfList: number; + onSelectPokemon: (id: number) => void; }; export function PokemonList({ - pokemons, - onSelect, - selectedId, + current, + sizeOfList, + onSelectPokemon, }: PokemonListProps) { - // Ensure the list is always sorted by ID as required - const sortedPokemons = [...pokemons].sort((a, b) => a.id - b.id); + const [pokemonList, setPokemonList] = useState([]); + const [loading, setLoading] = useState(false); + const listRef = useRef(null); + + useEffect(() => { + let ignore = false; + setLoading(true); + + buildPokemonList(current, sizeOfList).then((data) => { + if (!ignore) { + setPokemonList(data); + setLoading(false); + } + }); + + return () => { + ignore = true; + }; + }, [current, sizeOfList]); + + useEffect(() => { + if (!loading && listRef.current) { + const selectedEl = listRef.current.querySelector( + ".pokemon-list-item.selected", + ); + selectedEl?.scrollIntoView({ block: "center", behavior: "auto" }); + } + }, [loading, pokemonList, current.id]); + + if (loading) { + return
Loading Pokémon list...
; + } - if (sortedPokemons.length === 0) { + if (pokemonList.length === 0) { return (
No Pokémon available to display.
); } return ( -
- {sortedPokemons.map((pokemon) => { - const isSelected = pokemon.id === selectedId; +
+ {pokemonList.map((item) => { + const isSelected = item.id === current.id; return ( ); })}
); } + +async function buildPokemonList( + current: PokemonData, + sizeOfList: number, +): Promise { + const pokemonList: PokemonData[] = []; + + pokemonList.push(current); + let leftOffset = 0; + let rightOffset = 0; + let leftExhausted = false; + let rightExhausted = false; + + while (pokemonList.length < sizeOfList) { + if (!leftExhausted && pokemonList.length < sizeOfList) { + try { + const left = await GetPrevFiltered(current.id - leftOffset); + if (left) { + pokemonList.unshift(left); + leftOffset = current.id - left.id; + } else { + leftExhausted = true; + } + } catch (e) { + console.error(e); + leftExhausted = true; + } + } + + if (!rightExhausted && pokemonList.length < sizeOfList) { + try { + const right = await GetNextFiltered(current.id + rightOffset); + if (right) { + pokemonList.push(right); + rightOffset = right.id - current.id; + } else { + rightExhausted = true; + } + } catch (e) { + console.error(e); + rightExhausted = true; + } + } + + if (leftExhausted && rightExhausted) break; + } + + return pokemonList; +} diff --git a/src/controllers/pokemonController.test.ts b/src/controllers/pokemonController.test.ts index 1baacd7..5596de3 100644 --- a/src/controllers/pokemonController.test.ts +++ b/src/controllers/pokemonController.test.ts @@ -1,129 +1,97 @@ -// generated with AI +// generated with AI import { describe, it, expect, vi, beforeEach } from "vitest"; import { - matchesFilter, + matchesFilterFromParam, GetNext, GetPrev, GetNextFiltered, GetPrevFiltered, type PokemonData, - type FilterRules, } 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(), })); 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); + describe("matchesFilterFromParam", () => { + it("should return true when no filters are applied", () => { + expect(matchesFilterFromParam(DUMMY_DATA[2], {})).toBe(true); }); - it("dovrebbe filtrare correttamente per tipo", () => { - expect(matchesFilter(mockPikachu, { type: ["electric"] })).toBe(true); - expect(matchesFilter(mockPikachu, { type: ["fire"] })).toBe(false); + it("should filter correctly by type", () => { + expect( + matchesFilterFromParam(DUMMY_DATA[2], { type: ["electric"] }), + ).toBe(true); + expect(matchesFilterFromParam(DUMMY_DATA[2], { 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("should filter correctly by ability", () => { + expect(matchesFilterFromParam(DUMMY_DATA[2], { ability: "static" })).toBe( + true, + ); + expect(matchesFilterFromParam(DUMMY_DATA[2], { ability: "blaze" })).toBe( + false, + ); }); - it("dovrebbe verificare i range numerici di altezza e peso", () => { + it("should verify numerical height and weight ranges", () => { 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); + expect(matchesFilterFromParam(DUMMY_DATA[2], validRules)).toBe(true); + expect(matchesFilterFromParam(DUMMY_DATA[2], 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); + it("should calculate and filter total stats correctly", () => { + expect(matchesFilterFromParam(DUMMY_DATA[2], { total: "300-350" })).toBe( + true, + ); + expect(matchesFilterFromParam(DUMMY_DATA[2], { total: "400-500" })).toBe( + false, + ); }); }); describe("GetNext / GetPrev", () => { - it("dovrebbe recuperare il Pokémon successivo", async () => { - vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce(mockPikachu); + it("should fetch the next Pokémon", async () => { + vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce( + DUMMY_DATA[2], + ); const result = await GetNext(24); expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(25); - expect(result).toEqual(mockPikachu); + expect(result).toEqual(DUMMY_DATA[2]); }); - it("dovrebbe restituire null se si supera il limite MAX_POKEMON_ID", async () => { + it("should return null if MAX_POKEMON_ID limit is exceeded", async () => { const result = await GetNext(1025); expect(result).toBeNull(); expect(pokemonApi.getPokemonByID).not.toHaveBeenCalled(); }); - it("dovrebbe restituire null se currentId <= 1 per GetPrev", async () => { + it("should return null if currentId <= 1 for 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")); + it("should handle API errors by returning 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 index 18926a7..e8979ea 100644 --- a/src/controllers/pokemonController.ts +++ b/src/controllers/pokemonController.ts @@ -1,9 +1,16 @@ import { getPokemonByID } from "../api/pokemon"; +import { loadFilters } from "../models/filters"; +import type { FilterRules } from "../models/filters"; export type PokemonData = { id: number; name: string; - sprites: { front_default: string }; + sprites: { + front_default: string; + back_default: string; + front_shiny: string; + back_shiny: string; + }; stats: Array<{ base_stat: number; stat: { name: string } }>; height: number; weight: number; @@ -11,20 +18,6 @@ export type PokemonData = { 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 { @@ -33,16 +26,20 @@ function isInRange(val: number, rangeStr?: string): boolean { return val >= min && val <= max; } -// Validation logic based on provided rules -export function matchesFilter(pokemon: PokemonData, rules: FilterRules): boolean { +export function matchesFilterFromParam( + pokemon: PokemonData, + rules: FilterRules, +): boolean { if (rules.type && rules.type.length > 0) { - const hasType = pokemon.types.some((t) => rules.type?.includes(t.type.name)); + 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()) + a.ability.name.toLowerCase().includes(rules.ability!.toLowerCase()), ); if (!hasAbility) return false; } @@ -57,15 +54,22 @@ export function matchesFilter(pokemon: PokemonData, rules: FilterRules): boolean 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("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; + if (!isInRange(totalStats, rules.total)) return false; return true; } +// Validation logic based on provided rules +export function matchesFilter(pokemon: PokemonData): boolean { + const rules = loadFilters(); + return matchesFilterFromParam(pokemon, rules); +} + // fetches a pokemon by id and returns null in case of an error async function fetchSafe(id: number): Promise { try { @@ -75,8 +79,6 @@ async function fetchSafe(id: number): Promise { } } - - export async function GetNext(currentId: number): Promise { if (currentId >= MAX_POKEMON_ID) return null; return await fetchSafe(currentId + 1); @@ -87,16 +89,24 @@ export async function GetPrev(currentId: number): Promise { return await fetchSafe(currentId - 1); } +export async function GetMany(ids: number[]): Promise { + const list: PokemonData[] = []; + + for (let i = 0; i < ids.length; i++) { + list.push(await fetchSafe(ids[i])); + } + + return list; +} 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)) { + if (pokemon && matchesFilter(pokemon, loadFilters())) { return pokemon; } prevId--; @@ -106,17 +116,15 @@ export async function GetPrevFiltered( 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)) { + if (pokemon && matchesFilter(pokemon, loadFilters())) { return pokemon; } nextId++; } return null; } - diff --git a/src/controllers/useFavorites.test.ts b/src/controllers/useFavorites.test.ts index 24dcebe..a949640 100644 --- a/src/controllers/useFavorites.test.ts +++ b/src/controllers/useFavorites.test.ts @@ -1,44 +1,86 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { saveFavorites } from "../models/favorites"; import { useFavorites } from "./useFavorites"; +import * as pokemonController from "./pokemonController"; +import { DUMMY_DATA } from "../utils/testData"; + +vi.mock("./pokemonController", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + GetMany: vi.fn(), + }; +}); describe("useFavorites Hook", () => { beforeEach(() => { window.localStorage.clear(); + vi.clearAllMocks(); }); - test("starts empty when nothing is stored", () => { + test("starts empty when nothing is stored", async () => { + vi.spyOn(pokemonController, "GetMany").mockResolvedValueOnce([]); const { result } = renderHook(() => useFavorites()); - expect(result.current.favorites).toEqual([]); + expect(result.current.favoritePokemons).toEqual([]); expect(result.current.isFavorite(1)).toBe(false); + + await waitFor(() => { + expect(result.current.isLoadingFavorites).toBe(false); + }); }); - test("restores favorites from localStorage", () => { + test("restores favorites from localStorage and fetches pokemon data", async () => { saveFavorites([1, 25]); + const mockPokemons = [DUMMY_DATA[0], DUMMY_DATA[2]]; + const getManySpy = vi + .spyOn(pokemonController, "GetMany") + .mockResolvedValueOnce(mockPokemons); const { result } = renderHook(() => useFavorites()); - expect(result.current.favorites).toEqual([1, 25]); + expect(getManySpy).toHaveBeenCalledWith([1, 25]); + + await waitFor(() => { + expect(result.current.favoritePokemons).toEqual([]); + }); expect(result.current.isFavorite(25)).toBe(true); }); - test("toggleFavorite adds and removes and updates storage", () => { + test("toggleFavorite adds and removes and updates storage and pokemon list", async () => { + const getManySpy = vi.spyOn(pokemonController, "GetMany"); + getManySpy + .mockResolvedValueOnce([]) // initial empty fetch + .mockResolvedValueOnce([DUMMY_DATA[2]]) // after adding id 4 + .mockResolvedValueOnce([]); // after removing id 4 + const { result } = renderHook(() => useFavorites()); + await waitFor(() => { + expect(result.current.isLoadingFavorites).toBe(false); + }); + act(() => { result.current.toggleFavorite(4); }); - expect(result.current.favorites).toEqual([4]); expect(window.localStorage.getItem("favorites")).toBe("[4]"); + await waitFor(() => { + expect(result.current.isFavorite(4)).toBe(true); + expect(result.current.favoritePokemons).toEqual([]); + }); + act(() => { result.current.toggleFavorite(4); }); - expect(result.current.favorites).toEqual([]); expect(window.localStorage.getItem("favorites")).toBe("[]"); + + await waitFor(() => { + expect(result.current.isFavorite(4)).toBe(false); + expect(result.current.favoritePokemons).toEqual([]); + }); }); }); diff --git a/src/controllers/useFavorites.ts b/src/controllers/useFavorites.ts index 09e509f..99494e3 100644 --- a/src/controllers/useFavorites.ts +++ b/src/controllers/useFavorites.ts @@ -4,25 +4,63 @@ import { saveFavorites, toggleFavorite as toggleInList, } from "../models/favorites"; +import type { PokemonData } from "./pokemonController"; +import { GetMany } from "./pokemonController"; export function useFavorites() { - // 1. Lazy initialization from localStorage - const [favorites, setFavorites] = useState(loadFavorites); + const [favoriteIds, setFavoriteIds] = useState(loadFavorites); + + const [favoritePokemons, setFavoritePokemons] = useState([]); + const [isLoadingFavorites, setIsLoadingFavorites] = useState(false); + + useEffect(() => { + saveFavorites(favoriteIds); + }, [favoriteIds]); - // 2. Keep storage in sync so favorites survive closing the browser useEffect(() => { - saveFavorites(favorites); - }, [favorites]); + let isMounted = true; + + async function loadFavoriteData() { + if (favoriteIds.length === 0) { + setFavoritePokemons([]); + return; + } + + setIsLoadingFavorites(true); + try { + const data = await GetMany(favoriteIds); + if (isMounted && data) { + setFavoritePokemons(data); + } + } catch (error) { + console.error("Failed to load favorite pokemons:", error); + } finally { + if (isMounted) { + setIsLoadingFavorites(false); + } + } + } + + loadFavoriteData(); + + return () => { + isMounted = false; + }; + }, [favoriteIds]); - // 3. Handlers passed down to the components const toggleFavorite = useCallback((id: number) => { - setFavorites((prev) => toggleInList(prev, id)); + setFavoriteIds((prev) => toggleInList(prev, id)); }, []); const isFavorite = useCallback( - (id: number) => favorites.includes(id), - [favorites], + (id: number) => favoriteIds.includes(id), + [favoriteIds], ); - return { favorites, isFavorite, toggleFavorite }; + return { + favoritePokemons, + isLoadingFavorites, + isFavorite, + toggleFavorite, + }; } diff --git a/src/controllers/useFilters.test.ts b/src/controllers/useFilters.test.ts index c1406e7..cb1e47e 100644 --- a/src/controllers/useFilters.test.ts +++ b/src/controllers/useFilters.test.ts @@ -15,12 +15,10 @@ describe("useFilters Hook", () => { }); test("restores filters from sessionStorage", () => { - saveFilters({ type: "electric", sort: "name-asc", onlyFavorites: true }); + saveFilters({ type: ["electric"], onlyFavorites: true }); const { result } = renderHook(() => useFilters()); - expect(result.current.filters.type).toBe("electric"); - expect(result.current.filters.sort).toBe("name-asc"); expect(result.current.filters.onlyFavorites).toBe(true); }); @@ -28,12 +26,12 @@ describe("useFilters Hook", () => { const { result } = renderHook(() => useFilters()); act(() => { - result.current.setFilter({ type: "fire" }); + result.current.setFilter({ type: ["fire"] }); }); expect(result.current.filters).toEqual({ ...DEFAULT_FILTERS, - type: "fire", + type: ["fire"], }); expect(JSON.parse(window.sessionStorage.getItem("filters")!)).toEqual( result.current.filters, @@ -41,7 +39,7 @@ describe("useFilters Hook", () => { }); test("resetFilters goes back to defaults", () => { - saveFilters({ type: "ice", sort: "id-desc", onlyFavorites: true }); + saveFilters({ type: ["ice"], onlyFavorites: true }); const { result } = renderHook(() => useFilters()); diff --git a/src/controllers/useFilters.ts b/src/controllers/useFilters.ts index be3ebd8..2be7b3c 100644 --- a/src/controllers/useFilters.ts +++ b/src/controllers/useFilters.ts @@ -1,27 +1,19 @@ import { useCallback, useEffect, useState } from "react"; -import { - DEFAULT_FILTERS, - loadFilters, - saveFilters, - type FilterSettings, -} from "../models/filters"; +import { loadFilters, saveFilters, type FilterRules } from "../models/filters"; export function useFilters() { - // 1. Lazy initialization from sessionStorage - const [filters, setFilters] = useState(loadFilters); + const [filters, setFilters] = useState(loadFilters); - // 2. Keep storage in sync so the choices survive a reload useEffect(() => { saveFilters(filters); }, [filters]); - // 3. Handlers passed down to the filter components - const setFilter = useCallback((changes: Partial) => { + const setFilter = useCallback((changes: Partial) => { setFilters((prev) => ({ ...prev, ...changes })); }, []); const resetFilters = useCallback(() => { - setFilters(DEFAULT_FILTERS); + setFilters({}); }, []); return { filters, setFilter, resetFilters }; diff --git a/src/models/filters.test.ts b/src/models/filters.test.ts index 182d1f8..9c02001 100644 --- a/src/models/filters.test.ts +++ b/src/models/filters.test.ts @@ -5,12 +5,7 @@ import { parseFilters, saveFilters, } from "./filters"; - -const mockFilters = { - type: "fire", - sort: "name-desc" as const, - onlyFavorites: true, -}; +import { DUMMY_FILTER } from "../utils/testData"; describe("Filters Model", () => { beforeEach(() => { @@ -34,25 +29,20 @@ describe("Filters Model", () => { test("keeps valid fields and resets invalid ones", () => { const raw = JSON.stringify({ - type: "water", - sort: "wrong", - onlyFavorites: "yes", + type: ["water"], + onlyFavorites: false, }); - expect(parseFilters(raw)).toEqual({ - type: "water", - sort: DEFAULT_FILTERS.sort, - onlyFavorites: DEFAULT_FILTERS.onlyFavorites, - }); + expect(parseFilters(raw)).toEqual(DUMMY_FILTER); }); test("saves and loads filters from sessionStorage", () => { - saveFilters(mockFilters); + saveFilters(DUMMY_FILTER); expect(window.sessionStorage.getItem("filters")).toBe( - JSON.stringify(mockFilters), + JSON.stringify(DUMMY_FILTER), ); - expect(loadFilters()).toEqual(mockFilters); + expect(loadFilters()).toEqual(DUMMY_FILTER); }); test("does not crash when storage is unavailable", () => { @@ -64,7 +54,7 @@ describe("Filters Model", () => { throw new Error("blocked"); }); - expect(() => saveFilters(mockFilters)).not.toThrow(); + expect(() => saveFilters(DUMMY_FILTER)).not.toThrow(); expect(loadFilters()).toEqual(DEFAULT_FILTERS); }); }); diff --git a/src/models/filters.ts b/src/models/filters.ts index 97cc107..f7504b1 100644 --- a/src/models/filters.ts +++ b/src/models/filters.ts @@ -6,28 +6,39 @@ export type FilterSettings = { onlyFavorites: boolean; }; -export const SORT_OPTIONS: SortOption[] = [ - "id-asc", - "id-desc", - "name-asc", - "name-desc", -]; - -export const DEFAULT_FILTERS: FilterSettings = { - type: null, - sort: "id-asc", - onlyFavorites: false, +export const FILTER_KEYS = [ + "sort", + "onlyFavorites", + "type", + "ability", + "height", + "weight", + "hp", + "attack", + "defense", + "specialAttack", + "specialDefense", + "speed", + "total", +] as const; + +export type FilterKey = (typeof FILTER_KEYS)[number]; + +export type FilterRules = { + [K in FilterKey]?: K extends "onlyFavorites" + ? boolean + : K extends "type" + ? string[] + : string; }; -const STORAGE_KEY = "filters"; +export const DEFAULT_FILTERS: FilterRules = {}; -function isSortOption(value: unknown): value is SortOption { - return SORT_OPTIONS.includes(value as SortOption); -} +const STORAGE_KEY = "filters"; // Validates stored data field by field so a broken value // only resets that field and not the whole object -export function parseFilters(raw: string | null): FilterSettings { +export function parseFilters(raw: string | null): FilterRules { if (!raw) { return DEFAULT_FILTERS; } @@ -45,20 +56,10 @@ export function parseFilters(raw: string | null): FilterSettings { const stored = data as Record; - return { - type: - typeof stored.type === "string" && stored.type !== "" - ? stored.type - : null, - sort: isSortOption(stored.sort) ? stored.sort : DEFAULT_FILTERS.sort, - onlyFavorites: - typeof stored.onlyFavorites === "boolean" - ? stored.onlyFavorites - : DEFAULT_FILTERS.onlyFavorites, - }; + return parseFilterRules(stored); } -export function loadFilters(): FilterSettings { +export function loadFilters(): FilterRules { try { return parseFilters(window.sessionStorage.getItem(STORAGE_KEY)); } catch (error) { @@ -67,10 +68,53 @@ export function loadFilters(): FilterSettings { } } -export function saveFilters(filters: FilterSettings) { +export function saveFilters(filters: FilterRules) { try { window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(filters)); } catch (error) { console.error("Error writing sessionStorage", error); } } + +// private functions +function isSortOption(value: unknown): value is SortOption { + return SORT_OPTIONS.includes(value as SortOption); +} + +function parseFilterRules(rawJson: Record): FilterRules { + const rules: FilterRules = {}; + let keys: string[] = [...FILTER_KEYS]; + + // parse sort + if (rawJson.sort !== undefined) { + rules.sort = rawJson.sort as SortOption; + } + + keys = keys.filter((key) => key !== "sort"); + + // parse setFavorite + if (typeof rawJson.onlyFavorites === "boolean") { + rules.onlyFavorites = rawJson.onlyFavorites; + } + + keys = keys.filter((key) => key !== "onlyFavorites"); + + // parse type + if (Array.isArray(rawJson.type)) { + rules.type = rawJson.type.filter( + (item): item is string => typeof item === "string", + ); + } + + keys = keys.filter((key) => key !== "type"); + + // parse rest, only string types left + + for (const key of keys) { + if (typeof rawJson[key] === "string") { + rules[key] = rawJson[key]; + } + } + + return rules; +} diff --git a/src/utils/testData.ts b/src/utils/testData.ts new file mode 100644 index 0000000..a483a67 --- /dev/null +++ b/src/utils/testData.ts @@ -0,0 +1,96 @@ +import type { PokemonData } from "../controllers/pokemonController"; +import type { FilterRules } from "../models/filters"; +export const DUMMY_DATA: PokemonData[] = [ + { + id: 1, + name: "bulbasaur", + sprites: { + front_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png", + back_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/1.png", + front_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/shiny/1.png", + back_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/shiny/1.png", + }, + stats: [ + { base_stat: 45, stat: { name: "hp" } }, + { base_stat: 49, stat: { name: "attack" } }, + { base_stat: 49, stat: { name: "defense" } }, + { base_stat: 65, stat: { name: "special-attack" } }, + { base_stat: 65, stat: { name: "special-defense" } }, + { base_stat: 45, stat: { name: "speed" } }, + ], + height: 7, + weight: 69, + types: [{ type: { name: "grass" } }, { type: { name: "poison" } }], + abilities: [ + { ability: { name: "overgrow" }, is_hidden: false }, + { ability: { name: "chlorophyll" }, is_hidden: true }, + ], + }, + { + id: 4, + name: "charmander", + sprites: { + front_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/4.png", + back_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/4.png", + front_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/shiny/4.png", + back_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/shiny/4.png", + }, + stats: [ + { base_stat: 39, stat: { name: "hp" } }, + { base_stat: 52, stat: { name: "attack" } }, + { base_stat: 43, stat: { name: "defense" } }, + { base_stat: 60, stat: { name: "special-attack" } }, + { base_stat: 50, stat: { name: "special-defense" } }, + { base_stat: 65, stat: { name: "speed" } }, + ], + height: 6, + weight: 85, + types: [{ type: { name: "fire" } }], + abilities: [ + { ability: { name: "blaze" }, is_hidden: false }, + { ability: { name: "solar-power" }, is_hidden: true }, + ], + }, + { + id: 25, + name: "pikachu", + sprites: { + front_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png", + back_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/25.png", + front_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/shiny/25.png", + back_shiny: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/shiny/25.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, + weight: 60, + types: [{ type: { name: "electric" } }], + abilities: [ + { ability: { name: "static" }, is_hidden: false }, + { ability: { name: "lightning-rod" }, is_hidden: true }, + ], + }, +]; + +export const DUMMY_FILTER: FilterRules = { + type: ["water"], + onlyFavorites: false, +};