Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions PTG/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.103.0",
Expand All @@ -20,6 +22,9 @@
"@babel/core": "^7.29.7",
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
Expand All @@ -30,8 +35,10 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
"globals": "^17.11.0",
"jsdom": "^30.0.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.67.0",
"vite": "^8.2.2"
"vite": "^8.2.2",
"vitest": "^5.0.1"
}
}
708 changes: 703 additions & 5 deletions PTG/pnpm-lock.yaml

Large diffs are not rendered by default.

90 changes: 90 additions & 0 deletions PTG/src/ChoosePokemon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import ChoosePokemon from "./ChoosePokemon";
import { fetchGames } from "./games";
import { fetchTypes } from "./pokemonTypes";
import { withQueryClient } from "./test/withQueryClient";

vi.mock("./games", () => ({ fetchGames: vi.fn() }));
vi.mock("./pokemonTypes", () => ({ fetchTypes: vi.fn() }));

const mockedFetchGames = vi.mocked(fetchGames);
const mockedFetchTypes = vi.mocked(fetchTypes);

afterEach(() => {
vi.resetAllMocks();
});

function renderChoosePokemon(overrides: Partial<Parameters<typeof ChoosePokemon>[0]> = {}) {
const props = {
selectedGame: "",
selectedType: "any",
selectedName: "",
onGameChange: vi.fn(),
onTypeChange: vi.fn(),
onNameChange: vi.fn(),
...overrides,
};
render(<ChoosePokemon {...props} />, { wrapper: withQueryClient() });
return props;
}

