diff --git a/.env b/.env index 0058682..da03030 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -POKEMON_API_BASE_URL = +POKEMON_API_BASE_URL = "https://pokeapi.co/api/v2" QUIZ_MAX_TIME_SECONDS = 120 diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index eea49c8..b9d81af 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -10,7 +10,7 @@ on: jobs: build: - name: "Build Star Wars Quiz application" + name: "Build Pokemon Quiz application" runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -25,7 +25,7 @@ jobs: name: Upload build as artifact if: always() with: - name: swquiz-dist + name: pokequiz-dist path: ./dist deploy-on-github-pages: name: "Deploy app to GitHub Pages from main branch source code" @@ -35,7 +35,7 @@ jobs: steps: - uses: actions/download-artifact@v2 with: - name: swquiz-dist + name: pokequiz-dist path: ./dist - name: Deploy app as GitHub Pages uses: peaceiris/actions-gh-pages@v3.7.0-6 diff --git a/.gitignore b/.gitignore index b05591d..fab8dc4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist .cache node_modules coverage +.vscode \ No newline at end of file diff --git a/README.md b/README.md index c8d2ca3..1e508ec 100644 --- a/README.md +++ b/README.md @@ -1,271 +1,74 @@ -**UWAGA! Zaczynając pracę nad projektem — nie róbcie forka. -Jedna osoba z zespołu (np. Mentor) powinna użyć przycisku `Use this template` i dodać innych członków zespołu jako Collaborators do tego repozytorium.** - # CodersCamp 2020 - Projekt JavaScript -**CodersCamp (coderscamp.edu.pl) - Największy otwarty kurs programowania webowego** - -Wykorzystanie asynchronicznego JavaScript oraz korzystanie z REST API. - -![Star Wars Quiz - Ekrany](./.github/images/StarWarsQuizEkrany.png) -Proponowany projekt — Quiz Star Wars (opis poniżej). - -### Zasady wykonywania projektu (wspólne dla wszystkich grup i mentorów): - -##### W projekcie każdy z uczestników powinien zaprezentować praktyczną znajomość poniższych zagadnień związanych z JavaScript: -- zmienne -- operatory porównania -- pętle -- obiekty, atrybuty -- warunki -- funkcje -- operatory logiczne -- tablice -- iteracja i/lub rekurencja -- console -- return -- "===" vs "==" -- integracja z zewnętrznym REST API -- interakcja z domem -- odwoływanie się do elementów DOM z JavaScript -- zmiana stylów z poziomu JSa -- zmiana zawartości HTML z poziomu JSa -- animacje -- zewnętrzne biblioteki -- async await i/lub Promise -- funkcje callback -- metody HTTP -- pisanie testów jednostkowych - -Do implementacji nie używajcie React (tego nauczycie się w dalszej części kursu), czy takich frameworków jak Angular. -Najlepiej odstawcie też na bok biblioteki stylów takie jak Bootstrap — na upraszczanie życia przyjdzie jeszcze czas. -Ważne, żeby opanować, to co, jest pod spodem gotowych już bibliotek i budować na solidnym fundamencie. -Skupcie się na wykorzystaniu w praktyce tego, co nauczyliście się dzięki materiałom w przerabianym dziale. - - -##### W trakcie trwania projektu należy wyznaczyć w zespole odpowiednie funkcje -Aby zespół pracował efektywnie, ważne jest, żeby było wiadomo, kto odpowiada, za jaką kwestię. -Powstało wiele różnych metodyk wspomagające działanie zespołu, które stosuje się także przy pracy programisty. - -W trakcie trwania kursu CodersCamp spróbujemy przemycić Wam o nich jak najwięcej w praktyce. -Niestety forma kursu znacznie ogranicza możliwości — zazwyczaj programiści na swoją pracę i jej organizację poświęcają cały etat. -Tutaj nie mamy tyle czasu na pełne zastosowanie np. Scruma, czy też innych technik. -Mamy jednak nadzieję, że po kursie nie będą one dla Was już wielką niewiadomą. -Mentorzy z pewnością postarają się zorganizować Wam pracę tak, aby w jak największym stopniu odzwierciedlała realia ich codzienności. - -Pierwszym krokiem do lepszej organizacji Waszego zespołu będzie wyznaczenie w nim kilku funkcji, które są typowe dla projektów IT. -Z pewnością spotkanie się z nimi w praktyce zawodowej. -Najlepiej, gdyby uczestnicy po prostu się zgłosili. -W przypadku braku chętnych mentor wyznacza „ochotników". -Oczywiście każda z ról wykonuje prace programistyczne (w przypadku CodersCamp, w rzeczywistości jest to różnie), dodatkowo zajmując się wspomnianymi dla danej roli obowiązkami. -Role należy zmieniać następnie co projekt, aby każdy miał szansę się sprawdzić w którejś z nich. -Szczególnie w pierwszym projekcie poproście mentora o pomoc w spełnianiu swoich ról i podzieleniu się zadaniami. -Wasz mentor może oczywiście pokierować pracą trochę inaczej i zaproponować inny podział lub dodać też jakąś funkcję w zespole. -Warto zorganizować spotkanie rozpoczynające prace, na którym wykonacie i/lub omówicie podstawowy setup projektu. - -###### Klient -Zawsze jest to **Mentor**. Uważajcie! -Ten klient ma też zdolności techniczne i lepiej z nim nie dyskutować, jeśli coś „zaproponuje”. -Dodatkowe testy czy zmiana sposobu implementacji to uwagi jak najbardziej na miejscu. -Pamiętajcie też, że jedyną stałą w projektach informatycznych jest zmiana. -Wszelkie zmiany w projekcie, jakie zaproponuje Klient, powinny jak najbardziej zostać wzięte pod uwagę :) -W sytuacjach krytycznych można też poprosić go o posłużenie radą. Będzie też przeglądał każdy wasz wykonany kod. - -###### Tech Lead -Ma ostateczne zdanie w kwestiach związanych z technologią, ale dobrze, jeśli radzi się zespołu. -Np. jak dzielimy moduły projektu, w jaki sposób testujemy, której biblioteki użyć. -Powinien też respektować zdanie klienta. -Jeśli Tech Lead będzie przeprowadzał Code Review zadań, jest to jak najbardziej na plus. -Chociaż zachęcamy do tego wszystkich członków zespołu. - -###### Product Owner -Odpowiada za wizję produktu i kwestie związane z funkcjonalnościami. -Powinien podejmować ostateczne decyzje odnośnie do wątpliwości związanych z wymaganiami i wyjaśniać je z klientem. -Bardzo pożądane jest, aby często konsultował się z klientem i starał się, aby reszta zespołu mogła się skupić na swoich zadaniach zamiast doprecyzowywaniu wymagań. - -###### Development Manager -Oczywiście Klientowi zależy najbardziej na tym, aby projekt zakończył się na czas. -Dlatego zespół będzie nieustannie przez niego kontrolowany. -Jednakże, w trakcie pracy ważne jest, aby uzyskać zaufanie klienta i te kontrole nie były w ogóle potrzebne. -Development Manager będzie dbał odpowiednio o terminy, podział zadań, a także wywiązywanie się z obowiązków innych członków zespołu. -Powinien też kontrolować jakoś pracy — np. poprzez pilnowanie regularnych Code Review lub organizowanie programowania w parach (jeśli Wasz mentor jest za takimi praktykami). -Jeśli spełni odpowiednio swoją funkcję, to duża szansa, że uda wam się uformować efektywny i zgrany zespół, a klienci nie będą wypatrywali tylko na wasze potknięcia :) -W gestii Development Managera leży też organizowanie codziennych daily. Najlepiej, aby przyjęły formę wiadomości na Discord. -W ciągu dnia każda osoba z zespołu powinna odpowiedzieć na 3 pytania: -- Co zrobiła od ostatniego daily? -- Co planuje zrobić do kolejnego daily? -- Czy są jakieś problemy przy wykonywaniu zadań? -Oczywiście poprawną odpowiedzią w przypadku CodersCamp jest też: -``` -1. Od ostatniego daily zaimplementował zadanie. Wystawiłem Pull Request (TUTAJ), proszę Was o code review. -2. Do kolejnego nic nie zrobię - całą środę pracuję. Zamierzam posiedzieć nad projektem dopiero od czwartku. -3. Mam problem z połączeniem z SWApi, czy ktoś mógłby zerknąć, błąd opisałem w issue na GitHub. -``` -Mogą być takie dni, że nie uda nam się zrobić nic albo po prostu nie planowaliście poświęcać czasu na CodersCamp, czy byliście cały dzień w pracy. -Najważniejsza jest komunikacja, aby wiedzieć, jaki jest status projektu. -Warto też organizować co tydzień zdzwonki, aby nie tylko pisać, ale też porozmawiać jak idzie projekt i ewentualnie poprosić mentora o pomoc / wyjaśnienia itp. -Pamiętajcie, że mentor jest ciągle do waszej dyspozycji, więc nie musicie specjalnie czekać, żeby się z nim skomunikować. - - -##### Sposób oceny projektu (i wszystkich kolejnych projektów na CodersCamp) -Zapewne interesuje Was, w jaki sposób projekt zostanie „zaliczony” i oceniony. -Ocenianie będzie miało kilka etapów i trochę różni się od projektu pierwszego. -Wynika to z tego, że teraz realizujecie projekt zespołowo i jesteście oceniani jako zespół. - -- Kiedy skończycie pracę nad projektem, odnotujcie ten fakt w specjalnie przygotowanym formularzu — dostępnym [TUTAJ](https://docs.google.com/forms/d/e/1FAIpQLScAFLQ2KHcOhS9mlZd_2ngq46hkXKkFOb8HjiILvMciGM35nw/viewform). -Powinno to nastąpić najpóźniej o godzinie 23:59 dnia poprzedzającego prezentację projektu (data dostępna w harmonogramie kursu). -Zgłosić projekt do oceny jest obowiązkiem osoby pełniącej funkcję Development Managera. -Powinna ona zadbać, aby do tego czasu wszystko było już dopięte na ostatni guzik. -- Spotkajcie się w wyznaczonym dniu jeszcze z 2 innymi zespołami i ich mentorami (np. za pomocą Google Meet). -W trakcie spotkania każdy zespół prezentuje wykonany projekt. -Dlatego zdecydujcie, kto to zrobi z Waszego zespołu. -Liczba osób jest dowolna, ale nie powinien tego robić mentor. -W przygotowanie prezentacji powinni zaangażować się wszyscy uczestnicy. -Forma prezentacji pozostaje dowolna (musi zmieścić się w 10 minutach + 5 minut na pytania). -Filmik, odegranie scenki, prezentacja multimedialna. Dozwolone jest wszystko, co wam przyjdzie na myśl (oczywiście w granicach dobrego smaku). -Na pewno musi zostać pokazana działająca aplikacja, reszta wg Waszego uznania. -Celem prezentacji jest „sprzedanie” (pokazanie, że to, co zrobiliście, spełnia założenia) Waszej aplikacji osobom obecnym na spotkaniu. -- Po prezentacji mentor Waszego zespołu oceni projekt wg kryteriów opisanych w specjalnym arkuszu — przykład takiego arkusza możecie zobaczyć [TUTAJ](https://docs.google.com/spreadsheets/d/1mjCi-oDXILKoCReqJlhGYP4NW-HVMCzvdcIy6ntnsog/edit?usp=sharing). Na dole wybierzcie zakładkę odpowiedniego projektu, np. "Projekt 2". -Dokładnie taką samą ocenę przeprowadzi jeden z mentorów obecnych na spotkaniu — tzw. mentor recenzent. -Mentor Waszego zespołu dodatkowo określi też, jakie było zaangażowanie każdej osoby w projekt — więc postarajcie się dać z siebie 100 w trakcie pracy! -Składa się na to między innymi: terminowość, spełnianie funkcji w projekcie, pomoc innym. -Pamiętajcie też odpowiednio opracować README.md Waszego projektu, tak aby prezentowało kto, co, jak i dlaczego zostało wykonane. -Zawartość tego pliku możecie przenieść gdzieś indziej albo jedynie zostawić link prowadzący do używanego repozytorium szablonowego. -README.md waszego repozytorium powinno być wizytówką aplikacji. Koniecznie musi się w nim znaleźć link do działającego DEMO. -Dotyczy to tego i wszystkich kolejnych projektów. -- Po zrecenzowaniu waszych projektów mentor powinien przekazać każdemu jego ocenę zaangażowania i feedback jako uzasadnienie. -Ocena projektu jest wspólna dla całego zespołu i jest równa średniej z ocen obu mentorów. -- Ostateczna punktacja dla uczestnika to procent jego zaangażowania z oceny projektu. -Pamiętajcie, że ocena nie jest najważniejsza — im więcej pracy wykonacie, tym więcej praktycznych umiejętności opanujecie. -Zachęcamy mentorów do uzasadniania przydzielonych punktów, tak abyście mogli wyciągnąć z nich, jak najwięcej na przyszłość. -Mentorzy mają różne doświadczenie zawodowe i będą z pewnością właśnie oceniać projekty przez jego pryzmat. -Dzięki zmianom mentorów recenzentów zobaczycie z pewnością różne spojrzenia na podobne kwestie. - - -## Quiz Gwiezdne Wojny -Teraz przechodzimy do przykładowego projektu, który został przygotowany przez organizatorów kursu. -Proponowany projekt pozwala na zastosowania większości umiejętności, jakie powinniście posiąść w trakcie przerabiania działu. -Jednakże jeśli macie pomysł na projekt podobnej skali, który spełni opisane na górze wymagania i czujecie się na siłach -w zdefiniowaniu funkcjonalności, przygotowaniu ekranów i podzieleniu go na zadania — to nic nie stoi na przeszkodzie, -aby wykonać np. coś związanego z zainteresowaniami Waszej grupy :) -W trakcie Coders Camp będą do wykonania jeszcze 2 kolejne aplikacje, więc jeśli teraz zdecydujecie się na projekt proponowany, zawsze w kolejnych możecie wykonać aplikacje wg. własnego pomysłu. -**Ostateczną decyzję, jaką aplikację realizować podejmuje mentor — to on zawsze wie najlepiej, co będzie z największą korzyścią dla Was i przy czym najwięcej się nauczycie.** -Powodzenia! - -Czas porzucić narrację CodersCamp i wcielić się w członka zespołu projektowego... +## Pokemon Quiz +Projekt wykorzystuje asynchronicznego JavaScript oraz korzysta z REST API. -### Założenia projektowe -Jedna ze znanych marek płatków śniadaniowych prowadzi wieloletnią współpracę z wytwórnią filmów Disney, do której od niedawna należą także Gwiezdne Wojny. -W ramach kolejnej akcji promocyjnej wasz zespół został poproszony o przygotowanie Proof of Concept aplikacji związanej ze Star Wars. -Po wstępnym rozpoznaniu i analizie biznesowej podjęto decyzję o przygotowaniu quizu sprawdzającego znajomość uniwersum Gwiezdnych Wojen. -Ma to być aplikacja webowa działająca w przeglądarce, bez potrzeby instalacji. +### Zespół projektowy +Mentor: +[Łukasz Dutka](https://github.com/lukaszdutka) -Klient dostarczył prototyp interfejsu użytkownika dostosowany pod Desktop ([TUTAJ](https://www.figma.com/proto/4HOOjnEYjb7W7xEh2Vb4lx/CodersCamp2020.Project.JavaScript.StarWarsQuiz?node-id=256%3A127&scaling=min-zoom)). -Pokazany został tylko jeden tryb i jedno pytanie. Cała reszta działa analogicznie. -Projekt: https://www.figma.com/file/4HOOjnEYjb7W7xEh2Vb4lx/CodersCamp2020.Project.JavaScript.StarWarsQuiz?node-id=256%3A107 -Może się przydać do odczytania np. cieni i kolorów. Nie zwracajcie uwagi na jednostki w px, należy użyć jednostek responsywnych. +Uczestnicy: +* [Kamil Arendarczyk](https://github.com/arendarczyk) (UX Designer) +* [Aleksandra Cypko](https://github.com/AleksandraCyp) (Tech Lead) +* [Małgorzata Dziewit](https://github.com/memeraki) +* [Daria Dziubałtowska](https://github.com/daria305) (Product Owner) +* [Agata Ludwiczyńska](https://github.com/AgataLudwiczynska) +* [Mariusz Smarż](https://github.com/mariusz-sm) (Develepment Manager) +**[Live - działająca aplikacja - zagraj i sprawdź czy dobrze rozpoznajesz Pokemony :)](https://lukaszdutka.github.io/CodersCamp2020.Project.JavaScript.pokemonquiz/)** -Dostarczona została także lista funkcjonalności. -1. Wybór trybu quizu (People, Vehicles, Spaceships) -2. Opis zasad dla quizu. Obok zasad pokazuje się losowe zdjęcie z danego trybu (dostosowany opis, jeśli np. imię osoby ze zdjęcia jest w opisie zasad). -3. Po rozpoczęciu gry rozpoczyna się odliczanie czasu (2 minuty). -4. Zadaniem gracza jest odpowiedzieć na jak najwięcej pytań w ciągu ustalonego czasu (dodatkowo gracz konkuruje także z komputerem! Komputer tak samo jak gracz próbuje rozpoznać co jest na grafice). -5. W trakcie trwania quizu miecz świetlny pokazuje, ile jeszcze czasu zostało. Po wybraniu odpowiedzi zostaje ukazane przez sekundę czy odpowiedź była dobra czy zła. Następnie pytanie zostaje zmienione na kolejne (prototyp pokazuje jedynie 1 pytanie) i tak do końca czasu. -5. Pytania są generowane w następujący sposób: - - zostaje pobrany losowy zasób z danego trybu (np people o id 5) - - zostanie pobrane dla wylosowanego zasobu zdjęcie - - losowane są 3 odpowiedzi z zapytania do StarWars API. Dla trybu "People" będzie to: https://swapi.co/api/people (jedna brana jest z wcześniej wylosowanego, musi być poprawna) -6. Po ukończeniu czasu wynik gracza zapisywany jest w rankingu dla danej przeglądarki (LocalStorage) i pokazywany jest ranking 3 najlepszych wyników. +Aplikacja została stworzona w oparciu o czysty JavaScript. Nie korzysta z żadnych frameworków ani bibliotek styli. -Jedno z wcześniejszych wykonań działającej aplikacji możecie zobaczyć [TUTAJ](https://nowakprojects.github.io/CodersCamp2020.Project.JavaScript.StarWarsQuiz.SampleSolution/index.html). -Jednakże nie należy się na nim 100% wzorować. -Niektóre wymagania mogły ulec zmianie, a przedstawiana aplikacja nie jest responsywna. -**Jeśli macie w swoim zespole osobę chętną na przygotowanie designów, to także możecie UI zrobić kompletnie inaczej.** - -Waszym zadaniem będzie zaimplementować aplikację, aby działała wg wymagań klienta, a także przygotować i wykonać -wersję responsywną aplikacji (dostosowaną do wyświetlania na Tabletach i Telefonach — możecie przygotować najpierw projekt interfejsu, lub od razu przejść do implementacji). -W celu zaprezentowania działania aplikacja musi być możliwa do odwiedzenia w internecie. -Klient nie chce ponosić za to żadnych dodatkowych kosztów, dlatego należy wykorzystać jedną z usług oferujących darmowe -uruchomienie takiej aplikacji (np. GitHub Pages). -Klient wymaga także, aby aplikacja nie tylko działała, ale była odpowiednio pokryta testami. -Naprawdę macie szczęście co do klienta! Wielu uważa testy za niepotrzebne i jedynie stratę pieniędzy. -A co znaczy „odpowiednio pokryta”? To już należy właśnie ustalić z samym Klientem :) -Wszelkie nieścisłości w wymaganiach powinien wyjaśniać Product Owner wraz z Klientem. - -### EventModeling -Działanie aplikacji zostało także zamodelowane za pomocą techniki EventModeling. -Jeżeli chcecie, to możecie skorzystać z poniższego diagramu. Zapoznać się z tą techniką możecie na [blogu autora tej metodologi](https://eventmodeling.org/posts/what-is-event-modeling/). -Aktualny diagram jest też dostępny w lepszej jakości na [tablicy MIRO](https://miro.com/app/board/o9J_kg8fTO4=/?moveToWidget=3074457351245562568&cot=12). -![StarWarsQuizEventModeling](.github/images/StarWarsQuizEventModeling.png) - -### Kod startowy projektu -Nad aplikacją pracę wcześniej zaczęli też inni programiści, po których otrzymujecie mały kawałek kodu. -Oto co zostało już przygotowane (możecie oczywiście dowolnie to zmieniać i konfigurować zgodnie z potrzebami zespołu): - -1. Zostały skonfigurowane GitHub Actions. W podobny sposób jak w pierwszym projekcie. Po wykonaniu kroków opisanych poprzednio -Wasza aplikacja powinna zostać wdrożona na GitHub Pages. -1. Aplikacja jest budowana przy pomocy narzędzia Parcel, z którym mieliście okazję się zapoznać w materiałach. -1. Został dodany framework do testów — Jest w sposób opisany [TUTAJ](https://ryankubik.com/blog/parcel-and-jest/). - - Testy powinny zostać umieszczone w katalogu `test`. Kod produkcyjny (testowany) w katalogu `src`. -1. SWApi, z którego będziecie korzystać, ma ograniczenie do 1000 zapytań z jednego adresu IP na dzień. -Dlatego, jeśli przekroczycie tę liczbę w trakcie developmentu, przydatne możecie się okazać użycie JSON SERVER z katalogu `swapi-json-server`. -1. SWApi nie zwraca wam obrazków dla poszczególnych zasobów, dlatego w katalogu `static/assets/img` znajdziecie obrazy odpowiadające konkretnym zasobom. -1. W katalogu `static/images/ui` znajdziecie wszystkie grafiki, jakie będą Wam potrzebne do wykonania interfejsu użytkownika wg projektu. -Jednakże jeśli jesteście w stanie zaproponować lepszy Interfejs Użytkownika, może zaproponować i wykonać alternatywny widok oraz zrezygnować z wcześniej przygotowanego. - -#### Uruchomienie projektu -Aby uruchomić aplikację na lokalnej maszynie, wykonaj następujące kroki: -1. Zainstaluj zależności za pomocą komendy: `npm install` -2. Wystartuj serwer developerski `npm run start:dev` - -Aplikacja będzie dostępna pod adresem [localhost:8765/index.html](localhost:8765/index.html) - -Kod produkcyjny aplikacji umieszczamy w katalogu `src`. - -#### Uruchomienie testów -Dodając swoje 5 groszy do naszej aplikacji, pamiętaj o pokryciu kodu testami. -Aby uruchomić testy aplikacji, wykonaj następujące kroki: -1. Zainstaluj zależności za pomocą komendy: `npm install` (jeśli nie zrobiłeś już tego wcześniej). -1. Uruchom testy, wykonując komendę: `npm run test`. Testy z raportem pokrycia uruchomisz za pomocą: `npm run test:cov`. - -Kod testów umieszczamy w katalogu `test`. - -## Możliwe usprawnienia i dodatkowe funkcjonalności: -- Wykorzystanie Speech Recognition API i wyszukiwanie odpowiedzi, jaką gracz wypowiedział zamiast klikania w przycisk z odpowiedzią. -- Rozbudowanie ustawień gry o możliwość wyboru czasu na rozgrywkę. - -## Dodatkowe zadania (wykraczające poza zakres kursu): -Jeśli starczy Wam czasu, zachęcamy do wykonania chociaż jednego z wymienionych poniżej. -Możliwe jest też dodanie zaprojektowanej przez Was funkcjonalności. -Wszelkie inne dodane przez Was funkcjonalności czy usprawnienia infrastrukturalne należy przedstawić w README.md projektu :) - -Rozszerzenia, które my proponujemy do wykonania, to: - -- Ograniczenie liczby requestów po zewnętrze zasoby poprzez zastosowanie wybranego przez zespół sposobu cache. -- Utworzenie z aplikacji pliku wykonywalnego, który mógłby działać jako aplikacja Desktop dołączana do płatków śniadaniowy. - Możecie do tego użyć [Electron](https://www.electronjs.org/docs/tutorial/first-app). Należy wtedy dodać w aplikacji przycisk: „POBIERZ WERSJĘ OFFLINE". -- Wykonanie testów E2E, przy użyciu odpowiedniego narzędzia. Proponujemy np. Cypress. -- **Wykonanie tego punktu, jak i wszystkich dodatkowych jest w pełni opcjonalne.** Aby nie narazić się na koszty, pamiętajcie, że Google Cloud Platform Free Trial obowiązuje tylko dla nowych kont. Firmy programistyczne bardzo rzadko piszą swoje systemu od zera, bardzo często korzysta się z dostarczonych już rozwiązań. Szczególnie poprzez wykorzystanie technologi w chmurach, takich jak AWS, Google Cloud czy Azure. Bardzo pożądane jest, aby mieć jakieś doświadczenie w tych kwestiach. Dlatego w tym punkcie proponujemy, aby odpowiedź komputera generowana była przy pomocy [Google Vision API](https://cloud.google.com/vision/docs). Jesto to usuługa dostępna w Google Cloud Platform. Aby było możliwe wykonanie zapytania, konieczne jest wygenerowanie ApiKey, umożliwiające dostęp do Google Vision API. Powinno ono być definiowane w ustawieniach gry, tak jak pokazano na projekcie w Figma. W przypadku nie zdefiniowania API Key używany będzie poprzednio zaimplentowany algorytm komputera. API Key przetrzymywać należy jedynie w pamięci aplikacji. Generowanie odpowiedzi przebiega w następujący sposób: - - zdjęcie jest przesyłane do GoogleVision API, z którego bierze się najwyższy wynik prawdopodobieństwa rozpoznania (albo kilka z nich, algorytm trzeba ustalić) - - przeszukiwane są wyniki działania GoogleVision dla zdjęcia, czy któryś z nich pokrywa się z odpowiedzią (ustalić stopień podobieństwa, np. odpowiedź to może być Jabba, a Google API zwróci "Jabba The Hutt") - - Skorzystanie z Google Vision API wymaga założenia konta w usłudzie Google Cloud Platform. Dla nowych użytkowników Google oferuje tzw. [Google Cloud Free Program](https://cloud.google.com/free/docs/gcp-free-tier), dzięki któremu po założeniu konta, nie zostaniesz obciążony żadnymi kosztami przez 90 dni korzystania z usługi. To z pewnością wystarczy na realizację tego projektu. Wymagane jest podłączenie karty płatniczej, ale dopóki na to nie pozwolisz, nie powinieneś zostać obciążony kosztami. Tak jak mówi treść przywołanej strony: - ``` -To complete your Free Trial signup, you must provide a credit card or other payment method to set up a Cloud Billing account and verify your identity. Don't worry, setting up a Cloud Billing account does not enable us to charge you. You are not charged unless you explicitly enable billing by upgrading your Cloud Billing account to a paid account. You can upgrade to a paid account at any time during the trial. After you have upgraded, you can still use any remaining credits (within the 90-day period). - ``` - Tutaj dostępna jest polska instrukcja zakładania konta: https://flyonthecloud.com/pl/blog/konto-gcp-rejestracja-konfiguracja/#Zakladanie_konta_Google_Cloud_Platform - - -## Technologie do wykorzystania: +### Wykorzystane technologie: - JavaScript - HTML - CSS -- Star Wars API -- opcjonalnie: Google Vision API / Electron.js / Cypress - -**Uwaga:** Każda inna technologia / Biblioteka jak najbardziej mile widziana, jeśli pomoże Ci osiągnąć zamierzony cel — zgodnie z opisanymi na początku zasadami :) - - -## Porady odnośnie do projektu -- Dzięki testing-library, elementy widoku (DOM) można testować wg Guide: https://testing-library.com/docs/dom-testing-library/example-intro - -hihi:) \ No newline at end of file +- Pokémon API + +### Narzędzia z jakimi pracowaliśmy: +- Visual Studio Code +- Git +- GitHub Desktop +- Figma +- Trello + +**Pokemon Quiz** wzorowany był na **Quiz Star Wars** udostępnionym przez **CodersCamp**. +Projekt jest ukończony. + +![Pokemon Quiz - start screen](./static/assets/readme-images/startscreen.png) +![Pokemon Quiz - game screen](./static/assets/readme-images/gamescreen.png) +![Pokemon Quiz - help / summary / leaderboard screen](./static/assets/readme-images/modalsscreen.png) + +#### Wymagania funkcjonalne + +1. Tryb quizu (powinien zawierać pokemony z pierwszej generacji): + - Podane jest zdjęcie pokemona, gracz zaznacza nazwę – 4 opcje + - Modyfikacja - Trudniejsza wersja – zdjęcie ukazuje tylko kształt pokemona + - Podana jest nazwa pokemona, gracz ma za zadanie zaznaczyć prawidłowe zdjęcie. +2. Zasady dostępne w osobnym oknie, po naciśnięciu pojawią się zasady gry dla wszystkich trybów na raz. +3. Zasady dla danego trybu: + - 2-3 zdania wyjaśnienia dla poszczególnego trybu +4. Gracz podaje imię przed rozpoczęciem gry, na ekranie startowym. + - Dopiero po podaniu imienia ma możliwość kliknięcia na `start` – rozpoczęcia gry. + - Pole cały czas dostępne do edycji, zmiana imienia powinna być bezproblemowa i niezależna od wybranego trybu gry. +5. Po rozpoczęciu gry rozpoczyna się odliczanie czasu (2 minuty). + - Czas nie jest ograniczony dla pojedynczych pytań. + - Jest ograniczona maksymalna liczba pytań - 30 (Aby uniknąć sytuacji w której gracz mógłby szybko przeklikać pytania z tą samą odpowiedzią i statystycznie otrzymałby 25% dobrych odpowiedzi, w łatwy sposób pokonując uczciwych graczy. + - W przypadku tej samej liczby punktów w rankingu brany jest pod uwagę czas ukończenia quizu. +6. W trakcie trwania quizu ukazywany jest czas pozostały do końca quizu. +7. Wybór odpowiedzi przez gracza. + - Kliknięcia na odpowiedź od razu powoduje jej zaznaczenie i udzielenie odpowiedzi. + - Po najechaniu na cały guzik/odpowiedź ma się on uwypuklać/podświetlać. +8. Pod koniec podsumowanie pytań – użytkownik może sprawdzić swoje odpowiedzi + - Informacja personalizowana imieniem jakie podał gracz. + - Zdjęcie pikachu (opcjonalne). +9. Generowanie pytań + - Pytania muszą być zabezpieczone przed powtarzaniem się, gracz nie może wylosować ponownie tego samego pytania. + - Zostaje pobrany losowy zasób z danego trybu. + - Zostanie pobrane dla wylosowanego zasobu zdjęcie. + - Losowane są 3 pozostałe nieprawidłowe odpowiedzi z zapytania do Pokemon API. +10. Po ukończeniu czasu wynik gracza zapisywany jest w rankingu dla danej przeglądarki (LocalStorage). + - Osobne podsumowania dla trybów. + - Pokazywany jest ranking 3 najlepszych wyników. +11. Brak opcji SETTINGS + +# **Pokemon! Gotta Catch ’Em All!** \ No newline at end of file diff --git a/index.html b/index.html index 53388f9..8c36805 100644 --- a/index.html +++ b/index.html @@ -1,21 +1,170 @@ + - CodersCamp2020 | Star Wars API QUIZ + CodersCamp2020 | Pokemon Quiz + - + + -
- Łukasz Dutka -
+
+ Łukasz Dutka + daria305 + memeraki / Gosia Dziewit + Aleksandra Cypko / AleksandraCyp + mariusz-sm / Mariusz Smarz + Agata Ludwiczyńska/ AgataLudwiczynska + arendarczyk / Kamil Arendarczyk +
+ + + + + + + + + + + + + + - + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ce7742f..89d8434 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5900,6 +5900,16 @@ "js-tokens": "^3.0.0 || ^4.0.0" } }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "optional": true, + "requires": { + "yallist": "^4.0.0" + } + }, "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", @@ -6302,9 +6312,9 @@ "dev": true }, "node-notifier": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.0.tgz", - "integrity": "sha512-46z7DUmcjoYdaWyXouuFNNfUo6eFa94t23c53c+lG/9Cvauk4a98rAUp9672X5dxGdQmLpPzTxzu8f/OeEPaFA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", + "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, "optional": true, "requires": { @@ -6317,11 +6327,14 @@ }, "dependencies": { "semver": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", - "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "dev": true, - "optional": true + "optional": true, + "requires": { + "lru-cache": "^6.0.0" + } }, "which": { "version": "2.0.2", @@ -10029,6 +10042,13 @@ "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", "dev": true }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "optional": true + }, "yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", diff --git a/src/api/pokemon.spec.js b/src/api/pokemon.spec.js new file mode 100644 index 0000000..cb278ac --- /dev/null +++ b/src/api/pokemon.spec.js @@ -0,0 +1,50 @@ +import { + getPokemonById, + getTypeById +} from "./pokemons.js"; + +describe('Test pokemon API to get pokemon', () => { + + it("Given pokemon id is 1 when asking for data, should get id, photoUrl, types, name of the pokemon", async () => { + //given + const pokemonId = 1; + + //when + const pokeData = await getPokemonById(pokemonId) + + //then + expect(pokeData).toEqual({ + id: 1, + name: "bulbasaur", + photoUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png", + types: [{ + id: 12, + type: "grass" + }, + { + id: 4, + type: "poison" + } + ] + }); + }) +}); + + +describe("Test pokemon API to get pokemon types", () => { + + it("Given the type id is 12 when asking for pokemon data, should return id and name of the type", async () => { + //given + const typeId = 12; + + //when + const typeData = await getTypeById(typeId) + + //then + expect(typeData).toEqual({ + id: 12, + name: "grass" + } + ); + }) +}) \ No newline at end of file diff --git a/src/api/pokemons.js b/src/api/pokemons.js new file mode 100644 index 0000000..834eabd --- /dev/null +++ b/src/api/pokemons.js @@ -0,0 +1,40 @@ +import fetch from "cross-fetch" + +const POKEMON_API_BASE_URL = process.env.POKEMON_API_BASE_URL || "https://pokeapi.co/api/v2"; + +export async function getPokemonById(id) { + const getTypeIdFromUrl = (url) => { + const regex = /\/type\/(\d+)\/$/; + return Number(regex.exec(url)[1]) + }; + + const parseType = (type) => { + return { + id: getTypeIdFromUrl(type.type.url), + type: type.type.name + } + }; + + const res = await fetch(`${POKEMON_API_BASE_URL}/pokemon/${id}`); + const jsonRes = await res.json(); + + return { + id: jsonRes.id, + name: jsonRes.name, + types: jsonRes.types.map(parseType), + photoUrl: jsonRes.sprites.other["official-artwork"].front_default + } +}; + +export async function getTypeById(id) { + const res = await fetch(`${POKEMON_API_BASE_URL}/type/${id}`); + const jsonRes = await res.json(); + const { + id: typeId, + name: typeName + } = jsonRes; + return { + id: typeId, + name: typeName + } +} \ No newline at end of file diff --git a/src/app/App.js b/src/app/App.js index d796f05..443b4db 100644 --- a/src/app/App.js +++ b/src/app/App.js @@ -1,4 +1,101 @@ -export const App = ({options}) => { -} +import { showStartingPage } from './showStartingPage.js'; +import { showAPopUpScreen } from './showAPopUpScreen'; +import { renderQuizPage } from './quizPage.js'; +import { WHO_IS_THAT_POKEMON, WHAT_DOES_THIS_POKEMON_LOOK_LIKE, WHO_IS_THAT_POKEMON_HARD_MODE } from "../service/modes.js"; + +export const App = ({options}) => { + + let SELECTED_MODE = WHO_IS_THAT_POKEMON; + + showStartingPage(); + + //add event listener to the help button + const leader = document.querySelector('#leaderboardScreen'); + const styleL = getComputedStyle(leader); + document.querySelector('#helpOption').addEventListener('click', () => { + if(styleL.display=='none') + showAPopUpScreen(document.querySelector('#helpScreen'), 'initial'); + }); + + //checked box colors + const checkedFont="#3762AC" + const bgBoxColor="#FFCB05" + + const changeColorOfClickedButton = (currQuerySelector) => { + const currSelected = document.querySelector(currQuerySelector) + const whoIsThatPokemonOption = document.querySelector('#whoIsThatPokemonOption') + const whatItLooksLikeOption = document.querySelector('#whatItLooksLikeOption') + const whoIsThatPokemonHardModeOption = document.querySelector('#whoIsThatPokemonHardModeOption') + + whoIsThatPokemonOption.style.backgroundColor=checkedFont + whoIsThatPokemonOption.style.color=bgBoxColor + whatItLooksLikeOption.style.backgroundColor=checkedFont + whatItLooksLikeOption.style.color=bgBoxColor + whoIsThatPokemonHardModeOption.style.backgroundColor=checkedFont + whoIsThatPokemonHardModeOption.style.color=bgBoxColor + + currSelected.style.backgroundColor=bgBoxColor + currSelected.style.color=checkedFont + } + + //add event listener to the select mode menu button + const help = document.querySelector('#helpScreen'); + const styleH = getComputedStyle(help); + document.querySelector('#whoIsThatPokemonOption').addEventListener('click',()=>{ + if(styleH.display=='none' && styleL.display=='none'){ + SELECTED_MODE = WHO_IS_THAT_POKEMON; + } + changeColorOfClickedButton('#whoIsThatPokemonOption') + }); + document.querySelector('#whatItLooksLikeOption').addEventListener('click',()=>{ + if(styleH.display=='none' && styleL.display=='none'){ + SELECTED_MODE = WHAT_DOES_THIS_POKEMON_LOOK_LIKE; + } + changeColorOfClickedButton('#whatItLooksLikeOption') + }); + document.querySelector('#whoIsThatPokemonHardModeOption').addEventListener('click',()=>{ + if(styleH.display=='none' && styleL.display=='none'){ + SELECTED_MODE = WHO_IS_THAT_POKEMON_HARD_MODE; + } + changeColorOfClickedButton('#whoIsThatPokemonHardModeOption') + }); + + //ad event listener to the leaderboard button + document.querySelector('#leaderboard').addEventListener('click', () => { + if(styleH.display=='none') { + showAPopUpScreen(document.querySelector('#leaderboardScreen'), 'initial') + } + }); + + // Change color of mode button + switch(SELECTED_MODE) { + case WHO_IS_THAT_POKEMON: + changeColorOfClickedButton('#whoIsThatPokemonOption'); + break; + case WHAT_DOES_THIS_POKEMON_LOOK_LIKE: + changeColorOfClickedButton('#whatItLooksLikeOption'); + break; + case WHO_IS_THAT_POKEMON_HARD_MODE: + changeColorOfClickedButton('#whoIsThatPokemonHardModeOption'); + break; + } + + + //input (disabled "play" button when input name is empty) + const inputName = document.querySelector('#enterYourNameInput'); + const playButton = document.querySelector("#startGameButton"); + + // start the game + playButton.addEventListener("click", () => { + if(inputName.value.length > 0) { + const userName = inputName.value; + renderQuizPage(SELECTED_MODE, userName, options.quizMaxTime) + } else { + // alert("Enter whatever name you want to play this game :)"); + inputName.placeholder = "*required (write your name here)"; + } + + }); +} diff --git a/src/app/appSettings.js b/src/app/appSettings.js new file mode 100644 index 0000000..75fa126 --- /dev/null +++ b/src/app/appSettings.js @@ -0,0 +1,17 @@ +// css styles dictionary changed during app runtime +export const START_PAGE_STYLES = { + startPageClass: "start-page" +} + +export const QUIZ_PAGE_STYLES = { + quizPageClass: "quiz-page", + quizAnswerTextClass: "answer-text", + quizAnswerImageClass: "answer-image", + quizQuestionTextClass: "question-text", + quizQuestionImageClass: "question-image", + wrongAnswerClass: "wrong-answer", + correctAnswerClass: "correct-answer", + uncheckedClass: "unchecked" +} + +export const TOTAL_NUM_OF_QUESTIONS = 30; diff --git a/src/app/fillLeaderboard.js b/src/app/fillLeaderboard.js new file mode 100644 index 0000000..33366ee --- /dev/null +++ b/src/app/fillLeaderboard.js @@ -0,0 +1,35 @@ +export const fillLeaderboard = (pokemonApiRanking) => { + const leaderboardTable = document.querySelector('#leaderboardResults'); + const modeSelect = document.querySelector('#chooseModeLeaderboard'); + + const addLeaderboardTableResults = (modeClass, modeNumber) => { + leaderboardTable.innerHTML += + `
  • ${pokemonApiRanking[modeNumber].scores[0] ? pokemonApiRanking[modeNumber].scores[0].name : "-"}${pokemonApiRanking[modeNumber].scores[0] ? pokemonApiRanking[modeNumber].scores[0].score : "-"}
  • +
  • ${pokemonApiRanking[modeNumber].scores[1] ? pokemonApiRanking[modeNumber].scores[1].name : "-"}${pokemonApiRanking[modeNumber].scores[1] ? pokemonApiRanking[modeNumber].scores[1].score : "-"}
  • +
  • ${pokemonApiRanking[modeNumber].scores[2] ? pokemonApiRanking[modeNumber].scores[2].name : "-"}${pokemonApiRanking[modeNumber].scores[2] ? pokemonApiRanking[modeNumber].scores[2].score : "-"}
  • ` + } + + addLeaderboardTableResults('whosThatPokemonLeaderboard', 'mode1'); + addLeaderboardTableResults('whatItLooksLikeLeaderboard', 'mode2'); + addLeaderboardTableResults('whosThatPokemonHardModeLeaderboard', 'mode3'); + const rankingItemsCollection = document.querySelectorAll('.leaderboardItem'); + + const changeLeaderboardView = () => { + if (modeSelect.value === 'whoIsThatPokemon') { + for (let rankingItem of rankingItemsCollection) { + rankingItem.style.display = rankingItem.classList.contains('whosThatPokemonLeaderboard') ? 'flex' : 'none'; + } + } else if (modeSelect.value === 'whatItLooksLike') { + for (let rankingItem of rankingItemsCollection) { + rankingItem.style.display = rankingItem.classList.contains('whatItLooksLikeLeaderboard') ? 'flex' : 'none'; + } + } else if (modeSelect.value === 'whoIsThatPokemonHardMode') { + for (let rankingItem of rankingItemsCollection) { + rankingItem.style.display = rankingItem.classList.contains('whosThatPokemonHardModeLeaderboard') ? 'flex' : 'none'; + } + } + } + + changeLeaderboardView(); + modeSelect.addEventListener('change', changeLeaderboardView); +} \ No newline at end of file diff --git a/src/app/fillResultsModal.js b/src/app/fillResultsModal.js new file mode 100644 index 0000000..8899131 --- /dev/null +++ b/src/app/fillResultsModal.js @@ -0,0 +1,40 @@ +import { WHAT_DOES_THIS_POKEMON_LOOK_LIKE, WHO_IS_THAT_POKEMON, WHO_IS_THAT_POKEMON_HARD_MODE } from "../service/modes"; + +export const fillResultsModal = (gameHandlerResults, mode) => { + + const resultsDescription = document.querySelector('#resultsDescription'); + resultsDescription.textContent = `Congratulations ${gameHandlerResults.name}! During ${gameHandlerResults.time} seconds you answered ${gameHandlerResults.answers.length} questions and scored ${gameHandlerResults.score} point(s)!` + + const resultsTable = document.querySelector('#tableWithResults table'); + + for (let questionItem of gameHandlerResults.answers) { + + const tableCell = document.createElement('tr'); + tableCell.className = 'tableWithResultsQA'; + + if (mode === WHAT_DOES_THIS_POKEMON_LOOK_LIKE) { + tableCell.innerHTML = + `${questionItem.question} + pokemon img + pokemon img` + + tableCell.querySelector('.tableWithResultsYourAnswer').style.border = questionItem.isCorrect === true ? '2px solid green' : '2px solid red'; + + } else if (mode === WHO_IS_THAT_POKEMON) { + tableCell.innerHTML = + `pokemon img + ${questionItem.correctAnswer} + ${questionItem.answer}` + + tableCell.querySelector('.tableWithResultsYourAnswer').style.color = questionItem.isCorrect === true ? 'green' : 'red'; + } else if (mode === WHO_IS_THAT_POKEMON_HARD_MODE) { + tableCell.innerHTML = + `pokemon img + ${questionItem.correctAnswer} + ${questionItem.answer}` + + tableCell.querySelector('.tableWithResultsYourAnswer').style.color = questionItem.isCorrect === true ? 'green' : 'red'; + } + resultsTable.appendChild(tableCell); + } +} \ No newline at end of file diff --git a/src/app/quizPage.js b/src/app/quizPage.js new file mode 100644 index 0000000..2360c1b --- /dev/null +++ b/src/app/quizPage.js @@ -0,0 +1,269 @@ +import { + QUIZ_PAGE_STYLES, + START_PAGE_STYLES, + TOTAL_NUM_OF_QUESTIONS, +} from "./appSettings.js" + +import { + GameHandler +} from "../service/GameHandler.js" + +import { + QuestionGenerator +} from "../service/QuestionGenerator.js" + +import { + showAPopUpScreen +} from './showAPopUpScreen' + +import { + fillResultsModal +} from './fillResultsModal' +import { WHO_IS_THAT_POKEMON_HARD_MODE } from "../service/modes.js" + +import { + rankingService, + checkLocalStorage +} from '../service/rankingService' + +let CURRENT_MODE = null; +let GENERATOR = null; +let GAME_HANDLER = null; + +// use to render the page for the first time, after the game start +export function renderQuizPage(mode, name, totalTime) { + CURRENT_MODE = mode; + GAME_HANDLER = new GameHandler(name, totalTime); + GENERATOR = new QuestionGenerator(CURRENT_MODE) + const appScreen = document.querySelector('#pokequiz-app'); + appScreen.classList.add(QUIZ_PAGE_STYLES.quizPageClass) + appScreen.classList.remove(START_PAGE_STYLES.startPageClass) + const quizTemplate = document.getElementById('quiz-template'); + appScreen.innerHTML = quizTemplate.innerHTML; + + setupPageTitle(); + const resultsTemplate = document.getElementById('results-modal-template'); + appScreen.innerHTML += resultsTemplate.innerHTML; + + setupPageTitle(CURRENT_MODE); + //GENERATOR = new QuestionService.Generator() + renderNextQuestion(GENERATOR); + + // add event listener to the results screen button + document.querySelector('#backToStartingPageButton').addEventListener('click', () => { + location.reload(); + }); + + setupTimer(totalTime); +} + + +// use to update quizPage and change only the question, new answers and question counter +// not changing the timer and bar +// next question is rendered if there is any question left to answer +// otherwise finishes the game and redirect user to the summary page +export async function renderNextQuestion(generator) { + const genQuestion = await generator.getNextQuestion(); + + if (genQuestion !== undefined) { + const quizBody = document.querySelector("#quiz-body"); + // Update question + const quizQuestionElem = quizBody.querySelector(".quiz-question"); + updateQuestion(quizQuestionElem, genQuestion); + // Update answers list + const quizUl = quizBody.querySelector(".quiz-answers-list"); + updateAnswersList(quizUl, genQuestion); + // Update question counter + const questionCounter = document.querySelector("#question-counter"); + updateQuestionCounter(questionCounter, generator.askedQuestionsCount); + // listen for an answer selection + const answersOptions = [...quizBody.querySelector(".quiz-answers-list").children] + for (let option of answersOptions) { + option.querySelector('div').addEventListener("mouseup", function selectEventFunc() { + selectAnswer(genQuestion, option.querySelector("div")); + }) + } + } else { // no more questions left + const gameResults = GAME_HANDLER.getResults(durationTime); + rankingService(CURRENT_MODE, gameResults); + fillResultsModal(gameResults, CURRENT_MODE); + showAPopUpScreen(document.getElementById('resultsScreen'), 'flex'); + endTimer(); + } +} + +// Changes the title corresponding to the chosen game mode +const setupPageTitle = () => { + const modeHeader = document.querySelector(".mode-title div") + modeHeader.innerText = CURRENT_MODE.title // Setup mode title +} + +// Updates the question div with styling and content depending on a type of a question +const updateQuestion = (questionElement, questionSet) => { + if (CURRENT_MODE.questionType === "image") { + questionElement.classList.add(QUIZ_PAGE_STYLES.quizQuestionImageClass); + const imgElem = createImgElement(questionSet.question); // add img from url + questionElement.appendChild(imgElem); + const answers = document.querySelector('ul.quiz-answers-list'); + answers.classList.add('answersTypeText') + + } else if (CURRENT_MODE.questionType === "text") { + questionElement.classList.add(QUIZ_PAGE_STYLES.quizQuestionTextClass); + questionElement.innerText = questionSet.question; // add question as an inner text + const answers = document.querySelector('ul.quiz-answers-list'); + answers.classList.add('answersTypeImage') + } +} + +// Creates img element with a selected image url +const createImgElement = (url) => { + const img = document.createElement("img"); + img.setAttribute("src", url); + if (CURRENT_MODE === WHO_IS_THAT_POKEMON_HARD_MODE) { + img.style.filter = "brightness(0%)"; + } + return img; +} + +// Updates styles for question list based on mode type +// creates question items sor provided question set +const updateAnswersList = (answersElement, questionSet) => { + for (let answer of questionSet.answers) { + const answerElement = createAnswerElement(answer); + answersElement.appendChild(answerElement); + } +} + +// returns children elements from the template +// for template.content replacement, which is not fully supported yet +const getTemplateContent = (template) => { + const dummyDiv = document.createElement("div"); // + dummyDiv.innerHTML = template.innerHTML; + return dummyDiv.children +} + +const createAnswerElement = (answer) => { + const liTemplate = document.querySelector("#quiz-li"); + const li = getTemplateContent(liTemplate)[0]; + const liFirstElem = li.children[0] + + if (CURRENT_MODE.answerType === "image") { + // first child of li receives an image + liFirstElem.classList.add(QUIZ_PAGE_STYLES.quizAnswerImageClass) + const imgElem = createImgElement(answer) // get img url + liFirstElem.appendChild(imgElem) + + } else if (CURRENT_MODE.answerType === "text") { + // first child of li receives text + liFirstElem.classList.add(QUIZ_PAGE_STYLES.quizAnswerTextClass) + liFirstElem.innerText = answer // add question as an inner text + } + return li +} + +// Updates the question number ona quiz page +const updateQuestionCounter = (counterElem, questionNum) => { + counterElem.innerText = String(questionNum).padStart(2, '0') + "/" + String(TOTAL_NUM_OF_QUESTIONS); +} + +// fires up on mouse up event on answers list li, additionally accepts questionSet +// check which answer was selected +// eventHandler is element to which eventListener was attached to +function selectAnswer(questionSet, eventHandler) { + const answer = getAnswerFromElement(eventHandler); + if (answer) { + questionSet.correctAnswer.value.toLowerCase() === answer.toLowerCase() ? correctAnswerSelected(eventHandler, answer, questionSet) : wrongAnswerSelected(eventHandler, answer, questionSet); + } else { + throw new Error('Answer was not found') + } +} + + +// Read selected answer value from clicked list item +const getAnswerFromElement = (target) => { + const targetClasses = [...target.classList]; + let answer; + if (targetClasses.includes(QUIZ_PAGE_STYLES.uncheckedClass)) { + if (targetClasses.includes(QUIZ_PAGE_STYLES.quizAnswerTextClass)) { + answer = target.innerText + } else if (targetClasses.includes(QUIZ_PAGE_STYLES.quizAnswerImageClass)) { + answer = target.children[0].getAttribute("src") + } + return answer + } +} + +const correctAnswerSelected = (selectedElem, answer, questionSet) => { + selectedElem.classList.remove(QUIZ_PAGE_STYLES.uncheckedClass) + selectedElem.classList.add(QUIZ_PAGE_STYLES.correctAnswerClass) + GAME_HANDLER.addAnswer(questionSet.correctAnswer.value, answer, true, questionSet.question); + resetQuizAfterQuestion(); + renderNextQuestion(GENERATOR); +} + +const wrongAnswerSelected = (selectedElem, answer, questionSet) => { + // add wrong-answer class and remove unchecked + selectedElem.classList.remove(QUIZ_PAGE_STYLES.uncheckedClass) + selectedElem.classList.add(QUIZ_PAGE_STYLES.wrongAnswerClass) + GAME_HANDLER.addAnswer(questionSet.correctAnswer.value, answer, false, questionSet.question); + resetQuizAfterQuestion(); + renderNextQuestion(GENERATOR); +} + +// removes question list items +const resetQuizAfterQuestion = () => { + const quizBody = document.getElementById('quiz-body'); + const quizTemplate = document.getElementById('quiz-template'); + quizBody.innerHTML = getTemplateContent(quizTemplate)[1].innerHTML // get the quiz body inner HTML from the template +} + +// Timer +var interval; +var timeOut; +var durationTime; + +const setupTimer = (timerDuration) => { + const barDiv = createTimer() + startTimer(barDiv, timerDuration); +} + +const createTimer = () => { + const timerBody = document.getElementById('timer'); + const bar = document.createElement("div"); + bar.setAttribute('id', 'bar') + timerBody.appendChild(bar); + + return bar +} + +const startTimer = (bar, timerDuration) => { + // durationTime time in seconds, can be changed freely 120 -> 120 seconds = 2 minutes + durationTime = timerDuration + printTime(durationTime); + bar.style.animation = "anim 1 linear forwards"; + bar.style.animationDuration = durationTime + "s"; + interval = setInterval(runningTime, 1000); + timeOut = setTimeout(function () { + clearInterval(interval); + bar.style.animationPlayState = "paused"; + rankingService(CURRENT_MODE, GAME_HANDLER.getResults(durationTime)); + fillResultsModal(GAME_HANDLER.getResults(durationTime), CURRENT_MODE) + showAPopUpScreen(document.getElementById('resultsScreen'), 'flex'); + }, (durationTime * 1000)); + + function runningTime() { + durationTime--; + printTime(durationTime); + }; + + function printTime(timeToPrint) { + document.getElementById("timerLabel").innerHTML = '' + timeToPrint + 's'; + }; +} + +function endTimer() { + const bar = document.getElementById('bar'); + clearTimeout(timeOut); + clearInterval(interval); + bar.style.animationPlayState = "paused"; +} \ No newline at end of file diff --git a/src/app/showAPopUpScreen.js b/src/app/showAPopUpScreen.js new file mode 100644 index 0000000..2f2df6d --- /dev/null +++ b/src/app/showAPopUpScreen.js @@ -0,0 +1,19 @@ +export const showAPopUpScreen = (screenToDisplay, startingPosition) => { + // display the help screen + screenToDisplay.style.display = startingPosition; + // add blur to the rest of the page + document.querySelectorAll('.disableWithPopUpScreen').forEach(e => e.style.filter = 'blur(4px)'); + // function which gets called when the exit button is clicked + if (screenToDisplay.querySelector('.exitPopUpScreen')) { + const exitPopUpFunction = () => { + //hide the screen + screenToDisplay.style.display = 'none'; + // remove the blur + document.querySelectorAll('.disableWithPopUpScreen').forEach(e => e.style.filter = 'blur(0)'); + // remove the event listener + screenToDisplay.querySelector('.exitPopUpScreen').removeEventListener('click', exitPopUpFunction); + }; + // add event listener to the exit button; + screenToDisplay.querySelector('.exitPopUpScreen').addEventListener('click', exitPopUpFunction); + } + }; \ No newline at end of file diff --git a/src/app/showStartingPage.js b/src/app/showStartingPage.js new file mode 100644 index 0000000..085ff7c --- /dev/null +++ b/src/app/showStartingPage.js @@ -0,0 +1,112 @@ +import { doc } from "prettier"; +import { START_PAGE_STYLES } from "./appSettings.js"; +import { fillLeaderboard } from './fillLeaderboard'; +import { checkLocalStorage } from '../service/rankingService' + +export const showStartingPage = () => { + const appScreen = document.querySelector('#pokequiz-app'); + appScreen.classList.add(START_PAGE_STYLES.startPageClass) + + // render starting page + const startingPageTemplate = document.querySelector('#starting-page-template'); + appScreen.innerHTML = startingPageTemplate.innerHTML; + + // add help modal + const helpScreenTemplate = document.querySelector('#help-modal-template'); + appScreen.innerHTML += helpScreenTemplate.innerHTML; + + // pikachuImage + const pikachuSvgImage = document.querySelector('.replaceMe'); + pikachuSvgImage.id = 'pikachuImg'; + pikachuSvgImage.innerHTML = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + + appScreen.innerHTML += pikachuSvgImage.innerHTML; + + // add leaderboard modal + const leaderboardTemplate = document.querySelector('#leaderboard-modal-template'); + appScreen.innerHTML += leaderboardTemplate.innerHTML; + fillLeaderboard(checkLocalStorage()); + +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index f7c8b94..d8cc37c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,7 @@ import 'regenerator-runtime/runtime' //async/await with Parcel import {App} from "./app/App"; -const ONE_SECOND_MILLIS = 1000; -const POKEMON_API_BASE_URL = process.env.POKEMON_API_BASE_URL || "https://swapi.dev/api"; -const QUIZ_MAX_TIME = process.env.QUIZ_MAX_TIME_SECONDS ? process.env.QUIZ_MAX_TIME_SECONDS * ONE_SECOND_MILLIS : 120 * ONE_SECOND_MILLIS; +const POKEMON_API_BASE_URL = process.env.POKEMON_API_BASE_URL || "https://pokeapi.co/api/v2"; +const QUIZ_MAX_TIME = process.env.QUIZ_MAX_TIME_SECONDS ? process.env.QUIZ_MAX_TIME_SECONDS : 120; window.onload = () => App({options: {pokemonApiBaseUrl: POKEMON_API_BASE_URL, quizMaxTime: QUIZ_MAX_TIME}}) diff --git a/src/service/GameHandler.js b/src/service/GameHandler.js new file mode 100644 index 0000000..587d1b2 --- /dev/null +++ b/src/service/GameHandler.js @@ -0,0 +1,36 @@ + +export class GameHandler { + constructor(name, totalTime) { + this.name = name; + this.totalTime = totalTime; //seconds + this.score = 0; + this.answers = []; + } + + addAnswer(correctAnswer, answer, isCorrect, question) { + this.answers.push({ + answer: answer, + correctAnswer: correctAnswer, + isCorrect: isCorrect, + question: question, + }) + if (isCorrect) { + this.score++; + } + } + + // Subtruct time left from total time + // returns time of the game + calculateTime(timeLeft) { + return this.totalTime - timeLeft + } + + getResults(timeLeft) { + return { + name: this.name, + time: this.calculateTime(timeLeft), + score: this.score, + answers: this.answers, + } + } +} \ No newline at end of file diff --git a/src/service/QuestionGenerator.js b/src/service/QuestionGenerator.js new file mode 100644 index 0000000..a826f75 --- /dev/null +++ b/src/service/QuestionGenerator.js @@ -0,0 +1,35 @@ +import { randomNumberInRange } from './randomNumberInRange'; +import {QuestionService} from './QuestionService'; +import { TOTAL_NUM_OF_QUESTIONS } from "../app/appSettings.js" + + +const questionService = new QuestionService() +export class QuestionGenerator { + + constructor(mode) { + this.mode = mode; + this.minPokeId = 1; + this.maxPokeId = 152; + this.askedQuestions = []; + this.askedQuestionsCount = 0; + }; + + async getNextQuestion() { + if (this.askedQuestionsCount === TOTAL_NUM_OF_QUESTIONS) { + return undefined; + }; + this.askedQuestionsCount++; + + const answerPokeId = randomNumberInRange(this.minPokeId, this.maxPokeId, this.askedQuestions); + this.askedQuestions.push(answerPokeId); + + const currentQuestionsArray = [answerPokeId]; + for (let i = 1; i <= 3; i++) { + currentQuestionsArray.push(randomNumberInRange(this.minPokeId, this.maxPokeId, currentQuestionsArray)); + }; + + let questionObj = await questionService.getQuestion(currentQuestionsArray, this.mode); + + return questionObj + }; +}; \ No newline at end of file diff --git a/src/service/QuestionService.js b/src/service/QuestionService.js new file mode 100644 index 0000000..9ead6c3 --- /dev/null +++ b/src/service/QuestionService.js @@ -0,0 +1,58 @@ +import { + getPokemonById +} from "../api/pokemons"; +import { WHAT_DOES_THIS_POKEMON_LOOK_LIKE, WHO_IS_THAT_POKEMON, WHO_IS_THAT_POKEMON_HARD_MODE } from "./modes"; +import { shuffleAnswers } from "./shuffleAnswers" + +export class QuestionService { + + constructor() { + this.correctAnswerIndex = 0; // range 0-3 + } + + async getQuestion(pokemonIds, mode) { + + if (!pokemonIds || pokemonIds.length != 4) { + throw new Error('pokemonIds is not an array of 4 elements') + } + + const pokePromises = pokemonIds.map( id => getPokemonById(id)) + const answersObj = await Promise.all(pokePromises); + + let question + let answers + let correctAnswer + + if (mode === WHO_IS_THAT_POKEMON || mode === WHO_IS_THAT_POKEMON_HARD_MODE) { + question = answersObj[this.correctAnswerIndex].photoUrl + answers = answersObj.map((answer) => this.getName(answer)) + correctAnswer = { value: this.getName(answersObj[this.correctAnswerIndex]), index: this.correctAnswerIndex} + + } else if (mode === WHAT_DOES_THIS_POKEMON_LOOK_LIKE ) { + question = this.getName(answersObj[this.correctAnswerIndex]) + answers = answersObj.map( (answer) => answer.photoUrl) + correctAnswer = { value: answersObj[this.correctAnswerIndex].photoUrl, index: this.correctAnswerIndex } + }; + return shuffleAnswers({ + question: question, + answers: answers, + correctAnswer: correctAnswer + }) + } + + checkAnswer(questionObj, userAnswer) { + let result = false; + if ( userAnswer == questionObj.correctAnswer.value ) { + result = true; + }; + return result; + } + + capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + getName(answersElem) { + return this.capitalize(answersElem.name) + } +} \ No newline at end of file diff --git a/src/service/modes.js b/src/service/modes.js new file mode 100644 index 0000000..528b681 --- /dev/null +++ b/src/service/modes.js @@ -0,0 +1,21 @@ +export const WHO_IS_THAT_POKEMON = { + name: 'WHO_IS_THAT_POKEMON', + title: "Who's that pokemon?", + questionType: "image", + answerType: "text", + answersNumber: 4 +}; +export const WHAT_DOES_THIS_POKEMON_LOOK_LIKE = { + name: 'WHAT_DOES_THIS_POKEMON_LOOK_LIKE', + title: "What does this pokemon look like?", + questionType: "text", + answerType: "image", + answersNumber: 4 +}; +export const WHO_IS_THAT_POKEMON_HARD_MODE = { + name: 'WHO_IS_THAT_POKEMON_HARD_MODE', + title: "Who's that pokemon? (hard mode)", + questionType: "image", + answerType: "text", + answersNumber: 4 +}; \ No newline at end of file diff --git a/src/service/randomNumberInRange.js b/src/service/randomNumberInRange.js new file mode 100644 index 0000000..9bc0ea1 --- /dev/null +++ b/src/service/randomNumberInRange.js @@ -0,0 +1,41 @@ +// min should be a minimal number, max should be a maximal number, forbiddenNumbersArray should be an array containing all the numbers that should not be generated + +export const randomNumberInRange = (min, max, forbiddenNumbersArray = []) => { + + if (typeof min !== 'number' || typeof max !== 'number' || !Array.isArray(forbiddenNumbersArray) || !forbiddenNumbersArray.every(number => typeof number === 'number')) { + + throw Error(`Invalid argument type! The first and the second argument should be numbers, the third should be an array of only numbers. The arguments passed: min - ${min}, max - ${max}, forbiddenNumbersArray - ${forbiddenNumbersArray}`); + + } + + if (min > max) { + + throw Error(`Invalid arguments. Min is greated than max. Min - ${min}, max - ${max}`); + + } + + const allPossibleOutcomes = []; + + for (let i = min; i <= max; i++) { + allPossibleOutcomes.push(i); + } + + const allPossibleOutcomesNoProhibited = allPossibleOutcomes.filter(number => !(forbiddenNumbersArray.includes(number))); + + if (allPossibleOutcomesNoProhibited.length === 0) { + + throw Error(`Invalid array of forbidden numbers! All the possible outcomes are forbidden. The arguments passed: min - ${min}, max - ${max}, forbiddenNumbersArray - ${forbiddenNumbersArray}.`); + + } + + const generateIndex = () => { + + return Math.floor(Math.random() * allPossibleOutcomesNoProhibited.length); + } + + const numberIndex = generateIndex(); + + return allPossibleOutcomesNoProhibited[numberIndex]; + +} + \ No newline at end of file diff --git a/src/service/rankingService.js b/src/service/rankingService.js new file mode 100644 index 0000000..a2c10b6 --- /dev/null +++ b/src/service/rankingService.js @@ -0,0 +1,51 @@ +import { WHAT_DOES_THIS_POKEMON_LOOK_LIKE, WHO_IS_THAT_POKEMON, WHO_IS_THAT_POKEMON_HARD_MODE } from "../service/modes"; + +export const rankingService = (mode, user) => { + const PokemonApiRanking = checkLocalStorage(); //update from localStorage + let currentMode; //path to scores array for current mode + switch(mode) { + case WHO_IS_THAT_POKEMON: + currentMode = PokemonApiRanking.mode1.scores; + break; + case WHAT_DOES_THIS_POKEMON_LOOK_LIKE: + currentMode = PokemonApiRanking.mode2.scores; + break; + case WHO_IS_THAT_POKEMON_HARD_MODE: + currentMode = PokemonApiRanking.mode3.scores; + break; + default: + throw new Error('Wrong game mode!'); + } + currentMode.push(user); + currentMode.sort( (a,b) => { + const scoreCompare = b.score - a.score; + if(scoreCompare != 0) { // compare scores + return scoreCompare; + } else { // scores are the same => compare times + return a.time - b.time; + } + } ); + if(currentMode.length > 3) { + currentMode.pop(); + } + + localStorage.setItem('PokemonApiRanking', JSON.stringify(PokemonApiRanking)); //save in localStorage +} + +export const checkLocalStorage = () => { + const PokemonApiRanking = JSON.parse(localStorage.getItem('PokemonApiRanking')) || { + mode1: { + name : "Whos that pokemon?", + scores : [] + }, + mode2: { + name : "What it looks like?", + scores : [] + }, + mode3: { + name : "Whos that pokemon? (hard mode)", + scores : [] + } + } + return PokemonApiRanking; +} \ No newline at end of file diff --git a/src/service/shuffleAnswers.js b/src/service/shuffleAnswers.js new file mode 100644 index 0000000..7276afc --- /dev/null +++ b/src/service/shuffleAnswers.js @@ -0,0 +1,30 @@ +import { randomNumberInRange } from './randomNumberInRange' + +export const shuffleAnswers = (questionObj) => { + + if (!questionObj.hasOwnProperty('question') || !questionObj.hasOwnProperty('answers') || !questionObj.hasOwnProperty('correctAnswer')) { + + throw new Error (`Incorrect argument! The argument has to be a question object with properties question, anwers, correctAnswers.Argument received: ${questionObj}`); + + } + + // assign new indices, making sure every index is different + const correctAnswerNewIndex = randomNumberInRange(0, 3); + const secondAnswerNewIndex = randomNumberInRange(0, 3, [correctAnswerNewIndex]); + const thirdAnswerNewIndex = randomNumberInRange(0, 3, [correctAnswerNewIndex, secondAnswerNewIndex]); + const forthAnswerNewIndex = randomNumberInRange(0, 3, [correctAnswerNewIndex, secondAnswerNewIndex, thirdAnswerNewIndex]); + + // create an array of arrays: each array contains the answer at [0] and the new index at [1]; + const allQuestionsWithNewIndices = [[questionObj.answers[0], correctAnswerNewIndex], [questionObj.answers[1], secondAnswerNewIndex], [questionObj.answers[2], + thirdAnswerNewIndex], [questionObj.answers[3], forthAnswerNewIndex]]; + + //sort according to new indices + const allQuestionsSortedInNewOrder = allQuestionsWithNewIndices.sort((a, b) => a[1] - b[1]); + + return { + question: questionObj.question, + // add only first elements of allQuestionsSortedInNewOrder array - answers + answers: [ allQuestionsSortedInNewOrder[0][0], allQuestionsSortedInNewOrder[1][0], allQuestionsSortedInNewOrder[2][0], allQuestionsSortedInNewOrder[3][0]], + correctAnswer: { value: allQuestionsSortedInNewOrder[correctAnswerNewIndex][0], index: correctAnswerNewIndex} + } +} \ No newline at end of file diff --git a/static/assets/readme-images/gamescreen.png b/static/assets/readme-images/gamescreen.png new file mode 100644 index 0000000..2198465 Binary files /dev/null and b/static/assets/readme-images/gamescreen.png differ diff --git a/static/assets/readme-images/modalsscreen.png b/static/assets/readme-images/modalsscreen.png new file mode 100644 index 0000000..15ef367 Binary files /dev/null and b/static/assets/readme-images/modalsscreen.png differ diff --git a/static/assets/readme-images/startscreen.png b/static/assets/readme-images/startscreen.png new file mode 100644 index 0000000..38b3cbf Binary files /dev/null and b/static/assets/readme-images/startscreen.png differ diff --git a/static/assets/ui/LightsaberHandle.png b/static/assets/ui/LightsaberHandle.png deleted file mode 100644 index 3a78c7e..0000000 Binary files a/static/assets/ui/LightsaberHandle.png and /dev/null differ diff --git a/static/assets/ui/MasterYodaLeft.png b/static/assets/ui/MasterYodaLeft.png deleted file mode 100644 index 75b035e..0000000 Binary files a/static/assets/ui/MasterYodaLeft.png and /dev/null differ diff --git a/static/assets/ui/MasterYodaRight.png b/static/assets/ui/MasterYodaRight.png deleted file mode 100644 index 93301f0..0000000 Binary files a/static/assets/ui/MasterYodaRight.png and /dev/null differ diff --git a/static/assets/ui/QuizBackground.png b/static/assets/ui/QuizBackground.png deleted file mode 100644 index 5df5c5c..0000000 Binary files a/static/assets/ui/QuizBackground.png and /dev/null differ diff --git a/static/assets/ui/StarWarsLogo.png b/static/assets/ui/StarWarsLogo.png deleted file mode 100644 index 372b45a..0000000 Binary files a/static/assets/ui/StarWarsLogo.png and /dev/null differ diff --git a/static/assets/ui/bg1.svg b/static/assets/ui/bg1.svg new file mode 100644 index 0000000..edf0124 --- /dev/null +++ b/static/assets/ui/bg1.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/static/assets/ui/bg2.svg b/static/assets/ui/bg2.svg new file mode 100644 index 0000000..5d85933 --- /dev/null +++ b/static/assets/ui/bg2.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/static/assets/ui/crown.svg b/static/assets/ui/crown.svg new file mode 100644 index 0000000..9d4f6d3 --- /dev/null +++ b/static/assets/ui/crown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/assets/ui/forest.jpg b/static/assets/ui/forest.jpg new file mode 100644 index 0000000..1be58d0 Binary files /dev/null and b/static/assets/ui/forest.jpg differ diff --git a/static/assets/ui/hall-of-fame/background.png b/static/assets/ui/hall-of-fame/background.png new file mode 100644 index 0000000..c6f4515 Binary files /dev/null and b/static/assets/ui/hall-of-fame/background.png differ diff --git a/static/assets/ui/hall-of-fame/bronze.png b/static/assets/ui/hall-of-fame/bronze.png new file mode 100644 index 0000000..8b3fe63 Binary files /dev/null and b/static/assets/ui/hall-of-fame/bronze.png differ diff --git a/static/assets/ui/hall-of-fame/gold.png b/static/assets/ui/hall-of-fame/gold.png new file mode 100644 index 0000000..eb04788 Binary files /dev/null and b/static/assets/ui/hall-of-fame/gold.png differ diff --git a/static/assets/ui/hall-of-fame/silver.png b/static/assets/ui/hall-of-fame/silver.png new file mode 100644 index 0000000..4c0aa75 Binary files /dev/null and b/static/assets/ui/hall-of-fame/silver.png differ diff --git a/static/assets/ui/lightning.svg b/static/assets/ui/lightning.svg new file mode 100644 index 0000000..145391a --- /dev/null +++ b/static/assets/ui/lightning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/assets/ui/logo.png b/static/assets/ui/logo.png new file mode 100644 index 0000000..2e2c54e Binary files /dev/null and b/static/assets/ui/logo.png differ diff --git a/static/assets/ui/pikach1.png b/static/assets/ui/pikach1.png new file mode 100644 index 0000000..c3d9af9 Binary files /dev/null and b/static/assets/ui/pikach1.png differ diff --git a/static/assets/ui/pikachu2.png b/static/assets/ui/pikachu2.png new file mode 100644 index 0000000..76cc9d3 Binary files /dev/null and b/static/assets/ui/pikachu2.png differ diff --git a/static/assets/ui/pokelogo.svg b/static/assets/ui/pokelogo.svg new file mode 100644 index 0000000..cbc4a29 --- /dev/null +++ b/static/assets/ui/pokelogo.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/assets/ui/user.svg b/static/assets/ui/user.svg new file mode 100644 index 0000000..23737bf --- /dev/null +++ b/static/assets/ui/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/styles/App.css b/styles/App.css index 76c80a3..fb7e1d3 100644 --- a/styles/App.css +++ b/styles/App.css @@ -1,3 +1,876 @@ +@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@600;900&family=Ranchers&family=Secular+One&display=swap'); + +/* text on the web (font-weight: 600) */ +/* font-family: 'Raleway', sans-serif; +/* "enter name" */ +/* font-family: 'Ranchers', cursive; */ +/* header Quiz */ +/* font-family: 'Secular One', sans-serif; */ +:root { + --optionBlue: #3762AC; + --yellow: #FFCB05; + --lightBlue: #2C7EFA; +} + + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Raleway', sans-serif; + font-weight: 600; + color: #FFCB05; +} + body { + max-height: 100vh; font-family: 'Montserrat', sans-serif; + z-index: -1; +} + +.buttonWithText { + border-radius: 7vh; + background-color: var(--optionBlue); + font-size: 3.5vh; + cursor: pointer; +} + +.buttonWithText:hover { + background-color: var(--yellow) !important; + color: var(--optionBlue) !important; +} + +.start-page { + display: grid; + padding: 1.5vh 5vw; + grid-template-columns: 50% 50%; + grid-template-rows: 1fr 25vh 10vh 28vh; + height: 100vh; + background: linear-gradient(118.86deg, #2C7EFA 14.52%, rgba(123, 192, 255, 0.71) 116.61%); + background: url(../static/assets/ui/bg1.svg) no-repeat center; + background-size: cover; +} + +.firstColumn { + grid-column: 1; +} + +.secondColumn { + grid-column: 2; +} + +/* div headerWithLogo */ + +#pokemonLogo { + height: 18vh; +} + +#quizLogo { + padding-left: 15vh; + font-family: 'Secular One', sans-serif; + font-size: 8.5vh +} + +/* ul selectModeMenu */ + +#selectModeMenu { + grid-row: span 2/4; + display: flex; + flex-direction: column; + align-items: flex-start; + padding-top: 5%; + padding-left: 6%; + list-style: none; +} + +#selectModeMenu li { + margin-bottom: 4px; + padding: 1vh 2.5vw; + width: 60%; + text-align: center; +} + +/* div EnterName */ + +#enterNameAndPlayMenu { + grid-row: 2; + justify-self: center; +} + +#enterYourName { + padding-bottom: 5%; + font-family: 'Ranchers', Arial, sans-serif; + font-size: 5.5vh; +} + +#enterYourNameArea { + display: flex; + justify-content: center; +} + +#playerIcon { + height: 4vh; +} + +#enterYourNameInput { + border: none; + border-bottom: black solid 1px; + padding: 2% 5%; + background-color: transparent; + font-size: 3vh; + color: black; +} + +/* div buttonPlay */ + +#startGame { + grid-row-start: 3; +} + +#startGameButton { + margin-left: 30%; + padding: 0.5% 2%; + width: 40%; + border-color: transparent; + transition: 0.1s; +} + +#startGameButton:hover { + background-color: #6da2ff; +} + +/* ul bottomOfThePageOptions */ + +ul.bottomOfThePageOptions { + grid-row-end: -1; + display: flex; + align-items: flex-end; + list-style: none; +} + +ul.bottomOfThePageOptions li { + width: 8.5vh; + height: 8.5vh; + border-radius: 50%; + margin: 10px; + font-size: 5vh; + background-color: var(--optionBlue); + text-align: center; + line-height: 8.5vh; + font-weight: 900; + cursor: pointer; + transition: 0.1s; +} + +ul.bottomOfThePageOptions li:hover { + background-color: #6da2ff; +} + +ul.bottomOfThePageOptions li img { + width: 5vh; + filter: invert(82%) sepia(59%) saturate(2238%) hue-rotate(353deg) brightness(103%) contrast(103%); +} + +/* pikachu */ + +#pikachuImg { + grid-row-end: -1; + justify-self: center; + height: 100%; +} + +/* heart animation */ + +#pikachuImg #heart1 { + display: inline; + visibility: hidden; + animation: animation 3s infinite; + } + +#pikachuImg #heart2 { + display: inline; + visibility: hidden; + animation: animation 3s infinite; + animation-delay: .2s; + } + +#pikachuImg #heart3 { + display: inline; + visibility: hidden; + animation: hearts 3s infinite; + animation-delay: .4s; + } + +#pikachuImg #heart4 { + visibility: hidden; + animation: heartsBig 3s infinite; + animation-delay: .6s; + } + + + @keyframes hearts { + 0% { + visibility: visible; + } + 6.67% { + visibility: hidden; + } + } + + @keyframes heartsBig { + 0% { + visibility: visible; + } + 75% { + visibility: hidden; + } + } + +/* hidden screens */ + +.popUpScreen { + display: none; + width: 50vw; + height: 80vh; + position: absolute; + left: 50%; + bottom: 50%; + margin-left: -25vw; + margin-bottom: -40vh; + justify-items: center; + border-radius: 5%; + z-index: 2; +} + +.popUpTitleArea { + width: 100%; +} + +.popUpTitle { + text-align: center; +} + +.exitPopUpScreen { + position: absolute; + top: 0.5vh; + right: 2vw; + font-size: 5vh; + font-weight: lighter; + cursor: pointer; +} + +.mainHelpTextSection>* { + color: black; + font-weight: normal; + padding: 1vh 0vw; +} + +.popUpImgArea { + height: 15%; + width: 100%; + display: flex; + justify-content: center; +} + +.popUpImg { + padding-top: 1vh; + max-height: 100%; + min-height: 100%; +} + + +/* help modal */ + +#helpScreen { + background-color: lightgray; +} + +#exitPopUpScreenHelp { + color: red; +} + +#popUpTitleHelp { + background-color: #FFCB05; + color: black; + border: 2px solid black; + width: 50%; + font-size: 8vh; + margin: auto auto 2% auto; + height: 13%; +} + +.helpTitle { + color: black; + font-weight: 700; +} + +.mainHelpTextSection { + background-color: white; + color: black; + font-size: 3vh; + height: 70%; + width: 85%; + margin: auto; + overflow-y: scroll; + padding: 2vh 2vw; + list-style: url(../static/assets/ui/lightning.svg); + list-style-position: inside; +} + +.mainHelpTextSection::-webkit-scrollbar { + width: 2vh; +} + +.mainHelpTextSection::-webkit-scrollbar-track { + box-shadow: inset 0 0 5px grey; + border-radius: 10px; +} + +.mainHelpTextSection::-webkit-scrollbar-thumb { + background: var(--yellow); + border-radius: 10px; +} + + +/* leaderboard modal */ + +#leaderboardScreen { + background-image: url(../static/assets/ui/hall-of-fame/background.png); + background-position: center; + background-repeat: no-repeat; + background-size: cover; +} + +#popUpTitleAreaLeaderBoard { + height: 18%; + display: grid; + align-items: end; + justify-items: center; +} + +#popUpTitleLeaderboard { + color: white; + width: 90%; + font-size: 7.2vh; +} + +#exitPopUpScreenLeaderboard { + color: white; +} + +#chooseModeLeaderboardArea { + height: 7%; + display: grid; + grid-template-columns: auto auto; + align-items: center; +} + +#chooseModeLeaderboardLabel { + color: white; + justify-self: end; + padding: 0vh 2vh; + font-size: 2.8vh; +} + +#chooseModeLeaderboard { + justify-self: start; + padding: 0vh 2vh; + color: rgb(121, 121, 241); + background: none; + border: none; + font-size: 2.8vh; + cursor: pointer; + max-width: 100%; + text-align-last: center; +} + +#chooseModeLeaderboard option { + color: rgb(121, 121, 241); + font-size: 2.8vh; + background-color: black; + cursor: pointer; + max-width: 100%; + text-align-last: center; +} + +#leaderboardResults { + height: 75%; + text-align: center; + list-style-type: none; + display: grid; + justify-items: center; + align-items: center; + align-content: center; +} + +.leaderboardItem { + background-color: rgb(68, 66, 66); + opacity: 0.8; + height: 8vh; + width: 55%; + display: flex; + align-items: center; + margin: 3.5vh; +} + +.leaderboardItem span { + flex: 1 1 100px; + color: white; + font-size: 3vh; +} + +.leaderboardItem img { + height: 80%; + flex: 0 0 auto; +} + +.leaderFirstPlace { + width: 70%; +} + +/* quiz page */ +div.quiz-page { + padding-top: 2vh; + display: grid; + grid-template-rows: 10vh 80vh; + grid-gap: 5vh; + height: 100vh; + background: linear-gradient(118.86deg, #2C7EFA 14.52%, rgba(123, 192, 255, 0.71) 116.61%); + background: url(../static/assets/ui/bg2.svg) no-repeat center; + background-size: cover; +} + +.quiz-top-bar { + position: relative; + justify-self: center; + display: flex; + justify-content: space-around; + align-items: center; + width: 50vw; + height: fit-content; + font-size: 3vh; + text-shadow: -2px 0 #46444479, 0 2px #46444479, 2px 0 #46444479, 0 -2px #46444479; + border: 0px solid transparent; + border-radius: 7vh; + margin-left: 5vh; + margin-right: 5vh; + box-shadow: 2px 2px 10px black; + background-color: var(--lightBlue); +} + + +div.mode-title { + height: 100%; + padding: 0.7vh; + border-left: 2px solid #46444479; + border-right: 2px solid #46444479; + z-index: 1; + flex-grow: 1; + text-align: center; + display: flex; + justify-content: center; + align-items: center; +} + + +#quiz-body { + display: flex; + flex-direction: column; + padding-top: 5vh; +} + +.quiz-question { + color: #303030; + font-size: 3em; + font-weight: bold; + text-align: center; +} + +.question-image img { + height: 90%; + margin: 0 auto; + display: block; +} + +.question-text { + display: block; + width: 40%; + margin: 0 auto; + margin-bottom: 8vh; + border-radius: 7vh; + font-family: 'Secular One', sans-serif; + font-size: 6.5vh; + background-color: var(--optionBlue); + color: var(--yellow); + padding: 1vh 0vh; +} + +.quiz-answers-list { + display: grid; + margin: 0 20vw; + grid-template-columns: 50% 50%; + grid-template-rows: 1fr 1fr; + grid-row-gap: 2vh; + list-style: none; +} + +.answer-image { + display: flex; + justify-content: center; + border-radius: 4vh; + box-shadow: 2px 2px 10px black; + background-image: url("../static/assets/ui/forest.jpg"); + background-position: center; + background-size: cover; + width: 70%; + margin: 0 auto; +} + + +.answer-text { + text-align: center; + font-size: 3.5vh; + color: var(--yellow); + background-color: var(--optionBlue); + padding: 1vh 0; + width: 80%; + margin: 0 auto; + box-shadow: 2px 2px 5px black; + border-radius: 7vh; + transition: filter 0.1s ease-in-out; +} + +.quiz-answer div:hover { + filter: brightness(1.2); +} + +.quiz-answer { + width: 100%; + margin: 0 auto; +} + +.quiz-answer div { + cursor: pointer; +} + +.quiz-answer img { + max-height: 25vh; +} + +.question-image { + background-image: url("../static/assets/ui/forest.jpg"); + width: 50vh; + height: 50vh; + box-shadow: 2px 2px 5px black; + margin: 0 auto 10vh; + border-radius: 4vh; + display: flex; + align-items: center; +} + +/* quiz page - timer */ + +#timer { + position: absolute; + width: 100%; + height: 100%; + z-index: 0; +} + +#bar { + position: absolute; + height: 100%; + width: 100%; + background-color: var(--optionBlue); + z-index: 0; + border-radius: 7vh; +} + +#timerLabel { + text-align: center; +} + +#timerLabel, +#question-counter { + z-index: 1; + padding: 2vh; + width: 12vh; +} + +@keyframes anim { + 0% { + background-color: var(--optionBlue); + width: 100%; + } + + 50% { + background-color:var(--optionBlue); + width: 50%; + } + + 75% { + transition: background-color 3s ease; + background-color: rgb(83, 33, 139); + width: 25%; + } + + 80% { + top: 0px; + height: 100%; + } + + 100% { + background-color: red; + width: 0%; + height: 5%; + top:50%; + } +} + +/* resultsModal */ + +#resultsScreen { + background-color: white; + border: 5px solid var(--optionBlue); + display: none; + flex-direction: column; + align-content: center; + align-items: space-around; + justify-content: space-around; + font-size: 2.6vh; +} + +#popUpTitleResultsArea { + flex: 1 1 15%; +} + +#popUpTitleResults { + color: black; + font-size: 6vh; + font-weight: 700; + padding: 3vh; +} + +#resultsDescriptionArea { + flex: 1 1 8%; +} + +#resultsDescription { + text-align: center; + color: black; + font-size: 3vh; + padding: 0vh 1vh 3vh 1vh; +} + +#tableWithResultsArea { + display: grid; + grid-template-columns: auto 3fr; + flex: 1 1 47%; + height: 47%; +} + +#nextToTableImg { + width: 30vh; + align-self: center; +} + +#tableWithResults { + overflow-y: scroll; + border-bottom: 2px solid darkgray; + margin: 1.5vh; +} + +#tableWithResults::-webkit-scrollbar { + width: 2vh; + } + +#tableWithResults::-webkit-scrollbar-track { + box-shadow: inset 0 0 5px grey; + border-radius: 10px; + } + +#tableWithResults::-webkit-scrollbar-thumb { + background: var(--optionBlue); + border-radius: 10px; + } + +#tableWithResults table { + width: 100%; +} + +#tableWithResults th { + border-bottom: 2px solid darkgray; + color: black; + position: sticky; + top: 0; + background-color: white; +} + +.tableWithResultsQA { + text-align: center; +} + +.tableWithResultsQA td { + height: 9vh; + color: black; +} + +.tableWithResultsQA img { + height: 90%; +} + +.tableWithResultsQA:first-child { + height: 90%; + text-align: left; +} + +#backToStartingPageButtonArea { + display: flex; + align-items: center; + justify-content: center; + flex: 1 1 15%; +} + +#backToStartingPageButton { + flex: 0 1; + font-size: 3.5vh; + padding: 1.2vh 3vh 1.2vh 3vh; + background-color:var(--optionBlue); + border-radius: 2vh; + max-width: 100%; + transition: 0.1s; + cursor: pointer +} + +#backToStartingPageButton:hover { + background-color: #6da2ff; +} + +::placeholder { + color: #D61E40; + opacity: 1; + font-size: .7em; + } + +@media(max-width:850px) { + .popUpScreen { + width: 80vw; + margin-left: -40vw; + } + + #popUpTitleLeaderboard { + font-size: 5.5vh; + } + + #chooseModeLeaderboardArea { + grid-template-columns: 1fr; + } + + #chooseModeLeaderboardLabel { + justify-self: center; + } + + #chooseModeLeaderboard { + justify-self: center; + } + + #tableWithResultsArea { + grid-template-columns: 1fr; + } + + #nextToTableImg { + display: none; + } + + #backToStartingPageButton { + padding: 1.2vh; + } + + #quiz-body { + padding: 0px; + height: 100%; + } + + .question-image { + margin: 0 auto 5vh; + } + + .question-image img { + width: 60%; + height: auto; + } + + .answersTypeText { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr 1fr 1fr; + width: 95%; + margin: 0 auto; + } + + .answersTypeImage { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto; + width: 100%; + margin: 0; + padding-left: 3.5vh; + flex: 1 1 auto; + align-items: center; + align-content: space-around; + row-gap: 0 + } + + .answersTypeText.quiz-answer { + width: 95%; + margin: 0; + } + + .answer-image { + width: 90%; + margin: 0; + } + + .quiz-top-bar { + width: 90%; + } + + .question-text { + width: 90%; + margin-bottom: 0; + margin: 0 auto ; + } + } + +@media(orientation:portrait) { + .start-page { + grid-template-rows: 1fr 17vh 27vh 5vh 16vh; + } + + .spanInPortrait { + grid-column: span 2/-1; + } + + #enterNameAndPlayMenu { + padding-top: 4%; + } + + #selectModeMenu { + grid-row: 3; + padding-left: 0; + } + + #selectModeMenu li { + margin-left: 10%; + width: 80%; + text-align: center; + } + + #startGame { + grid-row: 4; + margin: 1vh; + } + + #pikachuImg { + justify-self: end; + } +} \ No newline at end of file diff --git a/test/GameHandler.spec.js b/test/GameHandler.spec.js new file mode 100644 index 0000000..abb807e --- /dev/null +++ b/test/GameHandler.spec.js @@ -0,0 +1,26 @@ +import{GameHandler} from "../src/service/GameHandler" + +describe('Store user choosen and statistic during game', () => { + it('Should update game statistic', () => { + //given + const gameHandler = new GameHandler("Ala", 120); + const question = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png"; + const correctAnswer = "pikachu"; + const answer = "charmander"; + const isCorrect = false; + //when + gameHandler.addAnswer(correctAnswer, answer, isCorrect, question) + //then + expect(gameHandler.getResults(15)).toEqual({ + name: "Ala", + time: 105, + score: 0, + answers: [{ + correctAnswer:"pikachu", + answer: "charmander", + isCorrect: false, + question: question + }] + }) + }) +}) diff --git a/test/QuestionGenerator.spec.js b/test/QuestionGenerator.spec.js new file mode 100644 index 0000000..971de60 --- /dev/null +++ b/test/QuestionGenerator.spec.js @@ -0,0 +1,34 @@ +import { QuestionGenerator } from '../src/service/QuestionGenerator'; +import { WHO_IS_THAT_POKEMON } from "../src/service/modes"; + +const generatedQuestion = new QuestionGenerator(WHO_IS_THAT_POKEMON); + +describe('Test QuestionGenerator class', () => { + + it('Should return undefined if number of asked questions is bigger than 30', () => { + + //given + const numberOfAskedQuestions = 31; + + //when + generatedQuestion.askedQuestionsCount = numberOfAskedQuestions + + //then + expect(generatedQuestion.getNextQuestion()).toBeUndefined; + + }); +}); + +describe('Test askedQuestionsCount property', () => { + + it('Should increment counter', async () => { + //given + const startingCount = generatedQuestion.askedQuestionsCount; + + //when + await generatedQuestion.getNextQuestion(); + + //then + expect(generatedQuestion.askedQuestionsCount).toEqual(startingCount+1); + }); +}); \ No newline at end of file diff --git a/test/QuestionService.spec.js b/test/QuestionService.spec.js new file mode 100644 index 0000000..121ac0c --- /dev/null +++ b/test/QuestionService.spec.js @@ -0,0 +1,65 @@ +import { WHO_IS_THAT_POKEMON } from "../src/service/modes"; +import { + QuestionService +} from "../src/service/QuestionService.js"; + +const quizQuestion = new QuestionService(); + +describe('Test getQuestion method', () => { + + it("Should return question object - example output for mode 1: question: 'bulbasaur' , answers: [ 'answer1', 'answer2', 'answer3', 'answer4' ], correctAnswer: { name: 'bulbasaur', index: 1 } ", async () => { + // Given + const pokemonIds = [1, 2, 3, 4]; + + // When + const question = await quizQuestion.getQuestion(pokemonIds, WHO_IS_THAT_POKEMON); + + // Then + expect(question).toHaveProperty('question'); + expect(question.question).toEqual('https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png'); + + expect(question).toHaveProperty('answers'); + expect(question.answers).toHaveLength(4); + expect(question.answers).toEqual(expect.arrayContaining(['Bulbasaur', 'Ivysaur', 'Venusaur', 'Charmander'])); //In any order + + expect(question).toHaveProperty('correctAnswer'); + expect(question.correctAnswer).toHaveProperty('value'); + expect(question.correctAnswer.value).toEqual('Bulbasaur'); + expect(question.correctAnswer).toHaveProperty('index'); + + expect(question.answers[question.correctAnswer.index]).toEqual(question.correctAnswer.value); + }) +}); + + +describe('Test checkAnswer method', () => { + + it("Should return result true for user answer -> 'Bulbasaur' ", async () => { + // Given + const pokemonIds = [1, 2, 3, 4]; + const userAnswer = 'Bulbasaur'; + + // When + const question = await quizQuestion.getQuestion(pokemonIds, WHO_IS_THAT_POKEMON); + + // Then + expect(quizQuestion.checkAnswer(question, userAnswer)).toBeTruthy(); + + }) +}); + +describe('Test checkAnswer method', () => { + + it("Should return result false for user answer -> 'Venusaur' ", async () => { + // Given + const pokemonIds = [1, 2, 3, 4]; + const userAnswer = 'Venusaur'; + + // When + const question = await quizQuestion.getQuestion(pokemonIds, WHO_IS_THAT_POKEMON); + + // Then + expect(quizQuestion.checkAnswer(question, userAnswer)).toBeFalsy(); + + }) +}); \ No newline at end of file diff --git a/test/randomNumberInRange.test.js b/test/randomNumberInRange.test.js new file mode 100644 index 0000000..c6d7847 --- /dev/null +++ b/test/randomNumberInRange.test.js @@ -0,0 +1,147 @@ +import { randomNumberInRange } from '../src/service/randomNumberInRange' + +describe('Test randomNumberInRange function', () => { + + it('Should return a number from 3 to 7', () => { + + //given + const minimum = 3; + const maximum = 7; + + + //when + const randomNumber = randomNumberInRange(minimum, maximum); + + //then + expect(randomNumber).toBeLessThanOrEqual(7); + expect(randomNumber).toBeGreaterThanOrEqual(3); + }); + + it('Should return a number from 0 to 5, but not 2 or 4', () => { + + //given + const minimum = 0; + const maximum = 5; + const forbidden = [2, 4]; + + //when + const randomNumber = randomNumberInRange(minimum, maximum, forbidden); + + //then + expect(randomNumber).toBeLessThanOrEqual(5); + expect(randomNumber).toBeGreaterThanOrEqual(0); + expect(randomNumber).not.toBe(2); + expect(randomNumber).not.toBe(4); + }); + + it('Should return a number from -100 to 50', () => { + + //given + const minimum = -100; + const maximum = -50; + + //when + const randomNumber = randomNumberInRange(minimum, maximum); + + //then + expect(randomNumber).toBeLessThanOrEqual(-50); + expect(randomNumber).toBeGreaterThanOrEqual(-100); + }); + + it('Should return an error when min is greater than max', () => { + + //given + const minimum = 9; + const maximum = 3; + + //when + const randomNumberInvoke = () => {randomNumberInRange(minimum, maximum);} + + //then + expect(randomNumberInvoke).toThrowError(`Invalid arguments. Min is greated than max. Min - ${minimum}, max - ${maximum}`); + }); + + it('Should return an error when forbidden is not an array', () => { + + //given + const minimum = 7; + const maximum = 10; + const forbidden = 8; + + //when + const randomNumberInvoke = () => {randomNumberInRange(minimum, maximum, forbidden);} + + //then + expect(randomNumberInvoke).toThrowError(`Invalid argument type! The first and the second argument should be numbers, the third should be an array of only numbers. The arguments passed: min - ${minimum}, max - ${maximum}, forbiddenNumbersArray - ${forbidden}`); + }); + + it('Should return an error when in forbidden array there is sth that is not a number', () => { + + //given + const minimum = 7; + const maximum = 10; + const forbidden = ['5']; + + //when + const randomNumberInvoke = () => {randomNumberInRange(minimum, maximum, forbidden);} + + //then + expect(randomNumberInvoke).toThrowError(`Invalid argument type! The first and the second argument should be numbers, the third should be an array of only numbers. The arguments passed: min - ${minimum}, max - ${maximum}, forbiddenNumbersArray - ${forbidden}`); + }); + + it('Should return an error when min is not a number', () => { + + //given + const minimum = '7'; + const maximum = 10; + const forbidden = [8]; + + //when + const randomNumberInvoke = () => {randomNumberInRange(minimum, maximum, forbidden);} + + //then + expect(randomNumberInvoke).toThrowError(`Invalid argument type! The first and the second argument should be numbers, the third should be an array of only numbers. The arguments passed: min - ${minimum}, max - ${maximum}, forbiddenNumbersArray - ${forbidden}`); + }); + + it('Should return an error when max is not a number', () => { + + //given + const minimum = 7; + const maximum = {max: 10}; + const forbidden = [8]; + + //when + const randomNumberInvoke = () => {randomNumberInRange(minimum, maximum, forbidden);} + + //then + expect(randomNumberInvoke).toThrowError(`Invalid argument type! The first and the second argument should be numbers, the third should be an array of only numbers. The arguments passed: min - ${minimum}, max - ${maximum}, forbiddenNumbersArray - ${forbidden}`); + }); + + it('Should return an error when every possible outcome is forbidden', () => { + + //given + const minimum = 4; + const maximum = 6; + const forbidden = [5, 4, 6]; + + //when + const randomNumberInvoke = () => {randomNumberInRange(minimum, maximum, forbidden);} + + //then + expect(randomNumberInvoke).toThrowError(`Invalid array of forbidden numbers! All the possible outcomes are forbidden. The arguments passed: min - ${minimum}, max - ${maximum}, forbiddenNumbersArray - ${forbidden}.`); + }); + + it('Should return an error when every possible outcome is forbiddenn and forbidden array is longer than possible outcomes', () => { + + //given + const minimum = 4; + const maximum = 6; + const forbidden = [4, 5, 6, 7]; + + //when + const randomNumberInvoke = () => {randomNumberInRange(minimum, maximum, forbidden);} + + //then + expect(randomNumberInvoke).toThrowError(`Invalid array of forbidden numbers! All the possible outcomes are forbidden. The arguments passed: min - ${minimum}, max - ${maximum}, forbiddenNumbersArray - ${forbidden}.`); + }); +}); \ No newline at end of file diff --git a/test/shuffleAnswers.test.js b/test/shuffleAnswers.test.js new file mode 100644 index 0000000..d5e2e73 --- /dev/null +++ b/test/shuffleAnswers.test.js @@ -0,0 +1,67 @@ +import { shuffleAnswers } from "../src/service/shuffleAnswers"; + +describe('Test shuffeAnswers function', () => { + + it("Should return question object with answers shuffled and assigned new correct index", () => { + // Given + const exampleQuestion = { + question: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png', + answers: ['bulbasaur', 'ivysaur', 'venusaur', 'charmander'], + correctAnswer: { value: 'bulbasaur', index: 0} + } + + // When + const questionShuffled = shuffleAnswers(exampleQuestion); + + // Then + expect(questionShuffled).toHaveProperty('question'); + expect(questionShuffled.question).toEqual('https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png'); + + expect(questionShuffled).toHaveProperty('answers'); + expect(questionShuffled.answers).toHaveLength(4); + expect(questionShuffled.answers).toEqual(expect.arrayContaining(['bulbasaur', 'ivysaur', 'venusaur', 'charmander'])); //In any order + + expect(questionShuffled).toHaveProperty('correctAnswer'); + expect(questionShuffled.correctAnswer).toHaveProperty('value'); + expect(questionShuffled.correctAnswer.value).toEqual('bulbasaur'); + expect(questionShuffled.correctAnswer).toHaveProperty('index'); + + expect(questionShuffled.answers[questionShuffled.correctAnswer.index]).toEqual(questionShuffled.correctAnswer.value); + }); + + it("Should return an arrow if receives an empty object", () => { + // Given + const exampleQuestion = {}; + + // When + const questionShuffled = () => {shuffleAnswers(exampleQuestion)}; + + // Then + expect(questionShuffled).toThrowError(`Incorrect argument! The argument has to be a question object with properties question, anwers, correctAnswers.Argument received: ${exampleQuestion}`); + }); + + it("Should return an arrow if receives not an object", () => { + // Given + const exampleQuestion = 'pokemon'; + + // When + const questionShuffled = () => {shuffleAnswers(exampleQuestion)}; + + // Then + expect(questionShuffled).toThrowError(`Incorrect argument! The argument has to be a question object with properties question, anwers, correctAnswers.Argument received: ${exampleQuestion}`); + }); + + it("Should return an error if receives an incorrect object", () => { + // Given + const exampleQuestion = { + answers: ['bulbasaur', 'ivysaur', 'venusaur', 'charmander'], + correctAnswer: { value: 'bulbasaur', index: 0} + }; + + // When + const questionShuffled = () => {shuffleAnswers(exampleQuestion)}; + + // Then + expect(questionShuffled).toThrowError(`Incorrect argument! The argument has to be a question object with properties question, anwers, correctAnswers.Argument received: ${exampleQuestion}`); + }); +}); \ No newline at end of file