diff --git a/PTG/.prettierrc.json b/PTG/.prettierrc.json
new file mode 100644
index 0000000..ab28f3e
--- /dev/null
+++ b/PTG/.prettierrc.json
@@ -0,0 +1,3 @@
+{
+ "tabWidth": 4
+}
\ No newline at end of file
diff --git a/PTG/eslint.config.js b/PTG/eslint.config.js
index ef614d2..6bc01ce 100644
--- a/PTG/eslint.config.js
+++ b/PTG/eslint.config.js
@@ -4,6 +4,7 @@ import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
+import eslintConfigPrettier from "eslint-config-prettier/flat";
export default defineConfig([
globalIgnores(['dist']),
@@ -19,4 +20,5 @@ export default defineConfig([
globals: globals.browser,
},
},
+ eslintConfigPrettier,
])
diff --git a/PTG/package.json b/PTG/package.json
index cdfed20..1913514 100644
--- a/PTG/package.json
+++ b/PTG/package.json
@@ -32,10 +32,12 @@
"@vitejs/plugin-react": "^6.1.0",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^10.9.0",
+ "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
"globals": "^17.11.0",
"jsdom": "^30.0.1",
+ "prettier": "3.9.8",
"typescript": "~6.0.2",
"typescript-eslint": "^8.67.0",
"vite": "^8.2.2",
diff --git a/PTG/pnpm-lock.yaml b/PTG/pnpm-lock.yaml
index 482e4e4..e2959b4 100644
--- a/PTG/pnpm-lock.yaml
+++ b/PTG/pnpm-lock.yaml
@@ -63,6 +63,9 @@ importers:
eslint:
specifier: ^10.9.0
version: 10.9.1
+ eslint-config-prettier:
+ specifier: ^10.1.8
+ version: 10.1.8(eslint@10.9.1)
eslint-plugin-react-hooks:
specifier: ^7.1.1
version: 7.1.1(eslint@10.9.1)
@@ -75,6 +78,9 @@ importers:
jsdom:
specifier: ^30.0.1
version: 30.0.1
+ prettier:
+ specifier: 3.9.8
+ version: 3.9.8
typescript:
specifier: ~6.0.2
version: 6.0.3
@@ -726,6 +732,12 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ eslint-config-prettier@10.1.8:
+ resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
+ hasBin: true
+ peerDependencies:
+ eslint: '>=7.0.0'
+
eslint-plugin-react-hooks@7.1.1:
resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
engines: {node: '>=18'}
@@ -1074,6 +1086,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
+ prettier@3.9.8:
+ resolution: {integrity: sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==}
+ engines: {node: '>=14'}
+ hasBin: true
+
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -1974,6 +1991,10 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ eslint-config-prettier@10.1.8(eslint@10.9.1):
+ dependencies:
+ eslint: 10.9.1
+
eslint-plugin-react-hooks@7.1.1(eslint@10.9.1):
dependencies:
'@babel/core': 7.29.7
@@ -2293,6 +2314,8 @@ snapshots:
prelude-ls@1.2.1: {}
+ prettier@3.9.8: {}
+
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
diff --git a/PTG/pnpm-workspace.yaml b/PTG/pnpm-workspace.yaml
index 17a2321..22bf258 100644
--- a/PTG/pnpm-workspace.yaml
+++ b/PTG/pnpm-workspace.yaml
@@ -1,3 +1,4 @@
minimumReleaseAgeExclude:
- '@tanstack/query-core@5.103.0'
- '@tanstack/react-query@5.103.0'
+ - prettier@3.9.8
diff --git a/PTG/src/About.css b/PTG/src/About.css
index 36b1b82..dbc3e58 100644
--- a/PTG/src/About.css
+++ b/PTG/src/About.css
@@ -1,4 +1,4 @@
-.container{
+.container {
display: flex;
flex-direction: column;
align-items: center;
@@ -6,13 +6,13 @@
min-height: calc(89.5vh - 60px);
}
-.footer-pin{
+.footer-pin {
position: relative;
bottom: 0;
left: 0;
}
-p{
+p {
text-align: justify;
margin-bottom: 1rem;
-}
\ No newline at end of file
+}
diff --git a/PTG/src/About.tsx b/PTG/src/About.tsx
index f99f398..c67bbb6 100644
--- a/PTG/src/About.tsx
+++ b/PTG/src/About.tsx
@@ -2,26 +2,28 @@ import Header from "./Header.tsx";
import Footer from "./Footer.tsx";
import "./About.css";
-export default function About(){
- return(
+export default function About() {
+ return (
<>
About
- This is the result of a group project in IT2810 at the Norwegian
- University of Science and Technology (NTNU) by Team-33.
- Yes, we are aware that each Pokédex takes in National Dex as well, so
- it is possible getting, e.g. Charizard when picking HG/SS.
- This results in end game Pokémon showing up in the Team Generator.
- Please be aware of this when generating a team.
+ This is the result of a group project in IT2810 at the
+ Norwegian University of Science and Technology (NTNU) by
+ Team-33. Yes, we are aware that each Pokédex takes in
+ National Dex as well, so it is possible getting, e.g.
+ Charizard when picking HG/SS. This results in end game
+ Pokémon showing up in the Team Generator. Please be aware of
+ this when generating a team.
- This website is made by students, and is made for training reasons,
- also for lazy people not wanting to pick a team themselves. Contact information
- can be found at the footer of the home page and this page. Any feedback will be deeply appreciated,
- as it will be used as a tool to sharpen our skills and improve our work in the
- second project.
+ This website is made by students, and is made for training
+ reasons, also for lazy people not wanting to pick a team
+ themselves. Contact information can be found at the footer
+ of the home page and this page. Any feedback will be deeply
+ appreciated, as it will be used as a tool to sharpen our
+ skills and improve our work in the second project.
diff --git a/PTG/src/App.css b/PTG/src/App.css
index aa05d28..e64fdfe 100644
--- a/PTG/src/App.css
+++ b/PTG/src/App.css
@@ -1,9 +1,9 @@
-.content{
+.content {
min-height: calc(100vh - 60px);
}
-.footer-pin{
+.footer-pin {
position: relative;
bottom: 0;
left: 0;
-}
\ No newline at end of file
+}
diff --git a/PTG/src/App.tsx b/PTG/src/App.tsx
index 7824600..ca1e5ff 100644
--- a/PTG/src/App.tsx
+++ b/PTG/src/App.tsx
@@ -1,50 +1,66 @@
import { useEffect, useState } from "react";
-import Header from "./Header.tsx"
-import Footer from "./Footer.tsx"
-import "./App.css"
+import Header from "./Header.tsx";
+import Footer from "./Footer.tsx";
+import "./App.css";
import ChoosePokemon from "./ChoosePokemon.tsx";
import Team from "./Team.tsx";
import PokemonGrid from "./PokemonGrid.tsx";
import { loadStoredFilters, saveStoredFilters } from "./pokemonFilters.ts";
import { usePokemonTeam } from "./usePokemonTeam.ts";
-export default function App(){
- const [selectedGame, setSelectedGame] = useState(() => loadStoredFilters().selectedGame);
- const [selectedType, setSelectedType] = useState(() => loadStoredFilters().selectedType);
- const [selectedName, setSelectedName] = useState(() => loadStoredFilters().selectedName);
+export default function App() {
+ const [selectedGame, setSelectedGame] = useState(
+ () => loadStoredFilters().selectedGame,
+ );
+ const [selectedType, setSelectedType] = useState(
+ () => loadStoredFilters().selectedType,
+ );
+ const [selectedName, setSelectedName] = useState(
+ () => loadStoredFilters().selectedName,
+ );
- useEffect(() => {
- saveStoredFilters({ selectedGame, selectedType, selectedName });
- }, [selectedGame, selectedType, selectedName]);
+ useEffect(() => {
+ saveStoredFilters({ selectedGame, selectedType, selectedName });
+ }, [selectedGame, selectedType, selectedName]);
- const { team, pokemonById, typeMap, allFavourited, generateTeam, toggleFavourite } = usePokemonTeam(selectedGame, selectedType, selectedName);
+ const {
+ team,
+ pokemonById,
+ typeMap,
+ allFavourited,
+ generateTeam,
+ toggleFavourite,
+ } = usePokemonTeam(selectedGame, selectedType, selectedName);
- return(
-
-
- );
+ return (
+
+ );
}
diff --git a/PTG/src/ChoosePokemon.css b/PTG/src/ChoosePokemon.css
index 7fc56e2..8304e3f 100644
--- a/PTG/src/ChoosePokemon.css
+++ b/PTG/src/ChoosePokemon.css
@@ -1,18 +1,18 @@
.chooseType {
- field-sizing: content;
- width: fit-content;
- max-width: 100%;
- margin-left: 1%;
- min-height: 1.5rem;
- border-radius: 0;
- height: 20px;
+ field-sizing: content;
+ width: fit-content;
+ max-width: 100%;
+ margin-left: 1%;
+ min-height: 1.5rem;
+ border-radius: 0;
+ height: 20px;
}
.chooseName {
- field-sizing: content;
- width: fit-content;
- max-width: 100%;
- min-width: 50px;
- margin-left: 1rem;
- height: 20px;
-}
\ No newline at end of file
+ field-sizing: content;
+ width: fit-content;
+ max-width: 100%;
+ min-width: 50px;
+ margin-left: 1rem;
+ height: 20px;
+}
diff --git a/PTG/src/ChoosePokemon.test.tsx b/PTG/src/ChoosePokemon.test.tsx
index 6ea6df8..bb7752e 100644
--- a/PTG/src/ChoosePokemon.test.tsx
+++ b/PTG/src/ChoosePokemon.test.tsx
@@ -13,78 +13,99 @@ const mockedFetchGames = vi.mocked(fetchGames);
const mockedFetchTypes = vi.mocked(fetchTypes);
afterEach(() => {
- vi.resetAllMocks();
+ vi.resetAllMocks();
});
-function renderChoosePokemon(overrides: Partial
[0]> = {}) {
- const props = {
- selectedGame: "",
- selectedType: "any",
- selectedName: "",
- onGameChange: vi.fn(),
- onTypeChange: vi.fn(),
- onNameChange: vi.fn(),
- ...overrides,
- };
- render( , { wrapper: withQueryClient() });
- return props;
+function renderChoosePokemon(
+ overrides: Partial[0]> = {},
+) {
+ const props = {
+ selectedGame: "",
+ selectedType: "any",
+ selectedName: "",
+ onGameChange: vi.fn(),
+ onTypeChange: vi.fn(),
+ onNameChange: vi.fn(),
+ ...overrides,
+ };
+ render( , { 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();
- });
+ 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();
+ });
});
diff --git a/PTG/src/ChoosePokemon.tsx b/PTG/src/ChoosePokemon.tsx
index c0c0fd9..9e97377 100644
--- a/PTG/src/ChoosePokemon.tsx
+++ b/PTG/src/ChoosePokemon.tsx
@@ -4,11 +4,11 @@ import { fetchGames } from "./games";
import { fetchTypes } from "./pokemonTypes";
type TypeChoice = {
- label: string,
- value: string
-}
+ label: string;
+ value: string;
+};
-const ANY_TYPE: TypeChoice = { label: 'Any Type', value: 'any' };
+const ANY_TYPE: TypeChoice = { label: "Any Type", value: "any" };
type ChoosePokemonProps = {
selectedGame: string;
@@ -17,9 +17,16 @@ type ChoosePokemonProps = {
onGameChange: (value: string) => void;
onTypeChange: (value: string) => void;
onNameChange: (value: string) => void;
-}
+};
-export default function ChoosePokemon({ selectedGame, selectedType, selectedName, onGameChange, onTypeChange, onNameChange }: ChoosePokemonProps){
+export default function ChoosePokemon({
+ selectedGame,
+ selectedType,
+ selectedName,
+ onGameChange,
+ onTypeChange,
+ onNameChange,
+}: ChoosePokemonProps) {
const { data: games = [], error: gamesError } = useQuery({
queryKey: ["games"],
queryFn: fetchGames,
@@ -29,18 +36,34 @@ export default function ChoosePokemon({ selectedGame, selectedType, selectedName
queryFn: fetchTypes,
});
- function handleNameChange(e: React.ChangeEvent) { onNameChange(e.target.value); }
+ function handleNameChange(e: React.ChangeEvent) {
+ onNameChange(e.target.value);
+ }
- function handleGameChange(e: React.ChangeEvent) { onGameChange(e.target.value); }
+ function handleGameChange(e: React.ChangeEvent) {
+ onGameChange(e.target.value);
+ }
- function handleTypeChange(e: React.ChangeEvent) { onTypeChange(e.target.value); }
+ function handleTypeChange(e: React.ChangeEvent) {
+ onTypeChange(e.target.value);
+ }
const newTypes: TypeChoice[] = [ANY_TYPE, ...types];
- if (gamesError) return Failed to load games: {gamesError.message}
;
- if (typesError) return Failed to load types: {typesError.message}
;
+ if (gamesError)
+ return (
+
+ Failed to load games: {gamesError.message}
+
+ );
+ if (typesError)
+ return (
+
+ Failed to load types: {typesError.message}
+
+ );
- return(
+ return (
<>
))}
-
>
);
}
diff --git a/PTG/src/Footer.css b/PTG/src/Footer.css
index 7527671..d01856b 100644
--- a/PTG/src/Footer.css
+++ b/PTG/src/Footer.css
@@ -1,16 +1,16 @@
-.footer{
+.footer {
background-color: red;
- bottom: 0;
width: 100%;
padding-bottom: 10px;
border-top: solid black 3px;
}
-div{
+div {
margin: 0;
}
-.footer > p, a{
+.footer > p,
+a {
margin: 0;
margin-top: 5px;
text-align: center;
@@ -19,7 +19,7 @@ div{
transition: transform 0.3s ease-in-out;
}
-.names{
+.names {
display: flex;
flex-direction: row;
justify-content: space-evenly;
diff --git a/PTG/src/Footer.tsx b/PTG/src/Footer.tsx
index 6b4b51e..7a05fb6 100644
--- a/PTG/src/Footer.tsx
+++ b/PTG/src/Footer.tsx
@@ -1,22 +1,18 @@
-import './Footer.css'
+import "./Footer.css";
-export default function Header(){
- return(
+export default function Header() {
+ return (
Designed by:
-
);
-}
\ No newline at end of file
+}
diff --git a/PTG/src/Header.css b/PTG/src/Header.css
index fb064c0..09ca04a 100644
--- a/PTG/src/Header.css
+++ b/PTG/src/Header.css
@@ -8,7 +8,7 @@ header {
border-bottom: solid black 3px;
}
-.about{
+.about {
right: 5%;
position: absolute;
color: white;
@@ -17,11 +17,11 @@ header {
justify-content: right;
}
-.about:hover{
+.about:hover {
filter: brightness(90%);
}
-.websiteName{
+.websiteName {
color: white;
margin-top: 1px;
margin-bottom: 1px;
@@ -29,8 +29,8 @@ header {
width: 50%;
}
-@media screen and (max-width: 400px){
- .websiteName{
+@media screen and (max-width: 400px) {
+ .websiteName {
font-size: small;
}
}
diff --git a/PTG/src/Header.test.tsx b/PTG/src/Header.test.tsx
index 901e260..ebc4615 100644
--- a/PTG/src/Header.test.tsx
+++ b/PTG/src/Header.test.tsx
@@ -4,10 +4,15 @@ import { MemoryRouter } from "react-router-dom";
import Header from "./Header";
describe("Header", () => {
- it("links home and to the about page", () => {
- render(, { wrapper: MemoryRouter });
+ it("links home and to the about page", () => {
+ render(, { wrapper: MemoryRouter });
- expect(screen.getByRole("link", { name: "Pokémon Team Generator" })).toHaveAttribute("href", "/");
- expect(screen.getByRole("link", { name: "About" })).toHaveAttribute("href", "/about");
- });
+ expect(
+ screen.getByRole("link", { name: "Pokémon Team Generator" }),
+ ).toHaveAttribute("href", "/");
+ expect(screen.getByRole("link", { name: "About" })).toHaveAttribute(
+ "href",
+ "/about",
+ );
+ });
});
diff --git a/PTG/src/Header.tsx b/PTG/src/Header.tsx
index c5f7004..b99af90 100644
--- a/PTG/src/Header.tsx
+++ b/PTG/src/Header.tsx
@@ -1,15 +1,17 @@
-import './Header.css'
-import { Link } from 'react-router-dom'
+import "./Header.css";
+import { Link } from "react-router-dom";
-export default function Header(){
- return(
+export default function Header() {
+ return (
-
-
- Pokémon Team Generator
- About
-
-
+
+
+ Pokémon Team Generator
+
+
+ About
+
+
);
-}
\ No newline at end of file
+}
diff --git a/PTG/src/NotFoundPage.tsx b/PTG/src/NotFoundPage.tsx
index 6d0c0ac..dcaa78b 100644
--- a/PTG/src/NotFoundPage.tsx
+++ b/PTG/src/NotFoundPage.tsx
@@ -1,13 +1,12 @@
-import { Link } from 'react-router-dom';
+import { Link } from "react-router-dom";
-export default function NotFoundPage(){
- return(
+export default function NotFoundPage() {
+ return (
Page Not Found
Go back Home
-
);
-}
\ No newline at end of file
+}
diff --git a/PTG/src/PokemonGrid.css b/PTG/src/PokemonGrid.css
index e0b5122..1fcb61a 100644
--- a/PTG/src/PokemonGrid.css
+++ b/PTG/src/PokemonGrid.css
@@ -1,49 +1,49 @@
.pokemonGrid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
- gap: 1rem;
- padding: 1rem;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 1rem;
+ padding: 1rem;
}
.pokemonCard {
- display: flex;
- flex-direction: column;
- align-items: center;
- border: 1px solid #ddd;
- border-radius: 8px;
- padding: 0.5rem;
- text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ padding: 0.5rem;
+ text-align: center;
}
.pokemonCardImage {
- position: relative;
- width: 100%;
- max-width: 120px;
- padding-top: 24px;
+ position: relative;
+ width: 100%;
+ max-width: 120px;
+ padding-top: 24px;
}
.pokemonCardImage > img {
- width: 100%;
- height: auto;
- display: block;
+ width: 100%;
+ height: auto;
+ display: block;
}
.pokemonCardTypes {
- position: absolute;
- top: 2px;
- left: 2px;
- display: flex;
- gap: 2px;
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ display: flex;
+ gap: 2px;
}
.pokemonCardTypes img {
- width: 20px;
- height: 20px;
- filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.6));
+ width: 20px;
+ height: 20px;
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.6));
}
.pokemonGridError,
.pokemonGridEmpty {
- color: red;
- text-align: center;
+ color: red;
+ text-align: center;
}
diff --git a/PTG/src/PokemonGrid.test.tsx b/PTG/src/PokemonGrid.test.tsx
index de754da..4da6e25 100644
--- a/PTG/src/PokemonGrid.test.tsx
+++ b/PTG/src/PokemonGrid.test.tsx
@@ -8,76 +8,123 @@ vi.mock("./useFilteredPokemon", () => ({ useFilteredPokemon: vi.fn() }));
const mockedUseFilteredPokemon = vi.mocked(useFilteredPokemon);
-function baseResult(overrides: Partial> = {}): ReturnType {
- return {
- pokemon: [],
- typeMap: undefined,
- isLoading: false,
- pokemonError: null,
- typeError: null,
- gameError: null,
- ...overrides,
- };
+function baseResult(
+ overrides: Partial> = {},
+): ReturnType {
+ return {
+ pokemon: [],
+ typeMap: undefined,
+ isLoading: false,
+ pokemonError: null,
+ typeError: null,
+ gameError: null,
+ ...overrides,
+ };
}
afterEach(() => {
- vi.resetAllMocks();
+ vi.resetAllMocks();
});
describe("PokemonGrid", () => {
- it("renders a card per pokemon, capitalized, with its type icons", () => {
- const typeMap = new Map([["bulbasaur", [{ id: 12, name: "grass" }]]]);
- mockedUseFilteredPokemon.mockReturnValue(
- baseResult({
- pokemon: [{ id: 1, name: "bulbasaur", image: "bulbasaur.png" }],
- typeMap,
- })
- );
-
- render( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- expect(screen.getByText(/Failed to load game: boom/)).toBeInTheDocument();
- });
+ it("renders a card per pokemon, capitalized, with its type icons", () => {
+ const typeMap = new Map([
+ ["bulbasaur", [{ id: 12, name: "grass" }]],
+ ]);
+ mockedUseFilteredPokemon.mockReturnValue(
+ baseResult({
+ pokemon: [{ id: 1, name: "bulbasaur", image: "bulbasaur.png" }],
+ typeMap,
+ }),
+ );
+
+ render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(
+ screen.getByText(/Failed to load game: boom/),
+ ).toBeInTheDocument();
+ });
});
diff --git a/PTG/src/PokemonGrid.tsx b/PTG/src/PokemonGrid.tsx
index 02d5508..6f76eec 100644
--- a/PTG/src/PokemonGrid.tsx
+++ b/PTG/src/PokemonGrid.tsx
@@ -3,46 +3,75 @@ import { TYPE_ICON_URL, type PokemonType } from "./pokemonTypes";
import { useFilteredPokemon } from "./useFilteredPokemon";
function capitalize(name: string) {
- return name.charAt(0).toUpperCase() + name.slice(1);
+ return name.charAt(0).toUpperCase() + name.slice(1);
}
type PokemonGridProps = {
- selectedGame: string;
- selectedType: string;
- selectedName: string;
+ selectedGame: string;
+ selectedType: string;
+ selectedName: string;
};
-export default function PokemonGrid({ selectedGame, selectedType, selectedName }: PokemonGridProps) {
- const { pokemon, typeMap, isLoading, pokemonError, typeError, gameError } = useFilteredPokemon(selectedGame, selectedType, selectedName);
+export default function PokemonGrid({
+ selectedGame,
+ selectedType,
+ selectedName,
+}: PokemonGridProps) {
+ const { pokemon, typeMap, isLoading, pokemonError, typeError, gameError } =
+ useFilteredPokemon(selectedGame, selectedType, selectedName);
- if (pokemonError) return Failed to load Pokémon: {pokemonError.message}
;
- if (typeError) return Failed to load type: {typeError.message}
;
- if (gameError) return Failed to load game: {gameError.message}
;
+ if (pokemonError)
+ return (
+
+ Failed to load Pokémon: {pokemonError.message}
+
+ );
+ if (typeError)
+ return (
+
+ Failed to load type: {typeError.message}
+
+ );
+ if (gameError)
+ return (
+
+ Failed to load game: {gameError.message}
+
+ );
- if (!isLoading && pokemon.length === 0) {
- return No pokèmon found for current filter
;
- }
+ if (!isLoading && pokemon.length === 0) {
+ return (
+
+ No pokèmon found for current filter
+
+ );
+ }
- return (
-
- {pokemon.map(({ id, name, image }) => {
- const types = typeMap?.get(name);
- return (
-
-
-
- {types && types.length > 0 && (
-
- {types.map((type: PokemonType) => (
-
- ))}
-
- )}
-
-
{capitalize(name)}
-
- );
- })}
-
- );
+ return (
+
+ {pokemon.map(({ id, name, image }) => {
+ const types = typeMap?.get(name);
+ return (
+
+
+
+ {types && types.length > 0 && (
+
+ {types.map((type: PokemonType) => (
+
+ ))}
+
+ )}
+
+
{capitalize(name)}
+
+ );
+ })}
+
+ );
}
diff --git a/PTG/src/Team.css b/PTG/src/Team.css
index bbb0319..3304d91 100644
--- a/PTG/src/Team.css
+++ b/PTG/src/Team.css
@@ -1,97 +1,97 @@
-.generateTeamButton{
- background-color: rgb(252, 197, 59);
- border: solid 1px grey;
- border-radius: 5px;
- margin: 0px 1%;
+.generateTeamButton {
+ background-color: rgb(252, 197, 59);
+ border: solid 1px grey;
+ border-radius: 5px;
+ margin: 0px 1%;
}
-.generateTeamButton:hover{
- cursor: pointer;
- filter: brightness(90%);
+.generateTeamButton:hover {
+ cursor: pointer;
+ filter: brightness(90%);
}
.generateTeamButton:disabled {
- color: gray;
+ color: gray;
}
.teamGrid {
- display: grid;
- grid-template-columns: repeat(auto-fill, 30%);
- justify-content: center;
- gap: 1rem;
- padding: 1rem;
- border: solid 3px;
- border-color: rgb(252, 197, 59);
- border-left-style: none;
- border-right-style: none;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, 30%);
+ justify-content: center;
+ gap: 1rem;
+ padding: 1rem;
+ border: solid 3px;
+ border-color: rgb(252, 197, 59);
+ border-left-style: none;
+ border-right-style: none;
}
-.teamDisplay > h1{
- text-align: center;
+.teamDisplay > h1 {
+ text-align: center;
}
.teamCard {
- position: relative;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- min-height: 160px;
- border: 1px solid #ddd;
- border-radius: 8px;
- padding: 0.5rem;
- text-align: center;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 160px;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ padding: 0.5rem;
+ text-align: center;
}
.teamCardImage {
- position: relative;
- width: 100%;
- max-width: 120px;
- padding-top: 24px;
+ position: relative;
+ width: 100%;
+ max-width: 120px;
+ padding-top: 24px;
}
.teamCardImage > img {
- width: 100%;
- height: auto;
- display: block;
+ width: 100%;
+ height: auto;
+ display: block;
}
.teamCardTypes {
- position: absolute;
- top: 2px;
- left: 2px;
- display: flex;
- gap: 2px;
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ display: flex;
+ gap: 2px;
}
.teamCardTypes img {
- width: 20px;
- height: 20px;
- filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.6));
+ width: 20px;
+ height: 20px;
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.6));
}
.teamCardFavourite {
- position: absolute;
- top: 2px;
- right: 2px;
- padding: 0;
- border: none;
- background: none;
- font-size: 1.25rem;
- line-height: 1;
- color: #ccc;
- cursor: pointer;
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ padding: 0;
+ border: none;
+ background: none;
+ font-size: 1.25rem;
+ line-height: 1;
+ color: #ccc;
+ cursor: pointer;
}
.teamCardFavourite:disabled {
- color: #e5e5e5;
- cursor: not-allowed;
+ color: #e5e5e5;
+ cursor: not-allowed;
}
.teamCardFavourite.isFavourited {
- color: gold;
+ color: gold;
}
.teamCardEmpty {
- color: #999;
+ color: #999;
}
diff --git a/PTG/src/Team.test.tsx b/PTG/src/Team.test.tsx
index 42a913c..5ec2e39 100644
--- a/PTG/src/Team.test.tsx
+++ b/PTG/src/Team.test.tsx
@@ -8,95 +8,108 @@ 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(
-
- );
+ it("renders empty slots when the team has no members", () => {
+ render(
+ ,
+ );
- 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();
- });
+ 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 };
+ it("renders a filled slot with its pokemon name and image", () => {
+ const team = createBlankTeam();
+ team[0] = { id: 1, favourite: false };
- render(
-
- );
+ render(
+ ,
+ );
- expect(screen.getByText("Bulbasaur")).toBeInTheDocument();
- expect(screen.getByAltText("bulbasaur")).toHaveAttribute("src", "bulbasaur.png");
- });
+ 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 };
+ 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(
-
- );
+ render(
+ ,
+ );
- const favouriteButtons = screen.getAllByRole("button", { name: "Add to favourites" });
- const enabledButton = favouriteButtons.find((button) => !button.hasAttribute("disabled"));
- await user.click(enabledButton!);
+ 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);
- });
+ 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();
+ 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(
-
- );
+ const { rerender } = render(
+ ,
+ );
- await user.click(screen.getByRole("button", { name: "Generate Team" }));
- expect(generateTeam).toHaveBeenCalledTimes(1);
+ await user.click(screen.getByRole("button", { name: "Generate Team" }));
+ expect(generateTeam).toHaveBeenCalledTimes(1);
- rerender(
-
- );
+ rerender(
+ ,
+ );
- expect(screen.getByRole("button", { name: "Generate Team" })).toBeDisabled();
- });
+ expect(
+ screen.getByRole("button", { name: "Generate Team" }),
+ ).toBeDisabled();
+ });
});
diff --git a/PTG/src/Team.tsx b/PTG/src/Team.tsx
index dff44ac..90dfb01 100644
--- a/PTG/src/Team.tsx
+++ b/PTG/src/Team.tsx
@@ -3,48 +3,96 @@ import { TYPE_ICON_URL, type PokemonType } from "./pokemonTypes";
import type { PokemonTeam } from "./usePokemonTeam";
function capitalize(name: string) {
- return name.charAt(0).toUpperCase() + name.slice(1);
+ return name.charAt(0).toUpperCase() + name.slice(1);
}
-type TeamProps = Pick;
+type TeamProps = Pick<
+ PokemonTeam,
+ | "team"
+ | "pokemonById"
+ | "typeMap"
+ | "allFavourited"
+ | "generateTeam"
+ | "toggleFavourite"
+>;
-export default function Team({ team, pokemonById, typeMap, allFavourited, generateTeam, toggleFavourite }: TeamProps) {
- return (
- <>
- Generate Team
-
-
Your team:
-
- {team.map((slot, index) => {
- const member = slot.id !== null ? pokemonById.get(slot.id) : undefined;
- const types = member ? typeMap?.get(member.name) : undefined;
- return (
-
-
- {member &&
}
-
toggleFavourite(index)}
- disabled={slot.id === null}
- aria-label={slot.favourite ? "Remove from favourites" : "Add to favourites"}
- aria-pressed={slot.favourite}
- >
- ★
-
- {types && types.length > 0 && (
-
- {types.map((type: PokemonType) => (
-
- ))}
-
- )}
-
-
{member ? capitalize(member.name) : "Empty"}
-
- );
- })}
-
-
- >
- );
+export default function Team({
+ team,
+ pokemonById,
+ typeMap,
+ allFavourited,
+ generateTeam,
+ toggleFavourite,
+}: TeamProps) {
+ return (
+ <>
+
+ Generate Team
+
+
+
Your team:
+
+ {team.map((slot, index) => {
+ const member =
+ slot.id !== null
+ ? pokemonById.get(slot.id)
+ : undefined;
+ const types = member
+ ? typeMap?.get(member.name)
+ : undefined;
+ return (
+
+
+ {member && (
+
+ )}
+
toggleFavourite(index)}
+ disabled={slot.id === null}
+ aria-label={
+ slot.favourite
+ ? "Remove from favourites"
+ : "Add to favourites"
+ }
+ aria-pressed={slot.favourite}
+ >
+ ★
+
+ {types && types.length > 0 && (
+
+ {types.map((type: PokemonType) => (
+
+ ))}
+
+ )}
+
+
+ {member ? capitalize(member.name) : "Empty"}
+
+
+ );
+ })}
+
+
+ >
+ );
}
diff --git a/PTG/src/games.test.ts b/PTG/src/games.test.ts
index eabd376..5183469 100644
--- a/PTG/src/games.test.ts
+++ b/PTG/src/games.test.ts
@@ -2,104 +2,150 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchGames, fetchGameSpeciesIds } from "./games";
function jsonResponse(body: unknown, ok = true, status = 200): Response {
- return { ok, status, json: async () => body } as Response;
+ return { ok, status, json: async () => body } as Response;
}
afterEach(() => {
- vi.unstubAllGlobals();
+ vi.unstubAllGlobals();
});
describe("fetchGames", () => {
- it("labels, filters out pokedex-less version groups, and sorts by id", async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- const url = input.toString();
- if (url === "https://pokeapi.co/api/v2/version-group?limit=100") {
- return jsonResponse({
- results: [
- { name: "red-blue", url: "https://pokeapi.co/api/v2/version-group/1/" },
- { name: "colosseum", url: "https://pokeapi.co/api/v2/version-group/15/" },
- { name: "sword-shield", url: "https://pokeapi.co/api/v2/version-group/20/" },
- ],
- });
- }
- if (url.includes("version-group/1/")) {
- return jsonResponse({ pokedexes: [{ name: "kanto", url: "x" }] });
- }
- if (url.includes("version-group/15/")) {
- // Spin-off with no pokedex - should be excluded.
- return jsonResponse({ pokedexes: [] });
- }
- if (url.includes("version-group/20/")) {
- return jsonResponse({ pokedexes: [{ name: "galar", url: "x" }] });
- }
- throw new Error(`unexpected url: ${url}`);
- });
- vi.stubGlobal("fetch", fetchMock);
+ it("labels, filters out pokedex-less version groups, and sorts by id", async () => {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = input.toString();
+ if (url === "https://pokeapi.co/api/v2/version-group?limit=100") {
+ return jsonResponse({
+ results: [
+ {
+ name: "red-blue",
+ url: "https://pokeapi.co/api/v2/version-group/1/",
+ },
+ {
+ name: "colosseum",
+ url: "https://pokeapi.co/api/v2/version-group/15/",
+ },
+ {
+ name: "sword-shield",
+ url: "https://pokeapi.co/api/v2/version-group/20/",
+ },
+ ],
+ });
+ }
+ if (url.includes("version-group/1/")) {
+ return jsonResponse({
+ pokedexes: [{ name: "kanto", url: "x" }],
+ });
+ }
+ if (url.includes("version-group/15/")) {
+ // Spin-off with no pokedex - should be excluded.
+ return jsonResponse({ pokedexes: [] });
+ }
+ if (url.includes("version-group/20/")) {
+ return jsonResponse({
+ pokedexes: [{ name: "galar", url: "x" }],
+ });
+ }
+ throw new Error(`unexpected url: ${url}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
- const games = await fetchGames();
+ const games = await fetchGames();
- expect(games).toEqual([
- { label: "Red Blue", value: "red-blue" },
- { label: "Sword Shield", value: "sword-shield" },
- ]);
- });
+ expect(games).toEqual([
+ { label: "Red Blue", value: "red-blue" },
+ { label: "Sword Shield", value: "sword-shield" },
+ ]);
+ });
- it("throws when the version group list request fails", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => jsonResponse({}, false, 500))
- );
- await expect(fetchGames()).rejects.toThrow("PokeAPI request failed: 500");
- });
+ it("throws when the version group list request fails", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => jsonResponse({}, false, 500)),
+ );
+ await expect(fetchGames()).rejects.toThrow(
+ "PokeAPI request failed: 500",
+ );
+ });
});
describe("fetchGameSpeciesIds", () => {
- it("flattens species ids across every pokedex in the version group", async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- const url = input.toString();
- if (url === "https://pokeapi.co/api/v2/version-group/red-blue") {
- return jsonResponse({
- pokedexes: [
- { name: "kanto", url: "https://pokeapi.co/api/v2/pokedex/kanto" },
- { name: "national", url: "https://pokeapi.co/api/v2/pokedex/national" },
- ],
- });
- }
- if (url.includes("pokedex/kanto")) {
- return jsonResponse({
- pokemon_entries: [
- { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/1/" } },
- { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/4/" } },
- ],
- });
- }
- if (url.includes("pokedex/national")) {
- return jsonResponse({
- pokemon_entries: [
- { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/4/" } },
- { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/7/" } },
- ],
- });
- }
- throw new Error(`unexpected url: ${url}`);
- });
- vi.stubGlobal("fetch", fetchMock);
+ it("flattens species ids across every pokedex in the version group", async () => {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = input.toString();
+ if (url === "https://pokeapi.co/api/v2/version-group/red-blue") {
+ return jsonResponse({
+ pokedexes: [
+ {
+ name: "kanto",
+ url: "https://pokeapi.co/api/v2/pokedex/kanto",
+ },
+ {
+ name: "national",
+ url: "https://pokeapi.co/api/v2/pokedex/national",
+ },
+ ],
+ });
+ }
+ if (url.includes("pokedex/kanto")) {
+ return jsonResponse({
+ pokemon_entries: [
+ {
+ pokemon_species: {
+ url: "https://pokeapi.co/api/v2/pokemon-species/1/",
+ },
+ },
+ {
+ pokemon_species: {
+ url: "https://pokeapi.co/api/v2/pokemon-species/4/",
+ },
+ },
+ ],
+ });
+ }
+ if (url.includes("pokedex/national")) {
+ return jsonResponse({
+ pokemon_entries: [
+ {
+ pokemon_species: {
+ url: "https://pokeapi.co/api/v2/pokemon-species/4/",
+ },
+ },
+ {
+ pokemon_species: {
+ url: "https://pokeapi.co/api/v2/pokemon-species/7/",
+ },
+ },
+ ],
+ });
+ }
+ throw new Error(`unexpected url: ${url}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
- const ids = await fetchGameSpeciesIds("red-blue");
+ const ids = await fetchGameSpeciesIds("red-blue");
- expect(ids).toEqual(new Set([1, 4, 7]));
- });
+ expect(ids).toEqual(new Set([1, 4, 7]));
+ });
- it("throws when the pokedex request fails", async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- const url = input.toString();
- if (url.includes("version-group/")) {
- return jsonResponse({ pokedexes: [{ name: "kanto", url: "https://pokeapi.co/api/v2/pokedex/kanto" }] });
- }
- return jsonResponse({}, false, 404);
- });
- vi.stubGlobal("fetch", fetchMock);
+ it("throws when the pokedex request fails", async () => {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = input.toString();
+ if (url.includes("version-group/")) {
+ return jsonResponse({
+ pokedexes: [
+ {
+ name: "kanto",
+ url: "https://pokeapi.co/api/v2/pokedex/kanto",
+ },
+ ],
+ });
+ }
+ return jsonResponse({}, false, 404);
+ });
+ vi.stubGlobal("fetch", fetchMock);
- await expect(fetchGameSpeciesIds("red-blue")).rejects.toThrow("PokeAPI request failed: 404");
- });
+ await expect(fetchGameSpeciesIds("red-blue")).rejects.toThrow(
+ "PokeAPI request failed: 404",
+ );
+ });
});
diff --git a/PTG/src/games.ts b/PTG/src/games.ts
index 1e375a8..750daaf 100644
--- a/PTG/src/games.ts
+++ b/PTG/src/games.ts
@@ -1,67 +1,78 @@
-const VERSION_GROUP_LIST_URL = "https://pokeapi.co/api/v2/version-group?limit=100";
-const VERSION_GROUP_URL = (name: string) => `https://pokeapi.co/api/v2/version-group/${name}`;
+const VERSION_GROUP_LIST_URL =
+ "https://pokeapi.co/api/v2/version-group?limit=100";
+const VERSION_GROUP_URL = (name: string) =>
+ `https://pokeapi.co/api/v2/version-group/${name}`;
export type GameOption = {
- label: string;
- value: string;
+ label: string;
+ value: string;
};
function idFromUrl(url: string): number {
- return Number(url.split("/").filter(Boolean).pop());
+ return Number(url.split("/").filter(Boolean).pop());
}
function toLabel(name: string): string {
- return name
- .split("-")
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
- .join(" ");
+ return name
+ .split("-")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ");
}
type VersionGroup = {
- name: string;
- url: string;
+ name: string;
+ url: string;
};
type VersionGroupDetail = {
- pokedexes: { name: string; url: string }[];
+ pokedexes: { name: string; url: string }[];
};
export async function fetchGames(): Promise {
- const res = await fetch(VERSION_GROUP_LIST_URL);
- if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`);
- const data: { results: VersionGroup[] } = await res.json();
+ const res = await fetch(VERSION_GROUP_LIST_URL);
+ if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`);
+ const data: { results: VersionGroup[] } = await res.json();
- const details = await Promise.all(
- data.results.map(async (versionGroup) => {
- const detailRes = await fetch(versionGroup.url);
- if (!detailRes.ok) throw new Error(`PokeAPI request failed: ${detailRes.status}`);
- const detail: VersionGroupDetail = await detailRes.json();
- return { versionGroup, detail };
- })
- );
+ const details = await Promise.all(
+ data.results.map(async (versionGroup) => {
+ const detailRes = await fetch(versionGroup.url);
+ if (!detailRes.ok)
+ throw new Error(`PokeAPI request failed: ${detailRes.status}`);
+ const detail: VersionGroupDetail = await detailRes.json();
+ return { versionGroup, detail };
+ }),
+ );
- return details
- .filter(({ detail }) => detail.pokedexes.length > 0) // exclude spin-off version groups with no pokedex
- .sort((a, b) => idFromUrl(a.versionGroup.url) - idFromUrl(b.versionGroup.url))
- .map(({ versionGroup }) => ({
- label: toLabel(versionGroup.name),
- value: versionGroup.name,
- }));
+ return details
+ .filter(({ detail }) => detail.pokedexes.length > 0) // exclude spin-off version groups with no pokedex
+ .sort(
+ (a, b) =>
+ idFromUrl(a.versionGroup.url) - idFromUrl(b.versionGroup.url),
+ )
+ .map(({ versionGroup }) => ({
+ label: toLabel(versionGroup.name),
+ value: versionGroup.name,
+ }));
}
export async function fetchGameSpeciesIds(name: string): Promise> {
- const res = await fetch(VERSION_GROUP_URL(name));
- if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`);
- const data: VersionGroupDetail = await res.json();
+ const res = await fetch(VERSION_GROUP_URL(name));
+ if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`);
+ const data: VersionGroupDetail = await res.json();
- const idLists = await Promise.all(
- data.pokedexes.map(async (pokedex) => {
- const pokedexRes = await fetch(pokedex.url);
- if (!pokedexRes.ok) throw new Error(`PokeAPI request failed: ${pokedexRes.status}`);
- const pokedexData: { pokemon_entries: { pokemon_species: { url: string } }[] } = await pokedexRes.json();
- return pokedexData.pokemon_entries.map(({ pokemon_species }) => idFromUrl(pokemon_species.url));
- })
- );
+ const idLists = await Promise.all(
+ data.pokedexes.map(async (pokedex) => {
+ const pokedexRes = await fetch(pokedex.url);
+ if (!pokedexRes.ok)
+ throw new Error(`PokeAPI request failed: ${pokedexRes.status}`);
+ const pokedexData: {
+ pokemon_entries: { pokemon_species: { url: string } }[];
+ } = await pokedexRes.json();
+ return pokedexData.pokemon_entries.map(({ pokemon_species }) =>
+ idFromUrl(pokemon_species.url),
+ );
+ }),
+ );
- return new Set(idLists.flat());
+ return new Set(idLists.flat());
}
diff --git a/PTG/src/index.css b/PTG/src/index.css
index 803bd9f..699a279 100644
--- a/PTG/src/index.css
+++ b/PTG/src/index.css
@@ -1,3 +1,3 @@
-body{
+body {
margin: 0;
-}
\ No newline at end of file
+}
diff --git a/PTG/src/main.tsx b/PTG/src/main.tsx
index abda7f0..406eaae 100644
--- a/PTG/src/main.tsx
+++ b/PTG/src/main.tsx
@@ -1,33 +1,33 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { createBrowserRouter, RouterProvider } from 'react-router-dom'
-import './index.css'
-import App from './App.tsx'
-import About from './About.tsx'
-import NotFoundPage from './NotFoundPage.tsx'
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createBrowserRouter, RouterProvider } from "react-router-dom";
+import "./index.css";
+import App from "./App.tsx";
+import About from "./About.tsx";
+import NotFoundPage from "./NotFoundPage.tsx";
-const queryClient = new QueryClient()
+const queryClient = new QueryClient();
const router = createBrowserRouter([
- {
- path: "/",
- element:
- },
- {
- path: "/about",
- element:
- },
- {
- path: "*",
- element:
- }
+ {
+ path: "/",
+ element: ,
+ },
+ {
+ path: "/about",
+ element: ,
+ },
+ {
+ path: "*",
+ element: ,
+ },
]);
-createRoot(document.getElementById('root')!).render(
-
-
-
-
- ,
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
);
diff --git a/PTG/src/pokemonFilters.test.ts b/PTG/src/pokemonFilters.test.ts
index f5b408d..a40009b 100644
--- a/PTG/src/pokemonFilters.test.ts
+++ b/PTG/src/pokemonFilters.test.ts
@@ -2,46 +2,80 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadStoredFilters, saveStoredFilters } from "./pokemonFilters";
describe("pokemonFilters", () => {
- beforeEach(() => {
- sessionStorage.clear();
- });
+ beforeEach(() => {
+ sessionStorage.clear();
+ });
- it("returns default filters when nothing is stored", () => {
- expect(loadStoredFilters()).toEqual({ selectedGame: "", selectedType: "any", selectedName: "" });
- });
+ it("returns default filters when nothing is stored", () => {
+ expect(loadStoredFilters()).toEqual({
+ selectedGame: "",
+ selectedType: "any",
+ selectedName: "",
+ });
+ });
- it("round-trips saved filters", () => {
- const filters = { selectedGame: "red-blue", selectedType: "fire", selectedName: "char" };
- saveStoredFilters(filters);
- expect(loadStoredFilters()).toEqual(filters);
- });
+ it("round-trips saved filters", () => {
+ const filters = {
+ selectedGame: "red-blue",
+ selectedType: "fire",
+ selectedName: "char",
+ };
+ saveStoredFilters(filters);
+ expect(loadStoredFilters()).toEqual(filters);
+ });
- it("falls back to defaults when stored JSON is malformed", () => {
- sessionStorage.setItem("pokemonFilters", "{not-json");
- expect(loadStoredFilters()).toEqual({ selectedGame: "", selectedType: "any", selectedName: "" });
- });
+ it("falls back to defaults when stored JSON is malformed", () => {
+ sessionStorage.setItem("pokemonFilters", "{not-json");
+ expect(loadStoredFilters()).toEqual({
+ selectedGame: "",
+ selectedType: "any",
+ selectedName: "",
+ });
+ });
- it("falls back per-field when stored values have the wrong type", () => {
- sessionStorage.setItem(
- "pokemonFilters",
- JSON.stringify({ selectedGame: 42, selectedType: "water", selectedName: null })
- );
- expect(loadStoredFilters()).toEqual({ selectedGame: "", selectedType: "water", selectedName: "" });
- });
+ it("falls back per-field when stored values have the wrong type", () => {
+ sessionStorage.setItem(
+ "pokemonFilters",
+ JSON.stringify({
+ selectedGame: 42,
+ selectedType: "water",
+ selectedName: null,
+ }),
+ );
+ expect(loadStoredFilters()).toEqual({
+ selectedGame: "",
+ selectedType: "water",
+ selectedName: "",
+ });
+ });
- it("does not throw when sessionStorage.setItem fails", () => {
- const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
- throw new Error("quota exceeded");
- });
- expect(() => saveStoredFilters({ selectedGame: "", selectedType: "any", selectedName: "" })).not.toThrow();
- setItem.mockRestore();
- });
+ it("does not throw when sessionStorage.setItem fails", () => {
+ const setItem = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(() => {
+ throw new Error("quota exceeded");
+ });
+ expect(() =>
+ saveStoredFilters({
+ selectedGame: "",
+ selectedType: "any",
+ selectedName: "",
+ }),
+ ).not.toThrow();
+ setItem.mockRestore();
+ });
- it("does not throw when sessionStorage.getItem fails", () => {
- const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
- throw new Error("blocked");
- });
- expect(loadStoredFilters()).toEqual({ selectedGame: "", selectedType: "any", selectedName: "" });
- getItem.mockRestore();
- });
+ it("does not throw when sessionStorage.getItem fails", () => {
+ const getItem = vi
+ .spyOn(Storage.prototype, "getItem")
+ .mockImplementation(() => {
+ throw new Error("blocked");
+ });
+ expect(loadStoredFilters()).toEqual({
+ selectedGame: "",
+ selectedType: "any",
+ selectedName: "",
+ });
+ getItem.mockRestore();
+ });
});
diff --git a/PTG/src/pokemonFilters.ts b/PTG/src/pokemonFilters.ts
index 01d64f2..ce3f96d 100644
--- a/PTG/src/pokemonFilters.ts
+++ b/PTG/src/pokemonFilters.ts
@@ -1,32 +1,45 @@
const STORAGE_KEY = "pokemonFilters";
export type PokemonFilters = {
- selectedGame: string;
- selectedType: string;
- selectedName: string;
+ selectedGame: string;
+ selectedType: string;
+ selectedName: string;
};
-const DEFAULT_FILTERS: PokemonFilters = { selectedGame: "", selectedType: "any", selectedName: "" };
+const DEFAULT_FILTERS: PokemonFilters = {
+ selectedGame: "",
+ selectedType: "any",
+ selectedName: "",
+};
export function loadStoredFilters(): PokemonFilters {
- try {
- const raw = sessionStorage.getItem(STORAGE_KEY);
- if (!raw) return DEFAULT_FILTERS;
- const parsed = JSON.parse(raw);
- return {
- selectedGame: typeof parsed.selectedGame === "string" ? parsed.selectedGame : DEFAULT_FILTERS.selectedGame,
- selectedType: typeof parsed.selectedType === "string" ? parsed.selectedType : DEFAULT_FILTERS.selectedType,
- selectedName: typeof parsed.selectedName === "string" ? parsed.selectedName : DEFAULT_FILTERS.selectedName,
- };
- } catch {
- return DEFAULT_FILTERS;
- }
+ try {
+ const raw = sessionStorage.getItem(STORAGE_KEY);
+ if (!raw) return DEFAULT_FILTERS;
+ const parsed = JSON.parse(raw);
+ return {
+ selectedGame:
+ typeof parsed.selectedGame === "string"
+ ? parsed.selectedGame
+ : DEFAULT_FILTERS.selectedGame,
+ selectedType:
+ typeof parsed.selectedType === "string"
+ ? parsed.selectedType
+ : DEFAULT_FILTERS.selectedType,
+ selectedName:
+ typeof parsed.selectedName === "string"
+ ? parsed.selectedName
+ : DEFAULT_FILTERS.selectedName,
+ };
+ } catch {
+ return DEFAULT_FILTERS;
+ }
}
export function saveStoredFilters(filters: PokemonFilters) {
- try {
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
- } catch {
- // storage unavailable - filter still works for this session
- }
+ try {
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
+ } catch {
+ // storage unavailable - filter still works for this session
+ }
}
diff --git a/PTG/src/pokemonTeam.test.ts b/PTG/src/pokemonTeam.test.ts
index 4187935..0ddf233 100644
--- a/PTG/src/pokemonTeam.test.ts
+++ b/PTG/src/pokemonTeam.test.ts
@@ -1,51 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { createBlankTeam, loadStoredTeam, saveStoredTeam, TEAM_SIZE, type TeamSlot } from "./pokemonTeam";
+import {
+ createBlankTeam,
+ loadStoredTeam,
+ saveStoredTeam,
+ TEAM_SIZE,
+ type TeamSlot,
+} from "./pokemonTeam";
describe("pokemonTeam", () => {
- beforeEach(() => {
- localStorage.clear();
- });
-
- it("creates a blank team of the expected size", () => {
- const team = createBlankTeam();
- expect(team).toHaveLength(TEAM_SIZE);
- expect(team.every((slot) => slot.id === null && slot.favourite === false)).toBe(true);
- });
-
- it("returns a blank team when nothing is stored", () => {
- expect(loadStoredTeam()).toEqual(createBlankTeam());
- });
-
- it("returns a blank team when stored JSON is malformed", () => {
- localStorage.setItem("pokemonTeam", "{not-json");
- expect(loadStoredTeam()).toEqual(createBlankTeam());
- });
-
- it("returns a blank team when the stored array has the wrong length", () => {
- localStorage.setItem("pokemonTeam", JSON.stringify([{ id: 1, favourite: false }]));
- expect(loadStoredTeam()).toEqual(createBlankTeam());
- });
-
- it("returns a blank team when a slot has an invalid shape", () => {
- const invalid = Array.from({ length: TEAM_SIZE }, () => ({ id: "1", favourite: false }));
- localStorage.setItem("pokemonTeam", JSON.stringify(invalid));
- expect(loadStoredTeam()).toEqual(createBlankTeam());
- });
-
- it("round-trips a valid stored team", () => {
- const team: TeamSlot[] = Array.from({ length: TEAM_SIZE }, (_, i) => ({
- id: i,
- favourite: i % 2 === 0,
- }));
- saveStoredTeam(team);
- expect(loadStoredTeam()).toEqual(team);
- });
-
- it("does not throw when localStorage.setItem fails", () => {
- const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
- throw new Error("quota exceeded");
- });
- expect(() => saveStoredTeam(createBlankTeam())).not.toThrow();
- setItem.mockRestore();
- });
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("creates a blank team of the expected size", () => {
+ const team = createBlankTeam();
+ expect(team).toHaveLength(TEAM_SIZE);
+ expect(
+ team.every((slot) => slot.id === null && slot.favourite === false),
+ ).toBe(true);
+ });
+
+ it("returns a blank team when nothing is stored", () => {
+ expect(loadStoredTeam()).toEqual(createBlankTeam());
+ });
+
+ it("returns a blank team when stored JSON is malformed", () => {
+ localStorage.setItem("pokemonTeam", "{not-json");
+ expect(loadStoredTeam()).toEqual(createBlankTeam());
+ });
+
+ it("returns a blank team when the stored array has the wrong length", () => {
+ localStorage.setItem(
+ "pokemonTeam",
+ JSON.stringify([{ id: 1, favourite: false }]),
+ );
+ expect(loadStoredTeam()).toEqual(createBlankTeam());
+ });
+
+ it("returns a blank team when a slot has an invalid shape", () => {
+ const invalid = Array.from({ length: TEAM_SIZE }, () => ({
+ id: "1",
+ favourite: false,
+ }));
+ localStorage.setItem("pokemonTeam", JSON.stringify(invalid));
+ expect(loadStoredTeam()).toEqual(createBlankTeam());
+ });
+
+ it("round-trips a valid stored team", () => {
+ const team: TeamSlot[] = Array.from({ length: TEAM_SIZE }, (_, i) => ({
+ id: i,
+ favourite: i % 2 === 0,
+ }));
+ saveStoredTeam(team);
+ expect(loadStoredTeam()).toEqual(team);
+ });
+
+ it("does not throw when localStorage.setItem fails", () => {
+ const setItem = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(() => {
+ throw new Error("quota exceeded");
+ });
+ expect(() => saveStoredTeam(createBlankTeam())).not.toThrow();
+ setItem.mockRestore();
+ });
});
diff --git a/PTG/src/pokemonTeam.ts b/PTG/src/pokemonTeam.ts
index 35a89bb..c61e935 100644
--- a/PTG/src/pokemonTeam.ts
+++ b/PTG/src/pokemonTeam.ts
@@ -3,38 +3,48 @@ const STORAGE_KEY = "pokemonTeam";
export const TEAM_SIZE = 6;
export type TeamSlot = {
- id: number | null;
- favourite: boolean;
+ id: number | null;
+ favourite: boolean;
};
export function createBlankTeam(): TeamSlot[] {
- return Array.from({ length: TEAM_SIZE }, () => ({ id: null, favourite: false }));
+ return Array.from({ length: TEAM_SIZE }, () => ({
+ id: null,
+ favourite: false,
+ }));
}
function isTeamSlot(value: unknown): value is TeamSlot {
- if (typeof value !== "object" || value === null) return false;
- const slot = value as Record;
- return (slot.id === null || typeof slot.id === "number") && typeof slot.favourite === "boolean";
+ if (typeof value !== "object" || value === null) return false;
+ const slot = value as Record;
+ return (
+ (slot.id === null || typeof slot.id === "number") &&
+ typeof slot.favourite === "boolean"
+ );
}
export function loadStoredTeam(): TeamSlot[] {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return createBlankTeam();
- const parsed: unknown = JSON.parse(raw);
- if (!Array.isArray(parsed) || parsed.length !== TEAM_SIZE || !parsed.every(isTeamSlot)) {
- return createBlankTeam();
- }
- return parsed;
- } catch {
- return createBlankTeam();
- }
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return createBlankTeam();
+ const parsed: unknown = JSON.parse(raw);
+ if (
+ !Array.isArray(parsed) ||
+ parsed.length !== TEAM_SIZE ||
+ !parsed.every(isTeamSlot)
+ ) {
+ return createBlankTeam();
+ }
+ return parsed;
+ } catch {
+ return createBlankTeam();
+ }
}
export function saveStoredTeam(team: TeamSlot[]) {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(team));
- } catch {
- // storage unavailable - team still works for this session
- }
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(team));
+ } catch {
+ // storage unavailable - team still works for this session
+ }
}
diff --git a/PTG/src/pokemonTypes.test.ts b/PTG/src/pokemonTypes.test.ts
index 8d52e0d..3184b4f 100644
--- a/PTG/src/pokemonTypes.test.ts
+++ b/PTG/src/pokemonTypes.test.ts
@@ -2,81 +2,101 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchPokemonTypeMap, fetchTypes } from "./pokemonTypes";
function jsonResponse(body: unknown, ok = true, status = 200): Response {
- return { ok, status, json: async () => body } as Response;
+ return { ok, status, json: async () => body } as Response;
}
afterEach(() => {
- vi.unstubAllGlobals();
+ vi.unstubAllGlobals();
});
describe("fetchTypes", () => {
- it("excludes non-battle types and sorts battle types by id", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(async () =>
- jsonResponse({
- results: [
- { name: "water", url: "https://pokeapi.co/api/v2/type/11/" },
- { name: "fire", url: "https://pokeapi.co/api/v2/type/10/" },
- { name: "shadow", url: "https://pokeapi.co/api/v2/type/10001/" },
- { name: "unknown", url: "https://pokeapi.co/api/v2/type/10000/" },
- ],
- })
- )
- );
+ it("excludes non-battle types and sorts battle types by id", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () =>
+ jsonResponse({
+ results: [
+ {
+ name: "water",
+ url: "https://pokeapi.co/api/v2/type/11/",
+ },
+ {
+ name: "fire",
+ url: "https://pokeapi.co/api/v2/type/10/",
+ },
+ {
+ name: "shadow",
+ url: "https://pokeapi.co/api/v2/type/10001/",
+ },
+ {
+ name: "unknown",
+ url: "https://pokeapi.co/api/v2/type/10000/",
+ },
+ ],
+ }),
+ ),
+ );
- const types = await fetchTypes();
+ const types = await fetchTypes();
- expect(types).toEqual([
- { label: "Fire", value: "fire" },
- { label: "Water", value: "water" },
- ]);
- });
+ expect(types).toEqual([
+ { label: "Fire", value: "fire" },
+ { label: "Water", value: "water" },
+ ]);
+ });
- it("throws when the type list request fails", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => jsonResponse({}, false, 500))
- );
- await expect(fetchTypes()).rejects.toThrow("PokeAPI request failed: 500");
- });
+ it("throws when the type list request fails", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => jsonResponse({}, false, 500)),
+ );
+ await expect(fetchTypes()).rejects.toThrow(
+ "PokeAPI request failed: 500",
+ );
+ });
});
describe("fetchPokemonTypeMap", () => {
- it("maps each pokemon to its types ordered by slot", async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- const url = input.toString();
- if (url === "https://pokeapi.co/api/v2/type?limit=100") {
- return jsonResponse({
- results: [
- { name: "grass", url: "https://pokeapi.co/api/v2/type/12/" },
- { name: "poison", url: "https://pokeapi.co/api/v2/type/4/" },
- ],
- });
- }
- if (url === "https://pokeapi.co/api/v2/type/grass") {
- return jsonResponse({
- id: 12,
- name: "grass",
- pokemon: [{ slot: 1, pokemon: { name: "bulbasaur" } }],
- });
- }
- if (url === "https://pokeapi.co/api/v2/type/poison") {
- return jsonResponse({
- id: 4,
- name: "poison",
- pokemon: [{ slot: 2, pokemon: { name: "bulbasaur" } }],
- });
- }
- throw new Error(`unexpected url: ${url}`);
- });
- vi.stubGlobal("fetch", fetchMock);
+ it("maps each pokemon to its types ordered by slot", async () => {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = input.toString();
+ if (url === "https://pokeapi.co/api/v2/type?limit=100") {
+ return jsonResponse({
+ results: [
+ {
+ name: "grass",
+ url: "https://pokeapi.co/api/v2/type/12/",
+ },
+ {
+ name: "poison",
+ url: "https://pokeapi.co/api/v2/type/4/",
+ },
+ ],
+ });
+ }
+ if (url === "https://pokeapi.co/api/v2/type/grass") {
+ return jsonResponse({
+ id: 12,
+ name: "grass",
+ pokemon: [{ slot: 1, pokemon: { name: "bulbasaur" } }],
+ });
+ }
+ if (url === "https://pokeapi.co/api/v2/type/poison") {
+ return jsonResponse({
+ id: 4,
+ name: "poison",
+ pokemon: [{ slot: 2, pokemon: { name: "bulbasaur" } }],
+ });
+ }
+ throw new Error(`unexpected url: ${url}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
- const typeMap = await fetchPokemonTypeMap();
+ const typeMap = await fetchPokemonTypeMap();
- expect(typeMap.get("bulbasaur")).toEqual([
- { id: 12, name: "grass" },
- { id: 4, name: "poison" },
- ]);
- });
+ expect(typeMap.get("bulbasaur")).toEqual([
+ { id: 12, name: "grass" },
+ { id: 4, name: "poison" },
+ ]);
+ });
});
diff --git a/PTG/src/pokemonTypes.ts b/PTG/src/pokemonTypes.ts
index 6f20f19..6dd2dd2 100644
--- a/PTG/src/pokemonTypes.ts
+++ b/PTG/src/pokemonTypes.ts
@@ -8,75 +8,84 @@ const NON_BATTLE_TYPE_ID = 10000;
// Official Scarlet/Violet type icon, indexed by the same numeric type id PokeAPI uses.
export const TYPE_ICON_URL = (id: number) =>
- `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-ix/scarlet-violet/small/${id}.png`;
+ `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-ix/scarlet-violet/small/${id}.png`;
export type TypeOption = {
- label: string;
- value: string;
+ label: string;
+ value: string;
};
export type PokemonType = {
- id: number;
- name: string;
+ id: number;
+ name: string;
};
function idFromUrl(url: string): number {
- return Number(url.split("/").filter(Boolean).pop());
+ return Number(url.split("/").filter(Boolean).pop());
}
function toLabel(name: string): string {
- return name.charAt(0).toUpperCase() + name.slice(1);
+ return name.charAt(0).toUpperCase() + name.slice(1);
}
export async function fetchTypes(): Promise {
- const listRes = await fetch(TYPE_LIST_URL);
- if (!listRes.ok) throw new Error(`PokeAPI request failed: ${listRes.status}`);
- const listData: { results: { name: string; url: string }[] } = await listRes.json();
+ const listRes = await fetch(TYPE_LIST_URL);
+ if (!listRes.ok)
+ throw new Error(`PokeAPI request failed: ${listRes.status}`);
+ const listData: { results: { name: string; url: string }[] } =
+ await listRes.json();
- return listData.results
- .filter(({ url }) => idFromUrl(url) < NON_BATTLE_TYPE_ID)
- .sort((a, b) => idFromUrl(a.url) - idFromUrl(b.url))
- .map(({ name }) => ({ label: toLabel(name), value: name }));
+ return listData.results
+ .filter(({ url }) => idFromUrl(url) < NON_BATTLE_TYPE_ID)
+ .sort((a, b) => idFromUrl(a.url) - idFromUrl(b.url))
+ .map(({ name }) => ({ label: toLabel(name), value: name }));
}
type TypeDetail = {
- id: number;
- name: string;
- pokemon: { slot: number; pokemon: { name: string } }[];
+ id: number;
+ name: string;
+ pokemon: { slot: number; pokemon: { name: string } }[];
};
// Maps each Pokemon name to its types, ordered by slot (slot 1 = primary type).
-export async function fetchPokemonTypeMap(): Promise> {
- const listRes = await fetch(TYPE_LIST_URL);
- if (!listRes.ok) throw new Error(`PokeAPI request failed: ${listRes.status}`);
- const listData: { results: { name: string; url: string }[] } = await listRes.json();
- const battleTypes = listData.results.filter(({ url }) => idFromUrl(url) < NON_BATTLE_TYPE_ID);
+export async function fetchPokemonTypeMap(): Promise<
+ Map
+> {
+ const listRes = await fetch(TYPE_LIST_URL);
+ if (!listRes.ok)
+ throw new Error(`PokeAPI request failed: ${listRes.status}`);
+ const listData: { results: { name: string; url: string }[] } =
+ await listRes.json();
+ const battleTypes = listData.results.filter(
+ ({ url }) => idFromUrl(url) < NON_BATTLE_TYPE_ID,
+ );
- const details = await Promise.all(
- battleTypes.map(async ({ name }) => {
- const res = await fetch(TYPE_URL(name));
- if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`);
- const data: TypeDetail = await res.json();
- return data;
- })
- );
+ const details = await Promise.all(
+ battleTypes.map(async ({ name }) => {
+ const res = await fetch(TYPE_URL(name));
+ if (!res.ok)
+ throw new Error(`PokeAPI request failed: ${res.status}`);
+ const data: TypeDetail = await res.json();
+ return data;
+ }),
+ );
- const bySlot = new Map();
- for (const type of details) {
- for (const { slot, pokemon } of type.pokemon) {
- const entries = bySlot.get(pokemon.name) ?? [];
- entries.push({ slot, type: { id: type.id, name: type.name } });
- bySlot.set(pokemon.name, entries);
- }
- }
+ const bySlot = new Map();
+ for (const type of details) {
+ for (const { slot, pokemon } of type.pokemon) {
+ const entries = bySlot.get(pokemon.name) ?? [];
+ entries.push({ slot, type: { id: type.id, name: type.name } });
+ bySlot.set(pokemon.name, entries);
+ }
+ }
- const typeMap = new Map();
- for (const [name, entries] of bySlot) {
- typeMap.set(
- name,
- entries.sort((a, b) => a.slot - b.slot).map((entry) => entry.type)
- );
- }
+ const typeMap = new Map();
+ for (const [name, entries] of bySlot) {
+ typeMap.set(
+ name,
+ entries.sort((a, b) => a.slot - b.slot).map((entry) => entry.type),
+ );
+ }
- return typeMap;
+ return typeMap;
}
diff --git a/PTG/src/test/setup.ts b/PTG/src/test/setup.ts
index 1efae31..c226d1a 100644
--- a/PTG/src/test/setup.ts
+++ b/PTG/src/test/setup.ts
@@ -3,5 +3,5 @@ import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
- cleanup();
+ cleanup();
});
diff --git a/PTG/src/test/withQueryClient.tsx b/PTG/src/test/withQueryClient.tsx
index f7ea48c..0441127 100644
--- a/PTG/src/test/withQueryClient.tsx
+++ b/PTG/src/test/withQueryClient.tsx
@@ -2,13 +2,17 @@ import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function createTestQueryClient(): QueryClient {
- return new QueryClient({
- defaultOptions: { queries: { retry: false } },
- });
+ return new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
}
export function withQueryClient(client: QueryClient = createTestQueryClient()) {
- return function Wrapper({ children }: { children: ReactNode }) {
- return {children} ;
- };
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ };
}
diff --git a/PTG/src/useFilteredPokemon.test.tsx b/PTG/src/useFilteredPokemon.test.tsx
index 7937116..45c5819 100644
--- a/PTG/src/useFilteredPokemon.test.tsx
+++ b/PTG/src/useFilteredPokemon.test.tsx
@@ -4,99 +4,147 @@ import { useFilteredPokemon } from "./useFilteredPokemon";
import { withQueryClient } from "./test/withQueryClient";
function jsonResponse(body: unknown, ok = true, status = 200): Response {
- return { ok, status, json: async () => body } as Response;
+ return { ok, status, json: async () => body } as Response;
}
const POKEMON = [
- { name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" },
- { name: "charmander", url: "https://pokeapi.co/api/v2/pokemon/4/" },
- { name: "squirtle", url: "https://pokeapi.co/api/v2/pokemon/7/" },
+ { name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" },
+ { name: "charmander", url: "https://pokeapi.co/api/v2/pokemon/4/" },
+ { name: "squirtle", url: "https://pokeapi.co/api/v2/pokemon/7/" },
];
function stubFetch(handlers: { [urlSubstring: string]: unknown }) {
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- const url = input.toString();
- const entry = Object.entries(handlers).find(([pattern]) => url.includes(pattern));
- if (!entry) return jsonResponse({}, false, 404);
- return jsonResponse(entry[1]);
- });
- vi.stubGlobal("fetch", fetchMock);
- return fetchMock;
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = input.toString();
+ const entry = Object.entries(handlers).find(([pattern]) =>
+ url.includes(pattern),
+ );
+ if (!entry) return jsonResponse({}, false, 404);
+ return jsonResponse(entry[1]);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ return fetchMock;
}
afterEach(() => {
- vi.unstubAllGlobals();
+ vi.unstubAllGlobals();
});
describe("useFilteredPokemon", () => {
- it("returns the full list when no filters are applied", async () => {
- stubFetch({
- "api/v2/pokemon?limit=": { results: POKEMON },
- "api/v2/type?limit=": { results: [] },
- });
-
- const { result } = renderHook(() => useFilteredPokemon("", "any", ""), { wrapper: withQueryClient() });
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
- expect(result.current.pokemon.map((p) => p.name)).toEqual(["bulbasaur", "charmander", "squirtle"]);
- expect(result.current.pokemonError).toBeNull();
- });
-
- it("filters by name prefix, case-insensitively", async () => {
- stubFetch({
- "api/v2/pokemon?limit=": { results: POKEMON },
- "api/v2/type?limit=": { results: [] },
- });
-
- const { result } = renderHook(() => useFilteredPokemon("", "any", "CHAR"), { wrapper: withQueryClient() });
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
- expect(result.current.pokemon.map((p) => p.name)).toEqual(["charmander"]);
- });
-
- it("filters by type using the resolved name set", async () => {
- stubFetch({
- "api/v2/pokemon?limit=": { results: POKEMON },
- "api/v2/type?limit=": { results: [] },
- "api/v2/type/water": { pokemon: [{ pokemon: { name: "squirtle" } }] },
- });
-
- const { result } = renderHook(() => useFilteredPokemon("", "water", ""), { wrapper: withQueryClient() });
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
- await waitFor(() => expect(result.current.pokemon.map((p) => p.name)).toEqual(["squirtle"]));
- });
-
- it("combines game, type, and name filters", async () => {
- stubFetch({
- "api/v2/pokemon?limit=": { results: POKEMON },
- "api/v2/type?limit=": { results: [] },
- "api/v2/type/fire": { pokemon: [{ pokemon: { name: "charmander" } }] },
- "api/v2/version-group/red": {
- pokedexes: [{ name: "kanto", url: "https://pokeapi.co/api/v2/pokedex/kanto" }],
- },
- "api/v2/pokedex/kanto": {
- pokemon_entries: [
- { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/4/" } },
- { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/7/" } },
- ],
- },
- });
-
- const { result } = renderHook(() => useFilteredPokemon("red", "fire", "char"), { wrapper: withQueryClient() });
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
- await waitFor(() => expect(result.current.pokemon.map((p) => p.name)).toEqual(["charmander"]));
- });
-
- it("surfaces an error when the pokemon list request fails", async () => {
- stubFetch({
- "api/v2/type?limit=": { results: [] },
- });
-
- const { result } = renderHook(() => useFilteredPokemon("", "any", ""), { wrapper: withQueryClient() });
-
- await waitFor(() => expect(result.current.pokemonError).not.toBeNull());
- expect(result.current.pokemonError?.message).toContain("PokeAPI request failed");
- });
+ it("returns the full list when no filters are applied", async () => {
+ stubFetch({
+ "api/v2/pokemon?limit=": { results: POKEMON },
+ "api/v2/type?limit=": { results: [] },
+ });
+
+ const { result } = renderHook(() => useFilteredPokemon("", "any", ""), {
+ wrapper: withQueryClient(),
+ });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.pokemon.map((p) => p.name)).toEqual([
+ "bulbasaur",
+ "charmander",
+ "squirtle",
+ ]);
+ expect(result.current.pokemonError).toBeNull();
+ });
+
+ it("filters by name prefix, case-insensitively", async () => {
+ stubFetch({
+ "api/v2/pokemon?limit=": { results: POKEMON },
+ "api/v2/type?limit=": { results: [] },
+ });
+
+ const { result } = renderHook(
+ () => useFilteredPokemon("", "any", "CHAR"),
+ { wrapper: withQueryClient() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.pokemon.map((p) => p.name)).toEqual([
+ "charmander",
+ ]);
+ });
+
+ it("filters by type using the resolved name set", async () => {
+ stubFetch({
+ "api/v2/pokemon?limit=": { results: POKEMON },
+ "api/v2/type?limit=": { results: [] },
+ "api/v2/type/water": {
+ pokemon: [{ pokemon: { name: "squirtle" } }],
+ },
+ });
+
+ const { result } = renderHook(
+ () => useFilteredPokemon("", "water", ""),
+ { wrapper: withQueryClient() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ await waitFor(() =>
+ expect(result.current.pokemon.map((p) => p.name)).toEqual([
+ "squirtle",
+ ]),
+ );
+ });
+
+ it("combines game, type, and name filters", async () => {
+ stubFetch({
+ "api/v2/pokemon?limit=": { results: POKEMON },
+ "api/v2/type?limit=": { results: [] },
+ "api/v2/type/fire": {
+ pokemon: [{ pokemon: { name: "charmander" } }],
+ },
+ "api/v2/version-group/red": {
+ pokedexes: [
+ {
+ name: "kanto",
+ url: "https://pokeapi.co/api/v2/pokedex/kanto",
+ },
+ ],
+ },
+ "api/v2/pokedex/kanto": {
+ pokemon_entries: [
+ {
+ pokemon_species: {
+ url: "https://pokeapi.co/api/v2/pokemon-species/4/",
+ },
+ },
+ {
+ pokemon_species: {
+ url: "https://pokeapi.co/api/v2/pokemon-species/7/",
+ },
+ },
+ ],
+ },
+ });
+
+ const { result } = renderHook(
+ () => useFilteredPokemon("red", "fire", "char"),
+ { wrapper: withQueryClient() },
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ await waitFor(() =>
+ expect(result.current.pokemon.map((p) => p.name)).toEqual([
+ "charmander",
+ ]),
+ );
+ });
+
+ it("surfaces an error when the pokemon list request fails", async () => {
+ stubFetch({
+ "api/v2/type?limit=": { results: [] },
+ });
+
+ const { result } = renderHook(() => useFilteredPokemon("", "any", ""), {
+ wrapper: withQueryClient(),
+ });
+
+ await waitFor(() => expect(result.current.pokemonError).not.toBeNull());
+ expect(result.current.pokemonError?.message).toContain(
+ "PokeAPI request failed",
+ );
+ });
});
diff --git a/PTG/src/useFilteredPokemon.ts b/PTG/src/useFilteredPokemon.ts
index 792bb89..2d53d0f 100644
--- a/PTG/src/useFilteredPokemon.ts
+++ b/PTG/src/useFilteredPokemon.ts
@@ -5,90 +5,99 @@ import { fetchPokemonTypeMap, type PokemonType } from "./pokemonTypes";
const POKEMON_COUNT = 1025;
const LIST_URL = `https://pokeapi.co/api/v2/pokemon?limit=${POKEMON_COUNT}`;
const ARTWORK_URL = (id: number) =>
- `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`;
+ `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`;
const TYPE_URL = (type: string) => `https://pokeapi.co/api/v2/type/${type}`;
export type Pokemon = {
- id: number;
- name: string;
- image: string;
+ id: number;
+ name: string;
+ image: string;
};
async function fetchPokemonList(): Promise {
- const res = await fetch(LIST_URL);
- if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`);
- const data: { results: { name: string; url: string }[] } = await res.json();
- return data.results.map(({ name, url }) => {
- const id = Number(url.split("/").filter(Boolean).pop());
- return { id, name, image: ARTWORK_URL(id) };
- });
+ const res = await fetch(LIST_URL);
+ if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`);
+ const data: { results: { name: string; url: string }[] } = await res.json();
+ return data.results.map(({ name, url }) => {
+ const id = Number(url.split("/").filter(Boolean).pop());
+ return { id, name, image: ARTWORK_URL(id) };
+ });
}
async function fetchTypeNames(type: string): Promise> {
- const res = await fetch(TYPE_URL(type));
- if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`);
- const data: { pokemon: { pokemon: { name: string } }[] } = await res.json();
- return new Set(data.pokemon.map(({ pokemon }) => pokemon.name));
+ const res = await fetch(TYPE_URL(type));
+ if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`);
+ const data: { pokemon: { pokemon: { name: string } }[] } = await res.json();
+ return new Set(data.pokemon.map(({ pokemon }) => pokemon.name));
}
// The full Pokémon list, shared (and cached) across every component that needs it.
export function usePokemonList() {
- return useQuery({
- queryKey: ["pokemonList"],
- queryFn: fetchPokemonList,
- });
+ return useQuery({
+ queryKey: ["pokemonList"],
+ queryFn: fetchPokemonList,
+ });
}
export type FilteredPokemon = {
- pokemon: Pokemon[];
- typeMap: Map | undefined;
- isLoading: boolean;
- pokemonError: Error | null;
- typeError: Error | null;
- gameError: Error | null;
+ pokemon: Pokemon[];
+ typeMap: Map | undefined;
+ isLoading: boolean;
+ pokemonError: Error | null;
+ typeError: Error | null;
+ gameError: Error | null;
};
// Resolves the current game/type/name filters against the full Pokémon list.
-export function useFilteredPokemon(selectedGame: string, selectedType: string, selectedName: string = ""): FilteredPokemon {
- const { data: pokemon = [], error: pokemonError, isLoading } = usePokemonList();
+export function useFilteredPokemon(
+ selectedGame: string,
+ selectedType: string,
+ selectedName: string = "",
+): FilteredPokemon {
+ const {
+ data: pokemon = [],
+ error: pokemonError,
+ isLoading,
+ } = usePokemonList();
- // Decorative type icons. A failure here shouldn't block the rest of the grid.
- const { data: typeMap } = useQuery({
- queryKey: ["pokemonTypeMap"],
- queryFn: fetchPokemonTypeMap,
- retry: false,
- throwOnError: false,
- });
+ // Decorative type icons. A failure here shouldn't block the rest of the grid.
+ const { data: typeMap } = useQuery({
+ queryKey: ["pokemonTypeMap"],
+ queryFn: fetchPokemonTypeMap,
+ retry: false,
+ throwOnError: false,
+ });
- const isAnyType = !selectedType || selectedType === "any";
- const { data: typeNames, error: typeError } = useQuery({
- queryKey: ["pokemonTypeFilter", selectedType],
- queryFn: () => fetchTypeNames(selectedType),
- enabled: !isAnyType,
- });
+ const isAnyType = !selectedType || selectedType === "any";
+ const { data: typeNames, error: typeError } = useQuery({
+ queryKey: ["pokemonTypeFilter", selectedType],
+ queryFn: () => fetchTypeNames(selectedType),
+ enabled: !isAnyType,
+ });
- const { data: gameIds, error: gameError } = useQuery({
- queryKey: ["gameSpeciesIds", selectedGame],
- queryFn: () => fetchGameSpeciesIds(selectedGame),
- enabled: !!selectedGame,
- });
+ const { data: gameIds, error: gameError } = useQuery({
+ queryKey: ["gameSpeciesIds", selectedGame],
+ queryFn: () => fetchGameSpeciesIds(selectedGame),
+ enabled: !!selectedGame,
+ });
- const nameQuery = selectedName.trim().toLowerCase();
+ const nameQuery = selectedName.trim().toLowerCase();
- // Each filter is resolved to a matching-id/name set independently above. A Pokemon must satisfy all of them.
- const filtered = pokemon.filter(({ id, name }) => {
- if (gameIds && !gameIds.has(id)) return false;
- if (typeNames && !typeNames.has(name)) return false;
- if (nameQuery && !name.toLowerCase().startsWith(nameQuery)) return false;
- return true;
- });
+ // Each filter is resolved to a matching-id/name set independently above. A Pokemon must satisfy all of them.
+ const filtered = pokemon.filter(({ id, name }) => {
+ if (gameIds && !gameIds.has(id)) return false;
+ if (typeNames && !typeNames.has(name)) return false;
+ if (nameQuery && !name.toLowerCase().startsWith(nameQuery))
+ return false;
+ return true;
+ });
- return {
- pokemon: filtered,
- typeMap,
- isLoading,
- pokemonError,
- typeError,
- gameError,
- };
+ return {
+ pokemon: filtered,
+ typeMap,
+ isLoading,
+ pokemonError,
+ typeError,
+ gameError,
+ };
}
diff --git a/PTG/src/usePokemonTeam.test.tsx b/PTG/src/usePokemonTeam.test.tsx
index 005384c..f943f18 100644
--- a/PTG/src/usePokemonTeam.test.tsx
+++ b/PTG/src/usePokemonTeam.test.tsx
@@ -5,121 +5,137 @@ import { TEAM_SIZE } from "./pokemonTeam";
import { withQueryClient } from "./test/withQueryClient";
function jsonResponse(body: unknown, ok = true, status = 200): Response {
- return { ok, status, json: async () => body } as Response;
+ return { ok, status, json: async () => body } as Response;
}
// More candidates than TEAM_SIZE so a second generateTeam() call still has fresh, unused pokemon to draw from.
const POKEMON_COUNT = 20;
const POKEMON = Array.from({ length: POKEMON_COUNT }, (_, i) => ({
- name: `pokemon-${i}`,
- url: `https://pokeapi.co/api/v2/pokemon/${i}/`,
+ name: `pokemon-${i}`,
+ url: `https://pokeapi.co/api/v2/pokemon/${i}/`,
}));
function stubFetch() {
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- const url = input.toString();
- if (url.includes("api/v2/pokemon?limit=")) return jsonResponse({ results: POKEMON });
- if (url.includes("api/v2/type?limit=")) return jsonResponse({ results: [] });
- return jsonResponse({}, false, 404);
- });
- vi.stubGlobal("fetch", fetchMock);
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = input.toString();
+ if (url.includes("api/v2/pokemon?limit="))
+ return jsonResponse({ results: POKEMON });
+ if (url.includes("api/v2/type?limit="))
+ return jsonResponse({ results: [] });
+ return jsonResponse({}, false, 404);
+ });
+ vi.stubGlobal("fetch", fetchMock);
}
beforeEach(() => {
- localStorage.clear();
- stubFetch();
+ localStorage.clear();
+ stubFetch();
});
afterEach(() => {
- vi.unstubAllGlobals();
+ vi.unstubAllGlobals();
});
async function renderReadyTeam() {
- const { result } = renderHook(() => usePokemonTeam("", "any", ""), { wrapper: withQueryClient() });
- await waitFor(() => expect(result.current.pokemonById.size).toBe(POKEMON_COUNT));
- return result;
+ const { result } = renderHook(() => usePokemonTeam("", "any", ""), {
+ wrapper: withQueryClient(),
+ });
+ await waitFor(() =>
+ expect(result.current.pokemonById.size).toBe(POKEMON_COUNT),
+ );
+ return result;
}
describe("usePokemonTeam", () => {
- it("starts from a blank team when nothing is stored", async () => {
- const result = await renderReadyTeam();
- expect(result.current.team).toHaveLength(TEAM_SIZE);
- expect(result.current.team.every((slot) => slot.id === null)).toBe(true);
- });
-
- it("fills every slot with unique pokemon on generateTeam", async () => {
- const result = await renderReadyTeam();
-
- act(() => result.current.generateTeam());
-
- const ids = result.current.team.map((slot) => slot.id);
- expect(ids.every((id) => id !== null)).toBe(true);
- expect(new Set(ids).size).toBe(TEAM_SIZE);
- expect(result.current.team.every((slot) => slot.favourite === false)).toBe(true);
- });
-
- it("toggleFavourite flips only the targeted slot, and leaves empty slots alone", async () => {
- const result = await renderReadyTeam();
- act(() => result.current.generateTeam());
-
- act(() => result.current.toggleFavourite(0));
- expect(result.current.team[0].favourite).toBe(true);
- expect(result.current.team.slice(1).every((slot) => slot.favourite === false)).toBe(true);
-
- act(() => result.current.toggleFavourite(0));
- expect(result.current.team[0].favourite).toBe(false);
- });
-
- it("toggleFavourite is a no-op on an empty slot", async () => {
- const result = await renderReadyTeam();
- act(() => result.current.toggleFavourite(0));
- expect(result.current.team[0]).toEqual({ id: null, favourite: false });
- });
-
- it("generateTeam keeps favourited members and replaces the rest with fresh, non-duplicate picks", async () => {
- const result = await renderReadyTeam();
-
- act(() => result.current.generateTeam());
- const firstTeam = result.current.team;
- const originalIds = firstTeam.map((slot) => slot.id);
-
- act(() => result.current.toggleFavourite(0));
- const favouritedId = result.current.team[0].id;
-
- act(() => result.current.generateTeam());
- const secondTeam = result.current.team;
-
- expect(secondTeam[0].id).toBe(favouritedId);
- expect(secondTeam[0].favourite).toBe(true);
-
- const newNonFavouriteIds = secondTeam.slice(1).map((slot) => slot.id);
- // None of the newly generated slots should reuse a pokemon from the first team.
- for (const id of newNonFavouriteIds) {
- expect(originalIds).not.toContain(id);
- }
- expect(new Set(secondTeam.map((slot) => slot.id)).size).toBe(TEAM_SIZE);
- });
-
- it("reports allFavourited once every filled slot is favourited", async () => {
- const result = await renderReadyTeam();
- act(() => result.current.generateTeam());
-
- expect(result.current.allFavourited).toBe(false);
-
- for (let i = 0; i < TEAM_SIZE; i++) {
- act(() => result.current.toggleFavourite(i));
- }
-
- expect(result.current.allFavourited).toBe(true);
- });
-
- it("persists the team to localStorage", async () => {
- const result = await renderReadyTeam();
- act(() => result.current.generateTeam());
-
- await waitFor(() => {
- const stored = JSON.parse(localStorage.getItem("pokemonTeam") ?? "null");
- expect(stored).toEqual(result.current.team);
- });
- });
+ it("starts from a blank team when nothing is stored", async () => {
+ const result = await renderReadyTeam();
+ expect(result.current.team).toHaveLength(TEAM_SIZE);
+ expect(result.current.team.every((slot) => slot.id === null)).toBe(
+ true,
+ );
+ });
+
+ it("fills every slot with unique pokemon on generateTeam", async () => {
+ const result = await renderReadyTeam();
+
+ act(() => result.current.generateTeam());
+
+ const ids = result.current.team.map((slot) => slot.id);
+ expect(ids.every((id) => id !== null)).toBe(true);
+ expect(new Set(ids).size).toBe(TEAM_SIZE);
+ expect(
+ result.current.team.every((slot) => slot.favourite === false),
+ ).toBe(true);
+ });
+
+ it("toggleFavourite flips only the targeted slot, and leaves empty slots alone", async () => {
+ const result = await renderReadyTeam();
+ act(() => result.current.generateTeam());
+
+ act(() => result.current.toggleFavourite(0));
+ expect(result.current.team[0].favourite).toBe(true);
+ expect(
+ result.current.team
+ .slice(1)
+ .every((slot) => slot.favourite === false),
+ ).toBe(true);
+
+ act(() => result.current.toggleFavourite(0));
+ expect(result.current.team[0].favourite).toBe(false);
+ });
+
+ it("toggleFavourite is a no-op on an empty slot", async () => {
+ const result = await renderReadyTeam();
+ act(() => result.current.toggleFavourite(0));
+ expect(result.current.team[0]).toEqual({ id: null, favourite: false });
+ });
+
+ it("generateTeam keeps favourited members and replaces the rest with fresh, non-duplicate picks", async () => {
+ const result = await renderReadyTeam();
+
+ act(() => result.current.generateTeam());
+ const firstTeam = result.current.team;
+ const originalIds = firstTeam.map((slot) => slot.id);
+
+ act(() => result.current.toggleFavourite(0));
+ const favouritedId = result.current.team[0].id;
+
+ act(() => result.current.generateTeam());
+ const secondTeam = result.current.team;
+
+ expect(secondTeam[0].id).toBe(favouritedId);
+ expect(secondTeam[0].favourite).toBe(true);
+
+ const newNonFavouriteIds = secondTeam.slice(1).map((slot) => slot.id);
+ // None of the newly generated slots should reuse a pokemon from the first team.
+ for (const id of newNonFavouriteIds) {
+ expect(originalIds).not.toContain(id);
+ }
+ expect(new Set(secondTeam.map((slot) => slot.id)).size).toBe(TEAM_SIZE);
+ });
+
+ it("reports allFavourited once every filled slot is favourited", async () => {
+ const result = await renderReadyTeam();
+ act(() => result.current.generateTeam());
+
+ expect(result.current.allFavourited).toBe(false);
+
+ for (let i = 0; i < TEAM_SIZE; i++) {
+ act(() => result.current.toggleFavourite(i));
+ }
+
+ expect(result.current.allFavourited).toBe(true);
+ });
+
+ it("persists the team to localStorage", async () => {
+ const result = await renderReadyTeam();
+ act(() => result.current.generateTeam());
+
+ await waitFor(() => {
+ const stored = JSON.parse(
+ localStorage.getItem("pokemonTeam") ?? "null",
+ );
+ expect(stored).toEqual(result.current.team);
+ });
+ });
});
diff --git a/PTG/src/usePokemonTeam.ts b/PTG/src/usePokemonTeam.ts
index cb17d04..e714136 100644
--- a/PTG/src/usePokemonTeam.ts
+++ b/PTG/src/usePokemonTeam.ts
@@ -1,72 +1,104 @@
import { useEffect, useState } from "react";
import { loadStoredTeam, saveStoredTeam, type TeamSlot } from "./pokemonTeam";
-import { useFilteredPokemon, usePokemonList, type Pokemon } from "./useFilteredPokemon";
+import {
+ useFilteredPokemon,
+ usePokemonList,
+ type Pokemon,
+} from "./useFilteredPokemon";
import type { PokemonType } from "./pokemonTypes";
function shuffle(items: T[]): T[] {
- const result = [...items];
- for (let i = result.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [result[i], result[j]] = [result[j], result[i]];
- }
- return result;
+ const result = [...items];
+ for (let i = result.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [result[i], result[j]] = [result[j], result[i]];
+ }
+ return result;
}
export type PokemonTeam = {
- team: TeamSlot[];
- pokemonById: Map;
- typeMap: Map | undefined;
- allFavourited: boolean;
- generateTeam: () => void;
- toggleFavourite: (index: number) => void;
+ team: TeamSlot[];
+ pokemonById: Map;
+ typeMap: Map | undefined;
+ allFavourited: boolean;
+ generateTeam: () => void;
+ toggleFavourite: (index: number) => void;
};
-export function usePokemonTeam(selectedGame: string, selectedType: string, selectedName: string): PokemonTeam {
- const [team, setTeam] = useState(() => loadStoredTeam());
+export function usePokemonTeam(
+ selectedGame: string,
+ selectedType: string,
+ selectedName: string,
+): PokemonTeam {
+ const [team, setTeam] = useState(() => loadStoredTeam());
- // The team can hold Pokémon that fall outside the current filter (picked before the filter changed),
- // so members are looked up from the full list, while new picks are drawn from the filtered one.
- const { data: allPokemon = [] } = usePokemonList();
- const { pokemon: filteredPokemon, typeMap } = useFilteredPokemon(selectedGame, selectedType, selectedName);
+ // The team can hold Pokémon that fall outside the current filter (picked before the filter changed),
+ // so members are looked up from the full list, while new picks are drawn from the filtered one.
+ const { data: allPokemon = [] } = usePokemonList();
+ const { pokemon: filteredPokemon, typeMap } = useFilteredPokemon(
+ selectedGame,
+ selectedType,
+ selectedName,
+ );
- useEffect(() => {
- saveStoredTeam(team);
- }, [team]);
+ useEffect(() => {
+ saveStoredTeam(team);
+ }, [team]);
- const pokemonById = new Map(allPokemon.map((entry) => [entry.id, entry]));
- const allFavourited = team.every((slot) => slot.favourite);
+ const pokemonById = new Map(allPokemon.map((entry) => [entry.id, entry]));
+ const allFavourited = team.every((slot) => slot.favourite);
- function generateTeam() {
- // Built fresh inside the updater (rather than closed over) so nothing is mutated
- // across React StrictMode's double-invocation of setState updaters.
- setTeam((current) => {
- // Exclude every Pokémon already on the team (not just favourites) so a slot can't end up duplicated.
- const teamIds = new Set(current.filter((slot) => slot.id !== null).map((slot) => slot.id));
- const pool = filteredPokemon.filter(({ id }) => !teamIds.has(id));
+ function generateTeam() {
+ // Built fresh inside the updater (rather than closed over) so nothing is mutated
+ // across React StrictMode's double-invocation of setState updaters.
+ setTeam((current) => {
+ // Exclude every Pokémon already on the team (not just favourites) so a slot can't end up duplicated.
+ const teamIds = new Set(
+ current
+ .filter((slot) => slot.id !== null)
+ .map((slot) => slot.id),
+ );
+ const pool = filteredPokemon.filter(({ id }) => !teamIds.has(id));
- const nonFavouriteIndices = current
- .map((slot, index) => (slot.favourite ? -1 : index))
- .filter((index) => index !== -1);
+ const nonFavouriteIndices = current
+ .map((slot, index) => (slot.favourite ? -1 : index))
+ .filter((index) => index !== -1);
- // When the filter has fewer non-teammate matches than there are non-favourite slots,
- // only that many slots get replaced - chosen at random - and the rest are left as they were.
- const replaceCount = Math.min(pool.length, nonFavouriteIndices.length);
- const indicesToReplace = new Set(shuffle(nonFavouriteIndices).slice(0, replaceCount));
- const picks = shuffle(pool).slice(0, replaceCount);
+ // When the filter has fewer non-teammate matches than there are non-favourite slots,
+ // only that many slots get replaced - chosen at random - and the rest are left as they were.
+ const replaceCount = Math.min(
+ pool.length,
+ nonFavouriteIndices.length,
+ );
+ const indicesToReplace = new Set(
+ shuffle(nonFavouriteIndices).slice(0, replaceCount),
+ );
+ const picks = shuffle(pool).slice(0, replaceCount);
- let pickIndex = 0;
- return current.map((slot, index) => {
- if (!indicesToReplace.has(index)) return slot;
- return { ...slot, id: picks[pickIndex++].id };
- });
- });
- }
+ let pickIndex = 0;
+ return current.map((slot, index) => {
+ if (!indicesToReplace.has(index)) return slot;
+ return { ...slot, id: picks[pickIndex++].id };
+ });
+ });
+ }
- function toggleFavourite(index: number) {
- setTeam((current) =>
- current.map((slot, i) => (i === index && slot.id !== null ? { ...slot, favourite: !slot.favourite } : slot))
- );
- }
+ function toggleFavourite(index: number) {
+ setTeam((current) =>
+ current.map((slot, i) =>
+ i === index && slot.id !== null
+ ? { ...slot, favourite: !slot.favourite }
+ : slot,
+ ),
+ );
+ }
- return { team, pokemonById, typeMap, allFavourited, generateTeam, toggleFavourite };
+ return {
+ team,
+ pokemonById,
+ typeMap,
+ allFavourited,
+ generateTeam,
+ toggleFavourite,
+ };
}
diff --git a/PTG/vite.config.ts b/PTG/vite.config.ts
index 81dcdf4..ecd0d56 100644
--- a/PTG/vite.config.ts
+++ b/PTG/vite.config.ts
@@ -11,6 +11,7 @@ export default defineConfig({
],
test: {
environment: 'jsdom',
+ isolate: false,
setupFiles: './src/test/setup.ts',
},
})