diff --git a/src/App.css b/src/App.css index e69de29..f4bb3b5 100644 --- a/src/App.css +++ b/src/App.css @@ -0,0 +1,122 @@ +/* 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; +} + +.app-layout { + display: flex; + flex-direction: row; + align-items: stretch; + justify-self: center; + justify-content: center; + height: 100vh; + width: 90%; + box-sizing: border-box; +} + +.app-layout > div { + border: 1px solid #ccc; + padding: 10px; + box-sizing: border-box; + display: flex; + min-height: 0; +} + +.app-layout > #pokemonList { + order: 1; + flex: 1; +} + +.app-layout > #pokemonCard { + order: 2; + flex: 2; + flex-direction: column; +} + +.app-layout > #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; +} + +@media (max-width: 1000px) { + body { + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox, Safari 18.2+, Chromium 121+ */ + overflow: scroll; + } + + .app-layout { + flex-direction: column; + width: 100%; + height: auto; + align-items: stretch; + } + + .app-layout > 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.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..d897651 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import App from "./App"; +import * as pokemonApi from "./api/pokemon"; +import { DUMMY_DATA } from "./utils/testData"; +import { createWrapper } from "./utils/testWrapper"; + +vi.mock("./api/pokemon", () => ({ + getPokemonByID: vi.fn(), +})); + +// jsdom has no layout, so the scroll the list does after loading is a no-op +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); + +beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + vi.clearAllMocks(); + + // Every id resolves to a pokemon so the list around the current one fills up + vi.spyOn(pokemonApi, "getPokemonByID").mockImplementation(async (id) => ({ + ...DUMMY_DATA[0], + id, + })); +}); + +function renderApp() { + render(, { wrapper: createWrapper() }); +} + +describe("App", () => { + test("shows the first pokemon once it has loaded", async () => { + renderApp(); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + + await waitFor(() => { + expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(1); + }); + }); + + test("moves to the next pokemon when the user asks for it", async () => { + renderApp(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Next/ })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /Next/ })); + + await waitFor(() => { + expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(2); + }); + }); + + test("cannot go back past the first pokemon", async () => { + renderApp(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Prev/ })).toBeDisabled(); + }); + }); + + test("keeps a favorite in local storage", async () => { + renderApp(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Add to favorites" }), + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Add to favorites" })); + + expect(window.localStorage.getItem("favorites")).toBe("[1]"); + }); + + test("keeps a filter choice in session storage", async () => { + renderApp(); + + await waitFor(() => { + expect(screen.getByLabelText("Highest id first")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByLabelText("Highest id first")); + + await waitFor(() => { + expect(window.sessionStorage.getItem("filters")).toContain("id-desc"); + }); + }); + + test("restores the rules that were stored before", async () => { + window.sessionStorage.setItem( + "filters", + JSON.stringify({ type: ["electric"], onlyFavorites: true }), + ); + + renderApp(); + + await waitFor(() => { + expect(screen.getByLabelText("electric")).toBeChecked(); + }); + expect(screen.getByLabelText("Only show favorites")).toBeChecked(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 71b5d0f..ac6462d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,69 +1,101 @@ 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 { useFilters } from "./controllers/useFilters"; +import { PokemonList } from "./components/PokemonList/PokemonList"; +import { FavoritePokemon } from "./components/FavoritePokemon/FavoritePokemon"; +import { Filter } from "./components/Filter/Filter"; 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 { filters, setFilter, resetFilters } = useFilters(); 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 ( -
-
- +
+
+
+
+
+ +
+
+ - + - -
+ +
+
+ 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/api/pokemon.test.ts b/src/api/pokemon.test.ts new file mode 100644 index 0000000..91919a3 --- /dev/null +++ b/src/api/pokemon.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { getPokemonByID } from "./pokemon"; +import { DUMMY_DATA } from "../utils/testData"; + +// Nothing here is allowed to reach the network, so fetch is replaced +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("getPokemonByID", () => { + test("asks the api for the id it was given", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => DUMMY_DATA[2], + }); + + const result = await getPokemonByID(25); + + expect(fetchMock).toHaveBeenCalledWith( + "https://pokeapi.co/api/v2/pokemon/25", + ); + expect(result).toEqual(DUMMY_DATA[2]); + }); + + test("throws when the api answers with an error", async () => { + fetchMock.mockResolvedValue({ ok: false, json: async () => ({}) }); + + await expect(getPokemonByID(25)).rejects.toThrow("Failed to fetch pokemon"); + }); + + test("rejects an id above the highest pokemon without calling the api", async () => { + await expect(getPokemonByID(1026)).rejects.toThrow("Invalid parameter"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("rejects a negative id without calling the api", async () => { + await expect(getPokemonByID(-1)).rejects.toThrow("Invalid parameter"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("accepts the last valid id", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => DUMMY_DATA[0] }); + + await getPokemonByID(1025); + + expect(fetchMock).toHaveBeenCalledWith( + "https://pokeapi.co/api/v2/pokemon/1025", + ); + }); +}); diff --git a/src/components/FavoritePokemon/FavoritePokemon.css b/src/components/FavoritePokemon/FavoritePokemon.css index 3b8e517..9f840b2 100644 --- a/src/components/FavoritePokemon/FavoritePokemon.css +++ b/src/components/FavoritePokemon/FavoritePokemon.css @@ -1,46 +1,90 @@ .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); } +/* Rendered as a + + ); + })}
); diff --git a/src/components/FavoritePokemon/__snapshots__/FavoritePokemon.test.tsx.snap b/src/components/FavoritePokemon/__snapshots__/FavoritePokemon.test.tsx.snap new file mode 100644 index 0000000..736023c --- /dev/null +++ b/src/components/FavoritePokemon/__snapshots__/FavoritePokemon.test.tsx.snap @@ -0,0 +1,70 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`FavoritePokemon Component > matches snapshot 1`] = ` + +
+

+ Favorites +

+
    +
  • + +
  • +
  • + +
  • +
+
+
+`; diff --git a/src/components/Filter/Filter.css b/src/components/Filter/Filter.css new file mode 100644 index 0000000..b6d7eec --- /dev/null +++ b/src/components/Filter/Filter.css @@ -0,0 +1,97 @@ +/* Collapsed by default so the panel does not crowd the header row */ +.filter { + position: relative; + text-align: left; + font-family: sans-serif; + font-size: 14px; +} + +.filter summary { + cursor: pointer; + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 6px; + background: #fff; + white-space: nowrap; +} + +/* Opens on top of the card so the layout below does not shift */ +.filter-panel { + position: absolute; + z-index: 2; + top: 100%; + left: 0; + margin-top: 6px; + width: 260px; + max-height: 60vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + background: #fff; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.filter-group { + border: 1px solid #ddd; + border-radius: 6px; + padding: 8px 10px; + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.filter-group legend { + font-weight: bold; + padding: 0 4px; +} + +.filter-group label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +/* Wraps into as many columns as the panel has room for */ +.filter-types { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 2px 6px; +} + +.filter-type { + text-transform: capitalize; +} + +.filter input { + width: auto; + margin: 0; + cursor: pointer; +} + +.filter-reset { + align-self: flex-start; + cursor: pointer; + padding: 4px 8px; +} + +.filter summary:focus-visible, +.filter input:focus-visible, +.filter-reset:focus-visible { + outline: 2px solid #007bff; + outline-offset: 2px; +} + +/* On a narrow screen the panel fills the width instead of floating */ +@media (max-width: 1000px) { + .filter-panel { + position: static; + width: auto; + max-height: none; + } +} diff --git a/src/components/Filter/Filter.test.tsx b/src/components/Filter/Filter.test.tsx new file mode 100644 index 0000000..8ec0122 --- /dev/null +++ b/src/components/Filter/Filter.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { Filter } from "./Filter"; +import type { FilterRules } from "../../models/filters"; + +function setup(filters: FilterRules = {}) { + const onChange = vi.fn(); + const onReset = vi.fn(); + + render(); + + return { onChange, onReset }; +} + +describe("Filter Component", () => { + test("shows the rules it is given as props", () => { + setup({ sort: "id-desc", type: ["fire"], onlyFavorites: true }); + + expect(screen.getByLabelText("Highest id first")).toBeChecked(); + expect(screen.getByLabelText("fire")).toBeChecked(); + expect(screen.getByLabelText("Only show favorites")).toBeChecked(); + expect(screen.getByLabelText("water")).not.toBeChecked(); + }); + + test("defaults to lowest id first when no sort is stored", () => { + setup(); + + expect(screen.getByLabelText("Lowest id first")).toBeChecked(); + expect(screen.getByLabelText("Highest id first")).not.toBeChecked(); + }); + + test("counts the active rules in the summary", () => { + setup({ sort: "id-desc", type: ["fire", "water"], onlyFavorites: true }); + + expect(screen.getByText("Filters (4)")).toBeInTheDocument(); + }); + + test("reports a picked type without dropping the ones already picked", () => { + const { onChange } = setup({ type: ["fire"] }); + + fireEvent.click(screen.getByLabelText("water")); + + expect(onChange).toHaveBeenCalledWith({ type: ["fire", "water"] }); + }); + + test("removes a type that is picked again", () => { + const { onChange } = setup({ type: ["fire", "water"] }); + + fireEvent.click(screen.getByLabelText("fire")); + + expect(onChange).toHaveBeenCalledWith({ type: ["water"] }); + }); + + test("reports a change of sort order", () => { + const { onChange } = setup(); + + fireEvent.click(screen.getByLabelText("Highest id first")); + + expect(onChange).toHaveBeenCalledWith({ sort: "id-desc" }); + }); + + test("reports the favorites toggle", () => { + const { onChange } = setup(); + + fireEvent.click(screen.getByLabelText("Only show favorites")); + + expect(onChange).toHaveBeenCalledWith({ onlyFavorites: true }); + }); + + test("calls onReset from the reset button", () => { + const { onReset } = setup({ type: ["fire"] }); + + fireEvent.click(screen.getByRole("button", { name: "Reset filters" })); + + expect(onReset).toHaveBeenCalledTimes(1); + }); + + test("matches snapshot", () => { + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx new file mode 100644 index 0000000..1a08267 --- /dev/null +++ b/src/components/Filter/Filter.tsx @@ -0,0 +1,90 @@ +import "./Filter.css"; +import { POKEMON_TYPES } from "../../models/filters"; +import type { FilterRules } from "../../models/filters"; + +type FilterProps = { + filters: FilterRules; + onChange: (changes: Partial) => void; + onReset: () => void; +}; + +export function Filter({ filters, onChange, onReset }: FilterProps) { + const selectedTypes = filters.type ?? []; + + // Adds the type if it is not picked yet, removes it if it is + const toggleType = (type: string) => { + const next = selectedTypes.includes(type) + ? selectedTypes.filter((selected) => selected !== type) + : [...selectedTypes, type]; + + onChange({ type: next }); + }; + + const activeCount = + selectedTypes.length + + (filters.onlyFavorites ? 1 : 0) + + (filters.sort === "id-desc" ? 1 : 0); + + return ( +
+ Filters{activeCount > 0 ? ` (${activeCount})` : ""} +
+
+ Sort + + +
+ +
+ Type +
+ {POKEMON_TYPES.map((type) => ( + + ))} +
+
+ +
+ Favorites + +
+ + +
+
+ ); +} diff --git a/src/components/Filter/__snapshots__/Filter.test.tsx.snap b/src/components/Filter/__snapshots__/Filter.test.tsx.snap new file mode 100644 index 0000000..b05d560 --- /dev/null +++ b/src/components/Filter/__snapshots__/Filter.test.tsx.snap @@ -0,0 +1,216 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Filter Component > matches snapshot 1`] = ` + +
+ + Filters (1) + +
+
+ + Sort + + + +
+
+ + Type + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + Favorites + + +
+ +
+
+
+`; diff --git a/src/components/PokemonCard/PokemonCard.css b/src/components/PokemonCard/PokemonCard.css index 4a8a922..42e23ca 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,32 @@ .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; +} + +/* Wraps the sprite so it can be cycled with a keyboard */ +.sprite-button { + background: none; + border: none; + padding: 0; + cursor: pointer; + display: inline-block; +} + +.sprite-button:focus-visible { + outline: 2px solid #007bff; + outline-offset: 2px; +} + .type-badges { display: flex; gap: 6px; @@ -82,6 +110,9 @@ } .cell-num { + display: flex; + align-self: center; + justify-self: center; text-align: right; white-space: nowrap; } @@ -106,7 +137,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..9865af1 100644 --- a/src/components/PokemonCard/PokemonCard.test.tsx +++ b/src/components/PokemonCard/PokemonCard.test.tsx @@ -1,40 +1,72 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, test } from "vitest"; import { PokemonCard } from "./PokemonCard"; +import { DUMMY_DATA } from "../../utils/testData"; -const mockPokemon = { - name: "pikachu", - sprites: { - front_default: - "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png", - }, - height: 4, - weight: 60, - types: [{ type: { name: "electric" } }], - abilities: [{ ability: { name: "static" }, is_hidden: false }], - 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" } }, - ], -}; +const mockPokemon = DUMMY_DATA[2]; + +function spriteSrc() { + return screen.getByRole("img").getAttribute("src"); +} describe("PokemonCard Component", () => { test("renders pokemon name and general information", () => { - render(); + render(); expect(screen.getByText("Pikachu")).toBeDefined(); expect(screen.getByText("electric")).toBeDefined(); - expect(screen.getByText("static")).toBeDefined(); + // Abilities render as one block of text, so match on a substring + expect(screen.getByText(/static/)).toBeDefined(); expect(screen.getByText("0.4 m")).toBeDefined(); expect(screen.getByText("6 kg")).toBeDefined(); }); + test("adds up the base stats", () => { + render(); + + const total = mockPokemon.stats.reduce( + (sum, stat) => sum + stat.base_stat, + 0, + ); + + expect(screen.getByText(String(total))).toBeInTheDocument(); + }); + + test("steps through the sprites when the image is clicked", () => { + render(); + + expect(spriteSrc()).toBe(mockPokemon.sprites.front_default); + + fireEvent.click(screen.getByRole("button")); + expect(spriteSrc()).toBe(mockPokemon.sprites.back_default); + + fireEvent.click(screen.getByRole("button")); + expect(spriteSrc()).toBe(mockPokemon.sprites.front_shiny); + }); + + test("wraps back to the first sprite at the end", () => { + render(); + + for (let i = 0; i < 4; i++) { + fireEvent.click(screen.getByRole("button")); + } + + expect(spriteSrc()).toBe(mockPokemon.sprites.front_default); + }); + + test("goes back to the default sprite when another pokemon is shown", () => { + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button")); + expect(spriteSrc()).toBe(mockPokemon.sprites.back_default); + + rerender(); + + expect(spriteSrc()).toBe(DUMMY_DATA[0].sprites.front_default); + }); + 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..3b8b78b 100644 --- a/src/components/PokemonCard/PokemonCard.tsx +++ b/src/components/PokemonCard/PokemonCard.tsx @@ -1,20 +1,45 @@ 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: Record = { + 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 sprites = spriteList(pokemon); + + const [spriteIndex, setSpriteIndex] = useState(0); + const [shownId, setShownId] = useState(pokemon.id); + + // Start over at the default sprite when a different pokemon is shown + if (shownId !== pokemon.id) { + setShownId(pokemon.id); + setSpriteIndex(0); + } + const displayName = pokemon.name.charAt(0).toUpperCase() + pokemon.name.slice(1); @@ -27,35 +52,50 @@ export function PokemonCard({ pokemon }: PokemonCardProps) { speed: "Speed", }; - let total = 0; + const total = pokemon.stats.reduce((sum, stat) => sum + stat.base_stat, 0); return (

{displayName}

- {`${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")} +
@@ -63,7 +103,6 @@ export function PokemonCard({ pokemon }: PokemonCardProps) { {pokemon.stats.map((stat) => { const base = stat.base_stat; - total += base; const min = stat.stat.name === "hp" @@ -134,6 +173,16 @@ function calcHighMaxStat(base: number): number { return Math.floor((2 * base + 31 + 252 / 4 + 5) * 1.1); } +// The sprites to cycle through, skipping the ones the api left empty +function spriteList(pokemon: PokemonData): string[] { + return [ + pokemon.sprites.front_default, + pokemon.sprites.back_default, + pokemon.sprites.front_shiny, + pokemon.sprites.back_shiny, + ].filter(Boolean); +} + // 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..9a34654 --- /dev/null +++ b/src/components/PokemonCard/__snapshots__/PokemonCard.test.tsx.snap @@ -0,0 +1,276 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PokemonCard Component > matches snapshot 1`] = ` + +
+

+ Pikachu +

+ +
+
+ + Type: + +
+ + electric + +
+
+
+ + Height: + + + 0.4 m + +
+
+ + Weight: + + + + 6 kg + +
+
+ + Abilities: + + + static +lightning-rod (Hidden) + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 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 index a6dacd9..0053971 100644 --- a/src/components/PokemonList/PokemonList.test.tsx +++ b/src/components/PokemonList/PokemonList.test.tsx @@ -1,44 +1,111 @@ -import { describe, expect, test, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { PokemonList } from "./PokemonList"; +import * as pokemonController from "../../controllers/pokemonController"; +import { DUMMY_DATA } from "../../utils/testData"; +import { createWrapper } from "../../utils/testWrapper"; +import type { FilterRules } from "../../models/filters"; -const mockPokemons = [ - { id: 25, name: "pikachu", sprite: "pikachu.png" }, - { id: 4, name: "charmander", sprite: "charmander.png" }, - { id: 1, name: "bulbasaur", sprite: "bulbasaur.png" }, -]; +vi.mock("../../controllers/pokemonController", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../controllers/pokemonController") + >(); + return { + ...actual, + GetPrevFiltered: vi.fn(), + GetNextFiltered: vi.fn(), + }; +}); + +// jsdom has no layout, so the scroll the list does after loading is a no-op +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); + +function renderList(filters: FilterRules, favoritePokemons = DUMMY_DATA) { + const onSelectPokemon = vi.fn(); + + render( + , + { wrapper: createWrapper() }, + ); + + return { onSelectPokemon }; +} + +function renderedNames() { + return screen + .getAllByRole("button") + .map((button) => button.textContent?.replace(/[^a-z]/g, "")); +} describe("PokemonList Component", () => { - test("renders sorted list of pokemon", () => { - render( {}} />); + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("lists the neighbours it fetched, lowest id first", async () => { + vi.spyOn(pokemonController, "GetPrevFiltered").mockResolvedValue(null); + vi.spyOn(pokemonController, "GetNextFiltered") + .mockResolvedValueOnce(DUMMY_DATA[1]) + .mockResolvedValueOnce(DUMMY_DATA[2]); - const items = screen.getAllByRole("button"); - expect(items).toHaveLength(3); + renderList({}); - // 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"); + await waitFor(() => { + expect(renderedNames()).toEqual(["bulbasaur", "charmander", "pikachu"]); + }); }); - test("calls onSelect with correct ID when clicked", () => { - const handleSelect = vi.fn(); - render(); + test("turns the order around when sorting on highest id", async () => { + vi.spyOn(pokemonController, "GetPrevFiltered").mockResolvedValue(null); + vi.spyOn(pokemonController, "GetNextFiltered") + .mockResolvedValueOnce(DUMMY_DATA[1]) + .mockResolvedValueOnce(DUMMY_DATA[2]); - const pikachuButton = screen.getByText("pikachu"); - fireEvent.click(pikachuButton); + renderList({ sort: "id-desc" }); - expect(handleSelect).toHaveBeenCalledWith(25); + await waitFor(() => { + expect(renderedNames()).toEqual(["pikachu", "charmander", "bulbasaur"]); + }); }); - test("matches snapshot", () => { - const { asFragment } = render( - {}} - selectedId={4} - />, - ); - expect(asFragment()).toMatchSnapshot(); + test("shows the favorites it was handed without fetching anything", async () => { + const getNext = vi.spyOn(pokemonController, "GetNextFiltered"); + const getPrev = vi.spyOn(pokemonController, "GetPrevFiltered"); + + renderList({ onlyFavorites: true }, [DUMMY_DATA[2], DUMMY_DATA[0]]); + + await waitFor(() => { + expect(renderedNames()).toEqual(["bulbasaur", "pikachu"]); + }); + + expect(getNext).not.toHaveBeenCalled(); + expect(getPrev).not.toHaveBeenCalled(); + }); + + test("applies the other rules to the favorites as well", async () => { + renderList({ onlyFavorites: true, type: ["electric"] }); + + await waitFor(() => { + expect(renderedNames()).toEqual(["pikachu"]); + }); + }); + + test("says so when nothing is left to show", async () => { + renderList({ onlyFavorites: true }, []); + + await waitFor(() => { + expect( + screen.getByText("No Pokémon available to display."), + ).toBeInTheDocument(); + }); }); }); diff --git a/src/components/PokemonList/PokemonList.tsx b/src/components/PokemonList/PokemonList.tsx index 7de40f2..26b7e56 100644 --- a/src/components/PokemonList/PokemonList.tsx +++ b/src/components/PokemonList/PokemonList.tsx @@ -1,54 +1,142 @@ import "./PokemonList.css"; - -export type ListPokemon = { - id: number; - name: string; - sprite: string; -}; +import { useEffect, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + GetPrevFiltered, + GetNextFiltered, + matchesFilter, +} from "../../controllers/pokemonController"; +import type { PokemonData } from "../../controllers/pokemonController"; +import type { FilterRules } from "../../models/filters"; type PokemonListProps = { - pokemons: ListPokemon[]; - onSelect: (id: number) => void; - selectedId?: number; + current: PokemonData; + sizeOfList: number; + filters: FilterRules; + favoritePokemons: PokemonData[]; + onSelectPokemon: (id: number) => void; }; export function PokemonList({ - pokemons, - onSelect, - selectedId, + current, + sizeOfList, + filters, + favoritePokemons, + onSelectPokemon, }: PokemonListProps) { - // Ensure the list is always sorted by ID as required - const sortedPokemons = [...pokemons].sort((a, b) => a.id - b.id); + const listRef = useRef(null); + const onlyFavorites = filters.onlyFavorites === true; + + // Cached by TanStack Query so revisiting an id does not refetch the list. + // Skipped for the favorites view, which reuses what useFavorites already has + const { data: fetchedList = [], isPending } = useQuery({ + queryKey: ["pokemonList", current.id, sizeOfList, filters], + queryFn: () => buildPokemonList(current, sizeOfList, filters), + enabled: !onlyFavorites, + }); + + const loading = !onlyFavorites && isPending; + + const pokemonList = onlyFavorites + ? favoritePokemons.filter((pokemon) => matchesFilter(pokemon, filters)) + : fetchedList; + + const ordered = [...pokemonList].sort((a, b) => + filters.sort === "id-desc" ? b.id - a.id : a.id - b.id, + ); + + 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 (ordered.length === 0) { return (
No Pokémon available to display.
); } return ( -
- {sortedPokemons.map((pokemon) => { - const isSelected = pokemon.id === selectedId; +
+ {ordered.map((item) => { + const isSelected = item.id === current.id; return ( ); })}
); } + +async function buildPokemonList( + current: PokemonData, + sizeOfList: number, + filters: FilterRules, +): 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, filters); + 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, filters); + 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..542be27 100644 --- a/src/controllers/pokemonController.test.ts +++ b/src/controllers/pokemonController.test.ts @@ -1,4 +1,4 @@ -// generated with AI +// generated with AI import { describe, it, expect, vi, beforeEach } from "vitest"; import { matchesFilter, @@ -6,93 +6,76 @@ import { 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); + it("should return true when no filters are applied", () => { + expect(matchesFilter(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(matchesFilter(DUMMY_DATA[2], { type: ["electric"] })).toBe(true); + expect(matchesFilter(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(matchesFilter(DUMMY_DATA[2], { ability: "static" })).toBe(true); + expect(matchesFilter(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(matchesFilter(DUMMY_DATA[2], validRules)).toBe(true); + expect(matchesFilter(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(matchesFilter(DUMMY_DATA[2], { total: "300-350" })).toBe(true); + expect(matchesFilter(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(); @@ -100,30 +83,56 @@ describe("pokemonController", () => { }); 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" } }] }; + it("should return the next pokemon when no rules are set", async () => { + vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce( + DUMMY_DATA[1], + ); + + const result = await GetNextFiltered(3, {}); + + expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(4); + expect(result).toEqual(DUMMY_DATA[1]); + }); + it("should skip past the pokemon that do not match the rules", async () => { vi.spyOn(pokemonApi, "getPokemonByID") - .mockResolvedValueOnce(mockPikachu) - .mockResolvedValueOnce(Charmander); + .mockResolvedValueOnce(DUMMY_DATA[0]) // grass, skipped + .mockResolvedValueOnce(DUMMY_DATA[1]) // fire, skipped + .mockResolvedValueOnce(DUMMY_DATA[2]); // electric, kept - const result = await GetNextFiltered(2, { type: ["fire"] }); + const result = await GetNextFiltered(0, { type: ["electric"] }); - expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(2); - expect(result).toEqual(Charmander); + expect(result).toEqual(DUMMY_DATA[2]); + expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(3); }); - it("dovrebbe trovare il Pokémon precedente che rispetta i filtri", async () => { - const Bulbasaur: PokemonData = { ...mockPikachu, id: 1, types: [{ type: { name: "grass" } }] }; + it("should walk backwards for GetPrevFiltered", async () => { + vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce( + DUMMY_DATA[1], + ); + + const result = await GetPrevFiltered(5, {}); + + expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(4); + expect(result).toEqual(DUMMY_DATA[1]); + }); + + it("should return null once there is nothing left to walk to", async () => { + const result = await GetPrevFiltered(1, {}); + + expect(result).toBeNull(); + expect(pokemonApi.getPokemonByID).not.toHaveBeenCalled(); + }); + it("should keep going when a fetch fails instead of giving up", async () => { vi.spyOn(pokemonApi, "getPokemonByID") - .mockResolvedValueOnce(mockPikachu) - .mockResolvedValueOnce(Bulbasaur); + .mockRejectedValueOnce(new Error("API Error")) + .mockResolvedValueOnce(DUMMY_DATA[2]); - const result = await GetPrevFiltered(3, { type: ["grass"] }); + const result = await GetNextFiltered(23, {}); + expect(result).toEqual(DUMMY_DATA[2]); 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..cd7872b 100644 --- a/src/controllers/pokemonController.ts +++ b/src/controllers/pokemonController.ts @@ -1,9 +1,15 @@ import { getPokemonByID } from "../api/pokemon"; +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 +17,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 { @@ -34,15 +26,20 @@ function isInRange(val: number, rangeStr?: string): boolean { } // Validation logic based on provided rules -export function matchesFilter(pokemon: PokemonData, rules: FilterRules): boolean { +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)); + 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,11 +54,12 @@ 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; } @@ -75,8 +73,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 +83,21 @@ export async function GetPrev(currentId: number): Promise { return await fetchSafe(currentId - 1); } +// fetches many ids in parallel and drops the ones that failed +export async function GetMany(ids: number[]): Promise { + const results = await Promise.all(ids.map(fetchSafe)); + return results.filter((pokemon): pokemon is PokemonData => pokemon !== null); +} export async function GetPrevFiltered( currentId: number, - filters: FilterRules + rules: FilterRules, ): Promise { let prevId = currentId - 1; while (prevId >= 1) { const pokemon = await fetchSafe(prevId); - if (pokemon && matchesFilter(pokemon, filters)) { + if (pokemon && matchesFilter(pokemon, rules)) { return pokemon; } prevId--; @@ -106,17 +107,16 @@ export async function GetPrevFiltered( export async function GetNextFiltered( currentId: number, - filters: FilterRules + rules: 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, rules)) { return pokemon; } nextId++; } return null; } - diff --git a/src/controllers/useFavorites.test.ts b/src/controllers/useFavorites.test.ts index 24dcebe..8a682fb 100644 --- a/src/controllers/useFavorites.test.ts +++ b/src/controllers/useFavorites.test.ts @@ -1,44 +1,89 @@ -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"; +import { createWrapper } from "../utils/testWrapper"; + +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", () => { - const { result } = renderHook(() => useFavorites()); + test("starts empty when nothing is stored", async () => { + const getManySpy = vi.spyOn(pokemonController, "GetMany"); + const { result } = renderHook(() => useFavorites(), { + wrapper: createWrapper(), + }); - expect(result.current.favorites).toEqual([]); + expect(result.current.favoritePokemons).toEqual([]); expect(result.current.isFavorite(1)).toBe(false); + expect(result.current.isLoadingFavorites).toBe(false); + + // No ids stored, so there is nothing to ask the API for + expect(getManySpy).not.toHaveBeenCalled(); }); - 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") + .mockResolvedValue(mockPokemons); - const { result } = renderHook(() => useFavorites()); + const { result } = renderHook(() => useFavorites(), { + wrapper: createWrapper(), + }); - expect(result.current.favorites).toEqual([1, 25]); expect(result.current.isFavorite(25)).toBe(true); + + await waitFor(() => { + expect(result.current.favoritePokemons).toEqual(mockPokemons); + }); + + expect(getManySpy).toHaveBeenCalledWith([1, 25]); }); - test("toggleFavorite adds and removes and updates storage", () => { - const { result } = renderHook(() => useFavorites()); + test("toggleFavorite adds and removes and updates storage and pokemon list", async () => { + const getManySpy = vi.spyOn(pokemonController, "GetMany"); + getManySpy.mockResolvedValue([DUMMY_DATA[1]]); + + const { result } = renderHook(() => useFavorites(), { + wrapper: createWrapper(), + }); act(() => { result.current.toggleFavorite(4); }); - expect(result.current.favorites).toEqual([4]); expect(window.localStorage.getItem("favorites")).toBe("[4]"); + expect(result.current.isFavorite(4)).toBe(true); + + await waitFor(() => { + expect(result.current.favoritePokemons).toEqual([DUMMY_DATA[1]]); + }); + expect(getManySpy).toHaveBeenCalledWith([4]); act(() => { result.current.toggleFavorite(4); }); - expect(result.current.favorites).toEqual([]); expect(window.localStorage.getItem("favorites")).toBe("[]"); + expect(result.current.isFavorite(4)).toBe(false); + + // Back to an empty id list, so the list empties without another fetch + await waitFor(() => { + expect(result.current.favoritePokemons).toEqual([]); + }); }); }); diff --git a/src/controllers/useFavorites.ts b/src/controllers/useFavorites.ts index 09e509f..fa15a1a 100644 --- a/src/controllers/useFavorites.ts +++ b/src/controllers/useFavorites.ts @@ -1,28 +1,42 @@ import { useCallback, useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { loadFavorites, saveFavorites, toggleFavorite as toggleInList, } from "../models/favorites"; +import { GetMany } from "./pokemonController"; export function useFavorites() { // 1. Lazy initialization from localStorage - const [favorites, setFavorites] = useState(loadFavorites); + const [favoriteIds, setFavoriteIds] = useState(loadFavorites); // 2. Keep storage in sync so favorites survive closing the browser useEffect(() => { - saveFavorites(favorites); - }, [favorites]); + saveFavorites(favoriteIds); + }, [favoriteIds]); - // 3. Handlers passed down to the components + // 3. Fetch through TanStack Query so the data is cached between toggles + const { data: favoritePokemons = [], isPending } = useQuery({ + queryKey: ["favorites", favoriteIds], + queryFn: () => GetMany(favoriteIds), + enabled: favoriteIds.length > 0, + }); + + // 4. 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: favoriteIds.length > 0 && isPending, + 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..217b680 100644 --- a/src/models/filters.ts +++ b/src/models/filters.ts @@ -1,33 +1,69 @@ -export type SortOption = "id-asc" | "id-desc" | "name-asc" | "name-desc"; +// Only id sorting, name sorting would need every pokemon fetched up front +export type SortOption = "id-asc" | "id-desc"; -export type FilterSettings = { - type: string | null; - sort: SortOption; - onlyFavorites: boolean; -}; +export const SORT_OPTIONS: SortOption[] = ["id-asc", "id-desc"]; + +// The types a pokemon can have, used to build the type filter +export const POKEMON_TYPES = [ + "normal", + "fighting", + "flying", + "poison", + "ground", + "rock", + "bug", + "ghost", + "steel", + "fire", + "water", + "grass", + "electric", + "psychic", + "ice", + "dragon", + "dark", + "fairy", +] as const; + +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 const SORT_OPTIONS: SortOption[] = [ - "id-asc", - "id-desc", - "name-asc", - "name-desc", -]; - -export const DEFAULT_FILTERS: FilterSettings = { - type: null, - sort: "id-asc", - onlyFavorites: false, +// The keys that hold a plain string and parse the same way +export type StringFilterKey = Exclude< + FilterKey, + "sort" | "onlyFavorites" | "type" +>; + +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 +81,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 +93,50 @@ 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 isStringKey(key: FilterKey): key is StringFilterKey { + return key !== "sort" && key !== "onlyFavorites" && key !== "type"; +} + +function parseFilterRules(rawJson: Record): FilterRules { + const rules: FilterRules = {}; + + // parse sort, dropped unless it is a known option + if (isSortOption(rawJson.sort)) { + rules.sort = rawJson.sort; + } + + // parse onlyFavorites + if (typeof rawJson.onlyFavorites === "boolean") { + rules.onlyFavorites = rawJson.onlyFavorites; + } + + // parse type, non-string entries are dropped + if (Array.isArray(rawJson.type)) { + rules.type = rawJson.type.filter( + (item): item is string => typeof item === "string", + ); + } + + // parse the rest, only string values left + for (const key of FILTER_KEYS.filter(isStringKey)) { + const value = rawJson[key]; + if (typeof value === "string") { + rules[key] = value; + } + } + + return rules; +} diff --git a/src/test/setupTests.ts b/src/test/setupTests.ts index d0de870..cfa2e3c 100644 --- a/src/test/setupTests.ts +++ b/src/test/setupTests.ts @@ -1 +1,3 @@ -import "@testing-library/jest-dom"; +// The vitest entry point also registers the matcher types, so tests can use +// toBeChecked and friends without tsc complaining +import "@testing-library/jest-dom/vitest"; 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, +}; diff --git a/src/utils/testWrapper.tsx b/src/utils/testWrapper.tsx new file mode 100644 index 0000000..ed6baa6 --- /dev/null +++ b/src/utils/testWrapper.tsx @@ -0,0 +1,18 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +// A fresh QueryClient per test so nothing is cached between them, +// with retries off so a rejected mock fails instead of hanging +export function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + }, + }); + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +}