From 969e96bba9b2356a24e0dcb648110127b2350075 Mon Sep 17 00:00:00 2001 From: Halvor Date: Thu, 17 Sep 2026 17:59:39 +0200 Subject: [PATCH 01/24] Border at bottom, bigger title, and more --- PTG/src/Header.css | 11 ++++++++++- PTG/src/Header.tsx | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/PTG/src/Header.css b/PTG/src/Header.css index 1370523..635aa25 100644 --- a/PTG/src/Header.css +++ b/PTG/src/Header.css @@ -5,6 +5,9 @@ header { justify-content: center; width: 100%; margin-bottom: 5px; + border-bottom: solid 3px; + border-bottom-color: black; + border-bottom-style: outset; } .about{ @@ -13,13 +16,19 @@ header { color: white; margin-top: 5px; margin-bottom: 5px; + justify-content: right; +} + +.about:hover{ + filter: brightness(90%); } .websiteName{ color: white; margin-top: 5px; margin-bottom: 5px; - + font-size: xx-large; + width: 50%; } @media screen and (max-width: 400px){ diff --git a/PTG/src/Header.tsx b/PTG/src/Header.tsx index ea6648e..dad0341 100644 --- a/PTG/src/Header.tsx +++ b/PTG/src/Header.tsx @@ -8,6 +8,7 @@ export default function Header(){ Pokémon Team Generator About + ); } \ No newline at end of file From 0cbecb83695282d4ecfe2053843be15a39f02fe3 Mon Sep 17 00:00:00 2001 From: Halvor Date: Thu, 17 Sep 2026 18:00:52 +0200 Subject: [PATCH 02/24] Grouped with divs for more css functionality --- PTG/src/Team.tsx | 59 +++++++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/PTG/src/Team.tsx b/PTG/src/Team.tsx index 37726d3..dff44ac 100644 --- a/PTG/src/Team.tsx +++ b/PTG/src/Team.tsx @@ -12,35 +12,38 @@ export default function Team({ team, pokemonById, typeMap, allFavourited, genera return ( <> -
- {team.map((slot, index) => { - const member = slot.id !== null ? pokemonById.get(slot.id) : undefined; - const types = member ? typeMap?.get(member.name) : undefined; - return ( -
-
- {member && {member.name}} - - {types && types.length > 0 && ( -
- {types.map((type: PokemonType) => ( - {type.name} - ))} -
- )} +
+

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 && {member.name}} + + {types && types.length > 0 && ( +
+ {types.map((type: PokemonType) => ( + {type.name} + ))} +
+ )} +
+ {member ? capitalize(member.name) : "Empty"}
- {member ? capitalize(member.name) : "Empty"} -
- ); - })} + ); + })} +
); From 920c819213905715e6c23e4f5667789a87d59099 Mon Sep 17 00:00:00 2001 From: Halvor Date: Thu, 17 Sep 2026 18:01:40 +0200 Subject: [PATCH 03/24] Styling for the team grid, and more --- PTG/src/Team.css | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/PTG/src/Team.css b/PTG/src/Team.css index 0475194..bbb0319 100644 --- a/PTG/src/Team.css +++ b/PTG/src/Team.css @@ -16,9 +16,18 @@ .teamGrid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + 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; } .teamCard { From e4b2e61d3ab610375d9c37ce11e391f6a485c19a Mon Sep 17 00:00:00 2001 From: Halvor Date: Thu, 17 Sep 2026 18:07:52 +0200 Subject: [PATCH 04/24] Cleaning up .header class --- PTG/src/Header.css | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/PTG/src/Header.css b/PTG/src/Header.css index 635aa25..778243a 100644 --- a/PTG/src/Header.css +++ b/PTG/src/Header.css @@ -5,9 +5,7 @@ header { justify-content: center; width: 100%; margin-bottom: 5px; - border-bottom: solid 3px; - border-bottom-color: black; - border-bottom-style: outset; + border-bottom: solid black 3px; } .about{ From e92cbcb1d74d817fa6044e5e31cc8426521ba48c Mon Sep 17 00:00:00 2001 From: Halvor Date: Thu, 17 Sep 2026 18:08:12 +0200 Subject: [PATCH 05/24] Styling footer to fit in with the website --- PTG/src/Footer.css | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/PTG/src/Footer.css b/PTG/src/Footer.css index 2d160a1..7527671 100644 --- a/PTG/src/Footer.css +++ b/PTG/src/Footer.css @@ -1,20 +1,21 @@ .footer{ - background-color: grey; + background-color: red; bottom: 0; width: 100%; padding-bottom: 10px; + border-top: solid black 3px; } div{ margin: 0; } -p, a{ +.footer > p, a{ margin: 0; margin-top: 5px; text-align: center; text-decoration: none; - color: black; + color: white; transition: transform 0.3s ease-in-out; } From dd18efec49f24e4abf5aea40b92ece0443acd0e7 Mon Sep 17 00:00:00 2001 From: Halvor Date: Thu, 17 Sep 2026 18:11:17 +0200 Subject: [PATCH 06/24] Changed title from just Link object to h1 object --- PTG/src/Header.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PTG/src/Header.tsx b/PTG/src/Header.tsx index dad0341..c5f7004 100644 --- a/PTG/src/Header.tsx +++ b/PTG/src/Header.tsx @@ -5,7 +5,8 @@ export default function Header(){ return(
- Pokémon Team Generator +

+ Pokémon Team Generator

About
From 30cdff6d158a1885bdc1b1ecdf2777b56a57813e Mon Sep 17 00:00:00 2001 From: Halvor Date: Thu, 17 Sep 2026 18:11:36 +0200 Subject: [PATCH 07/24] Fixes for h1 change to title --- PTG/src/Header.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PTG/src/Header.css b/PTG/src/Header.css index 778243a..b159d95 100644 --- a/PTG/src/Header.css +++ b/PTG/src/Header.css @@ -25,7 +25,7 @@ header { color: white; margin-top: 5px; margin-bottom: 5px; - font-size: xx-large; + font-size: large; width: 50%; } From bcc05eb86dad268dea7155039c1580a7ba186c3f Mon Sep 17 00:00:00 2001 From: Halvor Date: Thu, 17 Sep 2026 18:13:23 +0200 Subject: [PATCH 08/24] Margin change in title --- PTG/src/Header.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PTG/src/Header.css b/PTG/src/Header.css index b159d95..187e31a 100644 --- a/PTG/src/Header.css +++ b/PTG/src/Header.css @@ -23,8 +23,8 @@ header { .websiteName{ color: white; - margin-top: 5px; - margin-bottom: 5px; + margin-top: 1px; + margin-bottom: 1px; font-size: large; width: 50%; } From 15d6607bd531c0f9ba273a50e52a95519cad440b Mon Sep 17 00:00:00 2001 From: Ayse Zeynep Aydin Date: Thu, 17 Sep 2026 18:45:41 +0200 Subject: [PATCH 09/24] Updated old className in @media --- PTG/src/Header.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PTG/src/Header.css b/PTG/src/Header.css index 187e31a..fb064c0 100644 --- a/PTG/src/Header.css +++ b/PTG/src/Header.css @@ -30,7 +30,7 @@ header { } @media screen and (max-width: 400px){ - .name{ - font-size: x-large; + .websiteName{ + font-size: small; } -} \ No newline at end of file +} From 13500e3d9c247686c9cf21019b484772d696fdd8 Mon Sep 17 00:00:00 2001 From: Ayse Zeynep Aydin Date: Thu, 17 Sep 2026 21:47:43 +0200 Subject: [PATCH 10/24] feat: Added About.css and edited about text. --- PTG/src/About.css | 11 +++++++++++ PTG/src/About.tsx | 34 ++++++++++++++++++++-------------- 2 files changed, 31 insertions(+), 14 deletions(-) create mode 100644 PTG/src/About.css diff --git a/PTG/src/About.css b/PTG/src/About.css new file mode 100644 index 0000000..dbf579a --- /dev/null +++ b/PTG/src/About.css @@ -0,0 +1,11 @@ +.container{ + display: flex; + flex-direction: column; + align-items: center; + margin: 0 15vw; +} + +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 c4016b3..c77756e 100644 --- a/PTG/src/About.tsx +++ b/PTG/src/About.tsx @@ -1,22 +1,28 @@ -import Header from "./Header.tsx" - +import Header from "./Header.tsx"; +import "./About.css"; export default function About(){ return( <>
-

About

-

- This is the result of a group project in IT2810 at NTNU. - 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. -

+
+

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 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. 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. +

+
); } \ No newline at end of file From 17d5c708d34f35a8a4219504581a35a0512257f5 Mon Sep 17 00:00:00 2001 From: Ayse Zeynep Aydin Date: Thu, 17 Sep 2026 22:37:21 +0200 Subject: [PATCH 11/24] feat: Enlarged the textbox and dropdowns --- PTG/src/ChoosePokemon.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/PTG/src/ChoosePokemon.css b/PTG/src/ChoosePokemon.css index c2e26b9..7fc56e2 100644 --- a/PTG/src/ChoosePokemon.css +++ b/PTG/src/ChoosePokemon.css @@ -2,11 +2,17 @@ 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 From 10b7d28293f6524ea30ea4148d5594c2bc736f30 Mon Sep 17 00:00:00 2001 From: Halvor Date: Fri, 18 Sep 2026 00:42:10 +0200 Subject: [PATCH 12/24] Added footer to about page --- PTG/src/About.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/PTG/src/About.css b/PTG/src/About.css index dbf579a..36b1b82 100644 --- a/PTG/src/About.css +++ b/PTG/src/About.css @@ -3,6 +3,13 @@ flex-direction: column; align-items: center; margin: 0 15vw; + min-height: calc(89.5vh - 60px); +} + +.footer-pin{ + position: relative; + bottom: 0; + left: 0; } p{ From 0bde36a3ce4eb3de5dbb93f36551e4bb8e0ba9cb Mon Sep 17 00:00:00 2001 From: Halvor Date: Fri, 18 Sep 2026 00:43:33 +0200 Subject: [PATCH 13/24] Added footer to about page --- PTG/src/About.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/PTG/src/About.tsx b/PTG/src/About.tsx index c77756e..6c8a6d0 100644 --- a/PTG/src/About.tsx +++ b/PTG/src/About.tsx @@ -1,4 +1,5 @@ import Header from "./Header.tsx"; +import Footer from "./Footer.tsx"; import "./About.css"; export default function About(){ @@ -23,6 +24,9 @@ export default function About(){ second project.

+
+
+
); } \ No newline at end of file From e2c9a8c55c37239ade49aef993ef9ffe3f525c80 Mon Sep 17 00:00:00 2001 From: Halvor Date: Fri, 18 Sep 2026 00:44:14 +0200 Subject: [PATCH 14/24] fix: Made footer stick to bottom --- PTG/src/App.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/PTG/src/App.css b/PTG/src/App.css index e69de29..aa05d28 100644 --- a/PTG/src/App.css +++ b/PTG/src/App.css @@ -0,0 +1,9 @@ +.content{ + min-height: calc(100vh - 60px); +} + +.footer-pin{ + position: relative; + bottom: 0; + left: 0; +} \ No newline at end of file From 52b19fed196fb8ef58e97931510f2d74c2e65a40 Mon Sep 17 00:00:00 2001 From: Halvor Date: Fri, 18 Sep 2026 00:44:35 +0200 Subject: [PATCH 15/24] Added extra divs for App.css file --- PTG/src/App.tsx | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/PTG/src/App.tsx b/PTG/src/App.tsx index 7b160d6..7824600 100644 --- a/PTG/src/App.tsx +++ b/PTG/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; 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"; @@ -20,25 +21,29 @@ export default function App(){ return(
-
- - - -
+
+
+ + + +
+
+
+
); From 15c9ae48c99d301bafbdef3b34889f16d00ae98b Mon Sep 17 00:00:00 2001 From: Ayse Zeynep Aydin Date: Fri, 18 Sep 2026 08:58:45 +0200 Subject: [PATCH 16/24] Text update Text said footer was in the home page, changed that so it says it is in this page too. --- PTG/src/About.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PTG/src/About.tsx b/PTG/src/About.tsx index 6c8a6d0..f99f398 100644 --- a/PTG/src/About.tsx +++ b/PTG/src/About.tsx @@ -19,7 +19,7 @@ export default function About(){

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. Any feedback will be deeply appreciated, + 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.

@@ -29,4 +29,4 @@ export default function About(){
); -} \ No newline at end of file +} From 04769d2df63c0aee887e05db9707b988fc2b8b22 Mon Sep 17 00:00:00 2001 From: Halvor Date: Fri, 18 Sep 2026 09:42:10 +0200 Subject: [PATCH 17/24] Improved performance of test --- PTG/src/Footer.css | 1 - PTG/vite.config.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/PTG/src/Footer.css b/PTG/src/Footer.css index 7527671..0cf3730 100644 --- a/PTG/src/Footer.css +++ b/PTG/src/Footer.css @@ -1,6 +1,5 @@ .footer{ background-color: red; - bottom: 0; width: 100%; padding-bottom: 10px; border-top: solid black 3px; 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', }, }) From 4cefbe27da222d4f48eb13de057a98998d0494c2 Mon Sep 17 00:00:00 2001 From: Halvor Date: Fri, 18 Sep 2026 10:39:45 +0200 Subject: [PATCH 18/24] Indent change in prettier --- PTG/.prettierrc.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 PTG/.prettierrc.json 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 From ed32506a25582d90a636d9539f292b90d0ff0053 Mon Sep 17 00:00:00 2001 From: Halvor Date: Fri, 18 Sep 2026 10:40:40 +0200 Subject: [PATCH 19/24] Installed prettier and setup to match ESLint --- PTG/eslint.config.js | 2 ++ PTG/package.json | 2 ++ PTG/pnpm-lock.yaml | 23 +++++++++++++++++++++++ PTG/pnpm-workspace.yaml | 1 + 4 files changed, 28 insertions(+) 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 From a13491be2dd673d4d5e884ac30d5dbe18daa4c11 Mon Sep 17 00:00:00 2001 From: Halvor Date: Fri, 18 Sep 2026 10:40:59 +0200 Subject: [PATCH 20/24] Changes with prettier --- PTG/src/About.css | 8 +- PTG/src/About.tsx | 28 ++-- PTG/src/App.css | 6 +- PTG/src/App.tsx | 94 +++++++----- PTG/src/ChoosePokemon.css | 28 ++-- PTG/src/ChoosePokemon.test.tsx | 159 ++++++++++++--------- PTG/src/ChoosePokemon.tsx | 48 +++++-- PTG/src/Footer.css | 9 +- PTG/src/Footer.tsx | 18 +-- PTG/src/Header.css | 10 +- PTG/src/Header.test.tsx | 15 +- PTG/src/Header.tsx | 24 ++-- PTG/src/NotFoundPage.tsx | 9 +- PTG/src/PokemonGrid.css | 56 ++++---- PTG/src/PokemonGrid.test.tsx | 179 ++++++++++++++--------- PTG/src/PokemonGrid.tsx | 97 ++++++++----- PTG/src/Team.css | 118 +++++++-------- PTG/src/Team.test.tsx | 169 ++++++++++++---------- PTG/src/Team.tsx | 130 +++++++++++------ PTG/src/games.test.ts | 214 +++++++++++++++++----------- PTG/src/games.ts | 95 ++++++------ PTG/src/index.css | 4 +- PTG/src/main.tsx | 54 +++---- PTG/src/pokemonFilters.test.ts | 106 +++++++++----- PTG/src/pokemonFilters.ts | 55 ++++--- PTG/src/pokemonTeam.test.ts | 110 ++++++++------ PTG/src/pokemonTeam.ts | 54 ++++--- PTG/src/pokemonTypes.test.ts | 146 +++++++++++-------- PTG/src/pokemonTypes.ts | 101 +++++++------ PTG/src/test/setup.ts | 2 +- PTG/src/test/withQueryClient.tsx | 16 ++- PTG/src/useFilteredPokemon.test.tsx | 214 +++++++++++++++++----------- PTG/src/useFilteredPokemon.ts | 131 +++++++++-------- PTG/src/usePokemonTeam.test.tsx | 210 ++++++++++++++------------- PTG/src/usePokemonTeam.ts | 136 +++++++++++------- 35 files changed, 1661 insertions(+), 1192 deletions(-) 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 0cf3730..d01856b 100644 --- a/PTG/src/Footer.css +++ b/PTG/src/Footer.css @@ -1,15 +1,16 @@ -.footer{ +.footer { background-color: red; 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; @@ -18,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 ( ); -} \ 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

- ); -} \ 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 ( -
-
- {name} - {types && types.length > 0 && ( -
- {types.map((type: PokemonType) => ( - {type.name} - ))} -
- )} -
- {capitalize(name)} -
- ); - })} -
- ); + return ( +
+ {pokemon.map(({ id, name, image }) => { + const types = typeMap?.get(name); + return ( +
+
+ {name} + {types && types.length > 0 && ( +
+ {types.map((type: PokemonType) => ( + {type.name} + ))} +
+ )} +
+ {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 ( - <> - -
-

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 && {member.name}} - - {types && types.length > 0 && ( -
- {types.map((type: PokemonType) => ( - {type.name} - ))} -
- )} -
- {member ? capitalize(member.name) : "Empty"} -
- ); - })} -
-
- - ); +export default function Team({ + team, + pokemonById, + typeMap, + allFavourited, + generateTeam, + toggleFavourite, +}: TeamProps) { + return ( + <> + +
+

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 && ( + {member.name} + )} + + {types && types.length > 0 && ( +
+ {types.map((type: PokemonType) => ( + {type.name} + ))} +
+ )} +
+ + {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, + }; } From ea897491a091da3ed4dd476a0f77c8ef64beaa4c Mon Sep 17 00:00:00 2001 From: Ayse Zeynep Aydin Date: Fri, 18 Sep 2026 11:15:43 +0200 Subject: [PATCH 21/24] docs: Added a paragraph on how to use the filters. --- PTG/src/About.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/PTG/src/About.tsx b/PTG/src/About.tsx index c67bbb6..f45856f 100644 --- a/PTG/src/About.tsx +++ b/PTG/src/About.tsx @@ -17,6 +17,14 @@ export default function About() { Pokémon showing up in the Team Generator. Please be aware of this when generating a team.

+

+ To randomly generate your team simply click the "Generate + Team" button. If you would like to keep a pokémon in the + team click the star icon. Balance your team by choosing + from different types and/or games. This can be done by using + the type and game filters at the top of the home page in combination + with the generate button. +

This website is made by students, and is made for training reasons, also for lazy people not wanting to pick a team From a6da654a554720e0dffc2e12e1e92a286a339ee1 Mon Sep 17 00:00:00 2001 From: Halvor Date: Fri, 18 Sep 2026 12:54:37 +0200 Subject: [PATCH 22/24] Resizing for devices with max-width <= 600px --- PTG/src/About.css | 8 +++++++- PTG/src/ChoosePokemon.css | 13 ++++++++++++- PTG/src/Header.css | 8 ++++++-- PTG/src/Team.css | 9 ++++++++- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/PTG/src/About.css b/PTG/src/About.css index dbc3e58..dbd7f13 100644 --- a/PTG/src/About.css +++ b/PTG/src/About.css @@ -3,7 +3,7 @@ flex-direction: column; align-items: center; margin: 0 15vw; - min-height: calc(89.5vh - 60px); + min-height: calc(93.5vh - 60px); } .footer-pin { @@ -16,3 +16,9 @@ p { text-align: justify; margin-bottom: 1rem; } + +@media screen and (max-width: 600px) { + .container{ + margin: 1rem; + } +} diff --git a/PTG/src/ChoosePokemon.css b/PTG/src/ChoosePokemon.css index 8304e3f..2e34708 100644 --- a/PTG/src/ChoosePokemon.css +++ b/PTG/src/ChoosePokemon.css @@ -2,7 +2,8 @@ field-sizing: content; width: fit-content; max-width: 100%; - margin-left: 1%; + margin-left: 1rem; + min-height: 1.5rem; border-radius: 0; height: 20px; @@ -14,5 +15,15 @@ max-width: 100%; min-width: 50px; margin-left: 1rem; + margin-bottom: 5px; height: 20px; } + +@media screen and (max-width: 600px){ + .chooseName{ + margin: 5px; + } + .chooseType{ + margin: 5px; + } +} diff --git a/PTG/src/Header.css b/PTG/src/Header.css index 09ca04a..c2f9236 100644 --- a/PTG/src/Header.css +++ b/PTG/src/Header.css @@ -29,8 +29,12 @@ header { width: 50%; } -@media screen and (max-width: 400px) { +.websiteName > h1 { + margin: 5px; +} + +@media screen and (max-width: 600px) { .websiteName { - font-size: small; + font-size: x-small; } } diff --git a/PTG/src/Team.css b/PTG/src/Team.css index 3304d91..649d7f3 100644 --- a/PTG/src/Team.css +++ b/PTG/src/Team.css @@ -2,7 +2,8 @@ background-color: rgb(252, 197, 59); border: solid 1px grey; border-radius: 5px; - margin: 0px 1%; + margin-left: 1rem; + margin-top: 5px; } .generateTeamButton:hover { @@ -95,3 +96,9 @@ .teamCardEmpty { color: #999; } + +@media screen and (max-width: 615px) { + .generateTeamButton{ + margin-left: 5px; + } +} \ No newline at end of file From 0bc44fb6402b21e902c5b549e265caf2f815f6b6 Mon Sep 17 00:00:00 2001 From: Halvor Date: Fri, 18 Sep 2026 12:59:29 +0200 Subject: [PATCH 23/24] Reduced typings to exclude stellar --- PTG/src/pokemonTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PTG/src/pokemonTypes.ts b/PTG/src/pokemonTypes.ts index 6dd2dd2..37f4015 100644 --- a/PTG/src/pokemonTypes.ts +++ b/PTG/src/pokemonTypes.ts @@ -1,6 +1,6 @@ // Type data pulled live from PokeAPI, so new types (and the generation // that introduces them) show up automatically. -const TYPE_LIST_URL = "https://pokeapi.co/api/v2/type?limit=100"; +const TYPE_LIST_URL = "https://pokeapi.co/api/v2/type?limit=18"; const TYPE_URL = (name: string) => `https://pokeapi.co/api/v2/type/${name}`; // "unknown" (???) and "shadow" are not real battle types you can filter by. From 5573d484b270e51ef7c2f67154a5409043dd2292 Mon Sep 17 00:00:00 2001 From: Halvor Date: Fri, 18 Sep 2026 13:46:25 +0200 Subject: [PATCH 24/24] fix: vitetest feedback --- PTG/src/pokemonTypes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PTG/src/pokemonTypes.test.ts b/PTG/src/pokemonTypes.test.ts index 3184b4f..1c9d488 100644 --- a/PTG/src/pokemonTypes.test.ts +++ b/PTG/src/pokemonTypes.test.ts @@ -60,7 +60,7 @@ 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") { + if (url === "https://pokeapi.co/api/v2/type?limit=18") { return jsonResponse({ results: [ {