diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ecce5e58..00ab49b1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@eslint/js": "^9.33.0", + "@testing-library/react": "^16.3.2", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^5.0.0", @@ -1896,6 +1897,63 @@ "node": ">= 10" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2352,6 +2410,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2375,6 +2444,17 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3021,6 +3101,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -4696,6 +4784,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6124,6 +6223,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6a38a3d2..3c1e0738 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@eslint/js": "^9.33.0", + "@testing-library/react": "^16.3.2", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^5.0.0", diff --git a/frontend/src/hooks/useMemoryMatch.test.js b/frontend/src/hooks/useMemoryMatch.test.js new file mode 100644 index 00000000..addfe327 --- /dev/null +++ b/frontend/src/hooks/useMemoryMatch.test.js @@ -0,0 +1,251 @@ +import React, { act, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { + useMemoryMatch, + DIFFICULTY_CONFIGS, +} from "./useMemoryMatch"; +import { act, renderHook } from "@testing-library/react"; + +vi.mock("../utils/matchAudio", () => ({ + playCardFlipSound: vi.fn(), + playMatchSuccessSound: vi.fn(), + playMatchWrongSound: vi.fn(), + playVictorySound: vi.fn(), + initAudioContext: vi.fn(), +})); + +vi.mock("../utils/dailySeed", () => ({ + getDailySeed: vi.fn(() => 12345), +})); + + +describe("useMemoryMatch", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("initializes with the expected default state", () => { + const { result, unmount } = renderHook(() => useMemoryMatch()); + + expect(result.current.phase).toBe("instructions"); + expect(result.current.difficulty).toBe("medium"); + expect(result.current.paused).toBe(false); + expect(result.current.dailyChallenge).toBe(false); + expect(result.current.cards).toEqual([]); + expect(result.current.flippedIndices).toEqual([]); + expect(result.current.moves).toBe(0); + expect(result.current.score).toBe(0); + expect(result.current.combo).toBe(1); + expect(result.current.maxCombo).toBe(1); + expect(result.current.stars).toBe(3); + expect(result.current.wrongMoves).toBe(0); + expect(result.current.timeElapsed).toBe(0); + expect(result.current.highScore).toBe(0); + expect(result.current.achievements).toEqual([]); + expect(result.current.perfectGame).toBe(false); + + unmount(); + }); + + it("loads the persisted high score from localStorage", () => { + localStorage.setItem("memory_match_high_score", "2500"); + + const { result, unmount } = renderHook(() => useMemoryMatch()); + + expect(result.current.highScore).toBe(2500); + + unmount(); + }); + + it("exposes the expected difficulty configurations", () => { + expect(DIFFICULTY_CONFIGS.easy).toMatchObject({ + rows: 4, + cols: 4, + pairs: 8, + multiplier: 1, + }); + + expect(DIFFICULTY_CONFIGS.medium).toMatchObject({ + rows: 5, + cols: 4, + pairs: 10, + multiplier: 1.8, + }); + + expect(DIFFICULTY_CONFIGS.hard).toMatchObject({ + rows: 6, + cols: 6, + pairs: 18, + multiplier: 3.5, + }); + + expect(DIFFICULTY_CONFIGS.extreme).toMatchObject({ + rows: 6, + cols: 6, + pairs: 18, + multiplier: 5.5, + }); + }); + + it("starts a game with the selected difficulty", () => { + const { result, unmount } = renderHook(() => useMemoryMatch()); + + act(() => { + result.current.startGame("easy", false); + }); + + expect(result.current.phase).toBe("playing"); + expect(result.current.difficulty).toBe("easy"); + expect(result.current.dailyChallenge).toBe(false); + expect(result.current.paused).toBe(false); + expect(result.current.moves).toBe(0); + expect(result.current.score).toBe(0); + expect(result.current.wrongMoves).toBe(0); + expect(result.current.timeElapsed).toBe(0); + expect(result.current.cards).toHaveLength(16); + expect(result.current.rowsCount).toBe(4); + expect(result.current.colsCount).toBe(4); + + unmount(); + }); + + it("creates matching pairs when starting a game", () => { + const { result, unmount } = renderHook(() => useMemoryMatch()); + + act(() => { + result.current.startGame("easy", false); + }); + + const counts = result.current.cards.reduce((map, card) => { + map[card.iconIndex] = (map[card.iconIndex] || 0) + 1; + return map; + }, {}); + + expect(Object.values(counts)).toEqual( + expect.arrayContaining(Array(8).fill(2)) + ); + expect(Object.keys(counts)).toHaveLength(8); + + expect( + result.current.cards.every( + (card) => + typeof card.id === "number" && + typeof card.iconIndex === "number" && + card.isFlipped === false && + card.isMatched === false + ) + ).toBe(true); + + unmount(); + }); + + it("updates elapsed time while the game is active", () => { + vi.useFakeTimers(); + + const { result, unmount } = renderHook(() => useMemoryMatch()); + + act(() => { + result.current.startGame("easy", false); + }); + + expect(result.current.timeElapsed).toBe(0); + + act(() => { + vi.advanceTimersByTime(3000); + }); + + expect(result.current.timeElapsed).toBe(3); + + unmount(); + }); + + it("pauses and resumes the game timer", () => { + vi.useFakeTimers(); + + const { result, unmount } = renderHook(() => useMemoryMatch()); + + act(() => { + result.current.startGame("easy", false); + }); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current.timeElapsed).toBe(2); + + act(() => { + result.current.pauseGame(); + }); + + act(() => { + vi.advanceTimersByTime(3000); + }); + + expect(result.current.timeElapsed).toBe(2); + expect(result.current.paused).toBe(true); + + act(() => { + result.current.resumeGame(); + }); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current.timeElapsed).toBe(4); + expect(result.current.paused).toBe(false); + + unmount(); + }); + + it("quits the game and returns to the instructions phase", () => { + const { result, unmount } = renderHook(() => useMemoryMatch()); + + act(() => { + result.current.startGame("easy", false); + }); + + expect(result.current.phase).toBe("playing"); + expect(result.current.cards).not.toEqual([]); + + act(() => { + result.current.quitGame(); + }); + + expect(result.current.phase).toBe("instructions"); + expect(result.current.paused).toBe(false); + expect(result.current.cards).toEqual([]); + expect(result.current.flippedIndices).toEqual([]); + + unmount(); + }); + + it("calculates 100 percent accuracy when no moves have been made", () => { + const { result, unmount } = renderHook(() => useMemoryMatch()); + + expect(result.current.accuracy).toBe(100); + + unmount(); + }); + + it("allows the difficulty to be changed before starting", () => { + const { result, unmount } = renderHook(() => useMemoryMatch()); + + act(() => { + result.current.setDifficulty("hard"); + }); + + expect(result.current.difficulty).toBe("hard"); + expect(result.current.config).toEqual(DIFFICULTY_CONFIGS.hard); + + unmount(); + }); +}); \ No newline at end of file