describe("ChoosePokemon", () => {
it("lists games and types once loaded, with an 'Any Type' option prepended", async () => {
mockedFetchGames.mockResolvedValue([{ label: "Red Blue", value: "red-blue" }]);
mockedFetchTypes.mockResolvedValue([{ label: "Fire", value: "fire" }]);

renderChoosePokemon();

expect(await screen.findByRole("option", { name: "Red Blue" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Any Game" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Any Type" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Fire" })).toBeInTheDocument();
});

it("calls onNameChange as the user types", async () => {
mockedFetchGames.mockResolvedValue([]);
mockedFetchTypes.mockResolvedValue([]);
const user = userEvent.setup();

const props = renderChoosePokemon();
await user.type(screen.getByPlaceholderText("Name..."), "pika");

expect(props.onNameChange).toHaveBeenCalledTimes(4);
expect(props.onNameChange).toHaveBeenLastCalledWith("a");
});

it("calls onGameChange and onTypeChange when a select changes", async () => {
mockedFetchGames.mockResolvedValue([{ label: "Red Blue", value: "red-blue" }]);
mockedFetchTypes.mockResolvedValue([{ label: "Fire", value: "fire" }]);
const user = userEvent.setup();

const props = renderChoosePokemon();
await screen.findByRole("option", { name: "Red Blue" });

await user.selectOptions(screen.getAllByRole("combobox")[0], "red-blue");
expect(props.onGameChange).toHaveBeenCalledWith("red-blue");

await user.selectOptions(screen.getAllByRole("combobox")[1], "fire");
expect(props.onTypeChange).toHaveBeenCalledWith("fire");
});

it("shows an error message when games fail to load", async () => {
mockedFetchGames.mockRejectedValue(new Error("boom"));
mockedFetchTypes.mockResolvedValue([]);

renderChoosePokemon();

expect(await screen.findByText(/Failed to load games: boom/)).toBeInTheDocument();
});

it("shows an error message when types fail to load", async () => {
mockedFetchGames.mockResolvedValue([]);
mockedFetchTypes.mockRejectedValue(new Error("boom"));

renderChoosePokemon();

expect(await screen.findByText(/Failed to load types: boom/)).toBeInTheDocument();
});
});
13 changes: 13 additions & 0 deletions PTG/src/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MemoryRouter } from "react-router-dom";
import Header from "./Header";

describe("Header", () => {
it("links home and to the about page", () => {
render(<Header />, { wrapper: MemoryRouter });

expect(screen.getByRole("link", { name: "Pokémon Team Generator" })).toHaveAttribute("href", "/");
expect(screen.getByRole("link", { name: "About" })).toHaveAttribute("href", "/about");
});
});
1 change: 1 addition & 0 deletions PTG/src/PokemonGrid.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
position: relative;
width: 100%;
max-width: 120px;
padding-top: 24px;
}

.pokemonCardImage > img {
Expand Down
83 changes: 83 additions & 0 deletions PTG/src/PokemonGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import PokemonGrid from "./PokemonGrid";
import { useFilteredPokemon } from "./useFilteredPokemon";
import type { PokemonType } from "./pokemonTypes";

vi.mock("./useFilteredPokemon", () => ({ useFilteredPokemon: vi.fn() }));

const mockedUseFilteredPokemon = vi.mocked(useFilteredPokemon);

function baseResult(overrides: Partial<ReturnType<typeof useFilteredPokemon>> = {}): ReturnType<typeof useFilteredPokemon> {
return {
pokemon: [],
typeMap: undefined,
isLoading: false,
pokemonError: null,
typeError: null,
gameError: null,
...overrides,
};
}

afterEach(() => {
vi.resetAllMocks();
});

describe("PokemonGrid", () => {
it("renders a card per pokemon, capitalized, with its type icons", () => {
const typeMap = new Map<string, PokemonType[]>([["bulbasaur", [{ id: 12, name: "grass" }]]]);
mockedUseFilteredPokemon.mockReturnValue(
baseResult({
pokemon: [{ id: 1, name: "bulbasaur", image: "bulbasaur.png" }],
typeMap,
})
);

render(<PokemonGrid selectedGame="" selectedType="any" selectedName="" />);

expect(screen.getByText("Bulbasaur")).toBeInTheDocument();
expect(screen.getByAltText("bulbasaur")).toHaveAttribute("src", "bulbasaur.png");
expect(screen.getByAltText("grass")).toBeInTheDocument();
});

it("shows an empty message when loading finished with no matches", () => {
mockedUseFilteredPokemon.mockReturnValue(baseResult({ pokemon: [], isLoading: false }));

render(<PokemonGrid selectedGame="" selectedType="fire" selectedName="zzz" />);

expect(screen.getByText("No pokèmon found for current filter")).toBeInTheDocument();
});

it("shows nothing but does not error while still loading with no results yet", () => {
mockedUseFilteredPokemon.mockReturnValue(baseResult({ pokemon: [], isLoading: true }));

render(<PokemonGrid selectedGame="" selectedType="any" selectedName="" />);

expect(screen.queryByText("No pokèmon found for current filter")).not.toBeInTheDocument();
});

it("shows an error message when the pokemon list fails", () => {
mockedUseFilteredPokemon.mockReturnValue(baseResult({ pokemonError: new Error("boom") }));

render(<PokemonGrid selectedGame="" selectedType="any" selectedName="" />);

expect(screen.getByText(/Failed to load Pokémon: boom/)).toBeInTheDocument();
});

it("shows an error message when the type filter fails", () => {
mockedUseFilteredPokemon.mockReturnValue(baseResult({ typeError: new Error("boom") }));

render(<PokemonGrid selectedGame="" selectedType="fire" selectedName="" />);

expect(screen.getByText(/Failed to load type: boom/)).toBeInTheDocument();
});

it("shows an error message when the game filter fails", () => {
mockedUseFilteredPokemon.mockReturnValue(baseResult({ gameError: new Error("boom") }));

render(<PokemonGrid selectedGame="red-blue" selectedType="any" selectedName="" />);

expect(screen.getByText(/Failed to load game: boom/)).toBeInTheDocument();
});
});
102 changes: 102 additions & 0 deletions PTG/src/Team.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import Team from "./Team";
import { createBlankTeam } from "./pokemonTeam";
import type { Pokemon } from "./useFilteredPokemon";

const bulbasaur: Pokemon = { id: 1, name: "bulbasaur", image: "bulbasaur.png" };

describe("Team", () => {
it("renders empty slots when the team has no members", () => {
render(
<Team
team={createBlankTeam()}
pokemonById={new Map()}
typeMap={undefined}
allFavourited={false}
generateTeam={vi.fn()}
toggleFavourite={vi.fn()}
/>
);

expect(screen.getAllByText("Empty")).toHaveLength(6);
expect(screen.getAllByRole("button", { name: "Add to favourites" })).toHaveLength(6);
expect(screen.getAllByRole("button", { name: "Add to favourites" })[0]).toBeDisabled();
});

it("renders a filled slot with its pokemon name and image", () => {
const team = createBlankTeam();
team[0] = { id: 1, favourite: false };

render(
<Team
team={team}
pokemonById={new Map([[1, bulbasaur]])}
typeMap={undefined}
allFavourited={false}
generateTeam={vi.fn()}
toggleFavourite={vi.fn()}
/>
);

expect(screen.getByText("Bulbasaur")).toBeInTheDocument();
expect(screen.getByAltText("bulbasaur")).toHaveAttribute("src", "bulbasaur.png");
});

it("calls toggleFavourite with the slot index when its star is clicked", async () => {
const user = userEvent.setup();
const toggleFavourite = vi.fn();
const team = createBlankTeam();
team[2] = { id: 1, favourite: false };

render(
<Team
team={team}
pokemonById={new Map([[1, bulbasaur]])}
typeMap={undefined}
allFavourited={false}
generateTeam={vi.fn()}
toggleFavourite={toggleFavourite}
/>
);

const favouriteButtons = screen.getAllByRole("button", { name: "Add to favourites" });
const enabledButton = favouriteButtons.find((button) => !button.hasAttribute("disabled"));
await user.click(enabledButton!);

expect(toggleFavourite).toHaveBeenCalledWith(2);
});

it("calls generateTeam when the generate button is clicked, and disables it once every slot is favourited", async () => {
const user = userEvent.setup();
const generateTeam = vi.fn();

const { rerender } = render(
<Team
team={createBlankTeam()}
pokemonById={new Map()}
typeMap={undefined}
allFavourited={false}
generateTeam={generateTeam}
toggleFavourite={vi.fn()}
/>
);

await user.click(screen.getByRole("button", { name: "Generate Team" }));
expect(generateTeam).toHaveBeenCalledTimes(1);

rerender(
<Team
team={createBlankTeam()}
pokemonById={new Map()}
typeMap={undefined}
allFavourited={true}
generateTeam={generateTeam}
toggleFavourite={vi.fn()}
/>
);

expect(screen.getByRole("button", { name: "Generate Team" })).toBeDisabled();
});
});
Loading