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/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.test.tsx b/src/components/FavoritePokemon/FavoritePokemon.test.tsx new file mode 100644 index 0000000..4dea1b0 --- /dev/null +++ b/src/components/FavoritePokemon/FavoritePokemon.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { FavoritePokemon } from "./FavoritePokemon"; +import { DUMMY_DATA } from "../../utils/testData"; +import type { PokemonData } from "../../controllers/pokemonController"; + +function setup(pokemonList: PokemonData[], currentId = 1) { + const onSelectPokemon = vi.fn(); + + render( + , + ); + + return { onSelectPokemon }; +} + +describe("FavoritePokemon Component", () => { + test("renders the pokemon it is given", () => { + setup([DUMMY_DATA[0], DUMMY_DATA[2]]); + + expect(screen.getByText("bulbasaur")).toBeInTheDocument(); + expect(screen.getByText("pikachu")).toBeInTheDocument(); + expect(screen.getByText("#001")).toBeInTheDocument(); + expect(screen.getByText("#025")).toBeInTheDocument(); + }); + + test("orders by id no matter how the list arrives", () => { + setup([DUMMY_DATA[2], DUMMY_DATA[0], DUMMY_DATA[1]]); + + const names = screen + .getAllByRole("button") + .map((button) => button.textContent?.replace(/[^a-z]/g, "")); + + expect(names).toEqual(["bulbasaur", "charmander", "pikachu"]); + }); + + test("leaves the array it was given alone", () => { + const list = [DUMMY_DATA[2], DUMMY_DATA[0]]; + + setup(list); + + expect(list[0]).toBe(DUMMY_DATA[2]); + }); + + test("marks the one that is selected", () => { + setup([DUMMY_DATA[0], DUMMY_DATA[2]], 25); + + const selected = screen.getByRole("button", { name: /pikachu/ }); + + expect(selected).toHaveAttribute("aria-current", "true"); + expect( + screen.getByRole("button", { name: /bulbasaur/ }), + ).not.toHaveAttribute("aria-current"); + }); + + test("reports the pokemon the user picked", () => { + const { onSelectPokemon } = setup([DUMMY_DATA[0], DUMMY_DATA[2]]); + + fireEvent.click(screen.getByRole("button", { name: /pikachu/ })); + + expect(onSelectPokemon).toHaveBeenCalledWith(25); + }); + + test("says so when there are no favorites yet", () => { + setup([]); + + expect(screen.getByText("No favorites yet.")).toBeInTheDocument(); + expect(screen.queryAllByRole("button")).toHaveLength(0); + }); + + test("matches snapshot", () => { + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); +}); 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/PokemonCard/PokemonCard.test.tsx b/src/components/PokemonCard/PokemonCard.test.tsx index 59ce8e2..9865af1 100644 --- a/src/components/PokemonCard/PokemonCard.test.tsx +++ b/src/components/PokemonCard/PokemonCard.test.tsx @@ -1,10 +1,14 @@ -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 = DUMMY_DATA[2]; +function spriteSrc() { + return screen.getByRole("img").getAttribute("src"); +} + describe("PokemonCard Component", () => { test("renders pokemon name and general information", () => { render(); @@ -17,6 +21,50 @@ describe("PokemonCard Component", () => { 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(); expect(asFragment()).toMatchSnapshot(); diff --git a/src/controllers/pokemonController.test.ts b/src/controllers/pokemonController.test.ts index 18108c5..542be27 100644 --- a/src/controllers/pokemonController.test.ts +++ b/src/controllers/pokemonController.test.ts @@ -1,6 +1,12 @@ // generated with AI import { describe, it, expect, vi, beforeEach } from "vitest"; -import { matchesFilter, GetNext, GetPrev } from "./pokemonController"; +import { + matchesFilter, + GetNext, + GetPrev, + GetNextFiltered, + GetPrevFiltered, +} from "./pokemonController"; import * as pokemonApi from "../api/pokemon"; import { DUMMY_DATA } from "../utils/testData"; import type { FilterRules } from "../models/filters"; @@ -75,4 +81,58 @@ describe("pokemonController", () => { expect(result).toBeNull(); }); }); + + describe("GetNextFiltered / GetPrevFiltered", () => { + 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(DUMMY_DATA[0]) // grass, skipped + .mockResolvedValueOnce(DUMMY_DATA[1]) // fire, skipped + .mockResolvedValueOnce(DUMMY_DATA[2]); // electric, kept + + const result = await GetNextFiltered(0, { type: ["electric"] }); + + expect(result).toEqual(DUMMY_DATA[2]); + expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(3); + }); + + 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") + .mockRejectedValueOnce(new Error("API Error")) + .mockResolvedValueOnce(DUMMY_DATA[2]); + + const result = await GetNextFiltered(23, {}); + + expect(result).toEqual(DUMMY_DATA[2]); + expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(2); + }); + }); });