From 955bd790d88a891e8dee44b3aa00ef10f0e224ab Mon Sep 17 00:00:00 2001 From: Victor Uchechukwu Date: Tue, 25 Aug 2026 16:55:51 +0100 Subject: [PATCH] ci: restore reproducible installs and add CI regression coverage ## Closes #3 ### What does this PR do? - Restores reproducible installs and pins supported runtime versions (Node.js >= 20.9.0 via .nvmrc and package.json engines, npm >= 10.0.0). - Adds comprehensive GitHub Actions CI pipeline running clean installs (npm ci), linting (npm run lint), TypeScript type checking (npm run type-check), unit & regression tests (npm test), network configuration validation (npm run test:network), smart contract compilation (npm run compile), contract testing (npm run test:contracts), Solidity coverage (npm run test:coverage), and production build (npm run build). - Introduces deterministic frontend unit and regression test suites for question invariants, adaptive difficulty thresholds, Zustand store actions & state transitions, countdown timer races & cleanup, session submit states, and page routing. - Enhances smart contract tests with full coverage of streak milestones, leaderboard boundaries and sorting, CELO reward claiming, and owner pool management, resolving a uint256 underflow in empty leaderboard queries. - Updates documentation in README with verified runtime prerequisites, test scripts, and local validation command sequence matching CI. --- .github/workflows/ci.yml | 6 + .gitignore | 1 + .nvmrc | 1 + README.md | 62 +++++-- contracts/contracts/MathBlocGame.sol | 3 + contracts/test/MathBlocGame.test.ts | 266 ++++++++++++++++++++++++--- eslint.config.mjs | 6 + hardhat.config.ts | 1 + lib/Web3Context.tsx | 2 +- package.json | 5 +- tests/aiTutor.test.ts | 171 +++++++++++++++++ tests/questionEngine.test.ts | 186 +++++++++++++++++++ tests/routes.test.tsx | 200 ++++++++++++++++++++ tests/store.test.ts | 235 +++++++++++++++++++++++ tests/timer.test.tsx | 117 ++++++++++++ tests/useSessionSubmit.test.ts | 172 +++++++++++++++++ 16 files changed, 1390 insertions(+), 44 deletions(-) create mode 100644 .nvmrc create mode 100644 tests/aiTutor.test.ts create mode 100644 tests/questionEngine.test.ts create mode 100644 tests/routes.test.tsx create mode 100644 tests/store.test.ts create mode 100644 tests/timer.test.tsx create mode 100644 tests/useSessionSubmit.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f4a7a7..daecece 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,9 @@ jobs: - name: Lint run: npm run lint + - name: Type check + run: npm run type-check + - name: Test application run: npm test @@ -41,5 +44,8 @@ jobs: - name: Test smart contracts run: npm run test:contracts + - name: Smart contract coverage + run: npm run test:coverage + - name: Build application run: npm run build diff --git a/.gitignore b/.gitignore index d216708..788ab6a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # testing /coverage +coverage.json # next.js /.next/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/README.md b/README.md index d4767b5..1c1bba9 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ An educational math game for children aged 2–9 with an on-chain reward system --- -## Tech Stack +## Tech Stack & Runtime Requirements | Layer | Tech | |---|---| +| Runtime | Node.js `>=20.9.0` (pinned via `.nvmrc` `20`), npm `>=10.0.0` | | Frontend | Next.js 16, React 19, Tailwind CSS | | State | Zustand (persisted to localStorage) | | Smart Contract | Solidity 0.8.24, Hardhat 2 | @@ -27,18 +28,25 @@ An educational math game for children aged 2–9 with an on-chain reward system ## Getting Started -### 1. Install dependencies +### 1. Prerequisites +Ensure you are using the supported Node.js and npm versions: ```bash -npm install +nvm use +# Node >= 20.9.0, npm >= 10.0.0 ``` -### 2. Set up environment +### 2. Install dependencies +```bash +npm ci +``` + +### 3. Set up environment ```bash cp .env.example .env.local # Fill in your DEPLOYER_PRIVATE_KEY ``` -### 3. Run locally +### 4. Run locally ```bash npm run dev # Open http://localhost:3000 @@ -46,6 +54,27 @@ npm run dev --- +## Local Validation (Matches CI) + +Run the full local validation sequence matching the CI workflow: + +```bash +npm ci && npm run lint && npm run type-check && npm test && npm run test:network && npm run compile && npm run test:contracts && npm run test:coverage && npm run build +``` + +### Individual Test Commands + +- **Frontend unit & component tests**: `npm test` +- **TypeScript type checking**: `npm run type-check` +- **ESLint**: `npm run lint` +- **Network configuration tests**: `npm run test:network` +- **Compile smart contracts**: `npm run compile` +- **Smart contract tests**: `npm run test:contracts` +- **Smart contract coverage**: `npm run test:coverage` +- **Production build**: `npm run build` + +--- + ## Smart Contract ### Contract: `MathBlocGame.sol` @@ -75,6 +104,12 @@ Deployed on **Celo Alfajores Testnet**. npm run compile ``` +### Test & Coverage +```bash +npm run test:contracts +npm run test:coverage +``` + ### Deploy to Alfajores (testnet) ```bash npm run deploy:alfajores @@ -132,14 +167,15 @@ mathbloc/ │ ├── data.ts # Story chapters, badges, TTS │ ├── useContract.ts # ethers.js contract hook │ └── contract.json # Auto-generated after deploy -└── contracts/ - ├── contracts/ - │ └── MathBlocGame.sol - ├── scripts/ - │ ├── deploy.ts - │ └── daily-activity.ts - └── test/ - └── MathBlocGame.test.ts +├── contracts/ +│ ├── contracts/ +│ │ └── MathBlocGame.sol +│ ├── scripts/ +│ │ ├── deploy.ts +│ │ └── daily-activity.ts +│ └── test/ +│ └── MathBlocGame.test.ts +└── tests/ # Frontend unit & regression tests ``` --- diff --git a/contracts/contracts/MathBlocGame.sol b/contracts/contracts/MathBlocGame.sol index ba688a6..846e2bd 100644 --- a/contracts/contracts/MathBlocGame.sol +++ b/contracts/contracts/MathBlocGame.sol @@ -247,6 +247,9 @@ contract MathBlocGame is Ownable, ReentrancyGuard { */ function getLeaderboard(uint256 topN) external view returns (LeaderboardEntry[] memory) { uint256 total = registeredPlayers.length; + if (total == 0 || topN == 0) { + return new LeaderboardEntry[](0); + } if (topN > total) topN = total; // Copy scores into memory array for sorting diff --git a/contracts/test/MathBlocGame.test.ts b/contracts/test/MathBlocGame.test.ts index 549acf7..11451a3 100644 --- a/contracts/test/MathBlocGame.test.ts +++ b/contracts/test/MathBlocGame.test.ts @@ -1,18 +1,18 @@ import { expect } from "chai"; import { ethers } from "hardhat"; -import { MathBlocGame } from "../artifacts/contracts/MathBlocGame.sol/MathBlocGame"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; describe("MathBlocGame", function () { - let contract: MathBlocGame; + let contract: any; let owner: HardhatEthersSigner; let player1: HardhatEthersSigner; let player2: HardhatEthersSigner; + let player3: HardhatEthersSigner; beforeEach(async () => { - [owner, player1, player2] = await ethers.getSigners(); + [owner, player1, player2, player3] = await ethers.getSigners(); const Factory = await ethers.getContractFactory("MathBlocGame"); - contract = (await Factory.deploy()) as unknown as MathBlocGame; + contract = await Factory.deploy(); await contract.waitForDeployment(); }); @@ -22,6 +22,12 @@ describe("MathBlocGame", function () { const p = await contract.getPlayer(player1.address); expect(p.username).to.equal("Alice"); expect(p.exists).to.be.true; + expect(p.totalScore).to.equal(0n); + expect(p.totalCorrect).to.equal(0n); + expect(p.totalAttempts).to.equal(0n); + expect(p.streak).to.equal(0n); + expect(p.coinsEarned).to.equal(0n); + expect(p.registeredAt).to.be.gt(0n); expect(await contract.getTotalPlayers()).to.equal(1n); }); @@ -33,6 +39,18 @@ describe("MathBlocGame", function () { it("rejects empty username", async () => { await expect(contract.connect(player1).register("")).to.be.revertedWith("Invalid username"); }); + + it("rejects username exceeding 32 bytes", async () => { + const longUsername = "a".repeat(33); + await expect(contract.connect(player1).register(longUsername)).to.be.revertedWith("Invalid username"); + }); + + it("accepts maximum 32 bytes username", async () => { + const validLongUsername = "a".repeat(32); + await expect(contract.connect(player1).register(validLongUsername)).to.not.be.reverted; + const p = await contract.getPlayer(player1.address); + expect(p.username).to.equal(validLongUsername); + }); }); describe("Activity Recording", () => { @@ -40,87 +58,277 @@ describe("MathBlocGame", function () { await contract.connect(player1).register("Alice"); }); - it("records activity and awards coins", async () => { + it("records activity and awards daily coins", async () => { await contract.connect(player1).recordActivity(80, 8, 10, "addition"); const p = await contract.getPlayer(player1.address); expect(p.totalScore).to.equal(80n); expect(p.totalCorrect).to.equal(8n); + expect(p.totalAttempts).to.equal(10n); expect(p.coinsEarned).to.equal(10n); // DAILY_REWARD_COINS + expect(await contract.totalActiveDays()).to.equal(1n); }); - it("awards perfect score bonus", async () => { + it("awards perfect score bonus when attempts >= 5 and 100% correct", async () => { await contract.connect(player1).recordActivity(100, 10, 10, "addition"); const p = await contract.getPlayer(player1.address); // DAILY_REWARD_COINS(10) + PERFECT_SCORE_BONUS(20) = 30 expect(p.coinsEarned).to.equal(30n); }); + it("does not award perfect score bonus when attempts < 5 even if 100% correct", async () => { + await contract.connect(player1).recordActivity(40, 4, 4, "addition"); + const p = await contract.getPlayer(player1.address); + expect(p.coinsEarned).to.equal(10n); // only daily reward, no bonus + }); + it("rejects unregistered player", async () => { await expect( contract.connect(player2).recordActivity(50, 5, 10, "counting") ).to.be.revertedWith("Not registered"); }); - it("rejects invalid correct count", async () => { + it("rejects 0 attempts", async () => { + await expect( + contract.connect(player1).recordActivity(0, 0, 0, "addition") + ).to.be.revertedWith("No attempts"); + }); + + it("rejects invalid correct count exceeding attempts", async () => { await expect( contract.connect(player1).recordActivity(50, 11, 10, "addition") ).to.be.revertedWith("Invalid correct count"); }); - it("tracks activity history", async () => { + it("rejects empty topic string", async () => { + await expect( + contract.connect(player1).recordActivity(50, 5, 10, "") + ).to.be.revertedWith("Topic required"); + }); + + it("tracks activity history and count", async () => { await contract.connect(player1).recordActivity(70, 7, 10, "subtraction"); const history = await contract.getActivityHistory(player1.address); expect(history.length).to.equal(1); expect(history[0].topicPlayed).to.equal("subtraction"); + expect(history[0].score).to.equal(70n); + expect(history[0].correct).to.equal(7n); + expect(history[0].attempts).to.equal(10n); + expect(await contract.getActivityCount(player1.address)).to.equal(1n); + }); + + it("handles multiple sessions on the same day without re-incrementing streak or active days", async () => { + await contract.connect(player1).recordActivity(50, 5, 10, "addition"); + await contract.connect(player1).recordActivity(60, 6, 10, "subtraction"); + + const p = await contract.getPlayer(player1.address); + expect(p.totalScore).to.equal(110n); + expect(p.totalCorrect).to.equal(11n); + expect(p.totalAttempts).to.equal(20n); + expect(p.streak).to.equal(1n); + expect(await contract.totalActiveDays()).to.equal(1n); + expect(await contract.getActivityCount(player1.address)).to.equal(2n); + }); + + it("updates todayActivePlayers and getDailyActivePlayers correctly", async () => { + await contract.connect(player2).register("Bob"); + await contract.connect(player1).recordActivity(50, 5, 10, "addition"); + await contract.connect(player2).recordActivity(60, 6, 10, "counting"); + + const active = await contract.getTodayActivePlayers(); + expect(active.length).to.equal(2); + expect(active[0]).to.equal(player1.address); + expect(active[1]).to.equal(player2.address); + expect(await contract.isActiveToday(player1.address)).to.be.true; + expect(await contract.isActiveToday(player3.address)).to.be.false; + + const block = await ethers.provider.getBlock("latest"); + const today = BigInt(Math.floor(block!.timestamp / 86400)); + const dailyActive = await contract.getDailyActivePlayers(today); + expect(dailyActive.length).to.equal(2); }); }); - describe("Streak", () => { - it("sets streak to 1 on first activity", async () => { - await contract.connect(player1).register("Bob"); + describe("Streak Logic and Milestones", () => { + it("increments streak on consecutive days", async () => { + await contract.connect(player1).register("Alice"); + await contract.connect(player1).recordActivity(50, 5, 10, "counting"); + expect((await contract.getPlayer(player1.address)).streak).to.equal(1n); + + // Advance time by 1 day (86400 seconds) + await ethers.provider.send("evm_increaseTime", [86400]); + await ethers.provider.send("evm_mine", []); + await contract.connect(player1).recordActivity(50, 5, 10, "counting"); + expect((await contract.getPlayer(player1.address)).streak).to.equal(2n); + }); + + it("resets streak if player misses a day", async () => { + await contract.connect(player1).register("Alice"); + await contract.connect(player1).recordActivity(50, 5, 10, "counting"); + expect((await contract.getPlayer(player1.address)).streak).to.equal(1n); + + // Advance time by 2 days (172800 seconds) - missed day + await ethers.provider.send("evm_increaseTime", [172800]); + await ethers.provider.send("evm_mine", []); + + await contract.connect(player1).recordActivity(50, 5, 10, "counting"); + expect((await contract.getPlayer(player1.address)).streak).to.equal(1n); + }); + + it("awards streak milestone bonus at 7-day streak", async () => { + await contract.connect(player1).register("Alice"); + + for (let day = 1; day <= 7; day++) { + if (day > 1) { + await ethers.provider.send("evm_increaseTime", [86400]); + await ethers.provider.send("evm_mine", []); + } + await contract.connect(player1).recordActivity(50, 5, 10, "counting"); + } + const p = await contract.getPlayer(player1.address); - expect(p.streak).to.equal(1n); + expect(p.streak).to.equal(7n); + // Days 1-6: 10 coins each = 60 + // Day 7: 10 + 5 * (7 / 7) = 15 + // Total coins = 75 + expect(p.coinsEarned).to.equal(75n); }); }); describe("Leaderboard", () => { - it("returns sorted leaderboard", async () => { + it("returns empty array when no players are registered", async () => { + const board = await contract.getLeaderboard(5); + expect(board.length).to.equal(0); + }); + + it("returns empty array when topN is 0", async () => { + await contract.connect(player1).register("Alice"); + const board = await contract.getLeaderboard(0); + expect(board.length).to.equal(0); + }); + + it("caps return count when topN exceeds total registered players", async () => { await contract.connect(player1).register("Alice"); - await contract.connect(player2).register("Bob"); await contract.connect(player1).recordActivity(100, 10, 10, "addition"); - await contract.connect(player2).recordActivity(50, 5, 10, "counting"); - const board = await contract.getLeaderboard(2); + const board = await contract.getLeaderboard(10); + expect(board.length).to.equal(1); expect(board[0].username).to.equal("Alice"); - expect(board[1].username).to.equal("Bob"); + expect(board[0].totalScore).to.equal(100n); + expect(board[0].player).to.equal(player1.address); + }); + + it("correctly sorts multiple players in descending order of total score", async () => { + await contract.connect(player1).register("Alice"); + await contract.connect(player2).register("Bob"); + await contract.connect(player3).register("Charlie"); + + await contract.connect(player1).recordActivity(40, 4, 10, "addition"); + await contract.connect(player2).recordActivity(90, 9, 10, "addition"); + await contract.connect(player3).recordActivity(70, 7, 10, "addition"); + + const board = await contract.getLeaderboard(3); + expect(board.length).to.equal(3); + expect(board[0].username).to.equal("Bob"); + expect(board[0].totalScore).to.equal(90n); + expect(board[1].username).to.equal("Charlie"); + expect(board[1].totalScore).to.equal(70n); + expect(board[2].username).to.equal("Alice"); + expect(board[2].totalScore).to.equal(40n); + }); + + it("limits output to topN when more players exist", async () => { + await contract.connect(player1).register("Alice"); + await contract.connect(player2).register("Bob"); + await contract.connect(player3).register("Charlie"); + + await contract.connect(player1).recordActivity(40, 4, 10, "addition"); + await contract.connect(player2).recordActivity(90, 9, 10, "addition"); + await contract.connect(player3).recordActivity(70, 7, 10, "addition"); + + const top2 = await contract.getLeaderboard(2); + expect(top2.length).to.equal(2); + expect(top2[0].username).to.equal("Bob"); + expect(top2[1].username).to.equal("Charlie"); }); }); - describe("CELO Rewards", () => { - it("allows owner to fund reward pool", async () => { + describe("CELO Rewards & Pool Management", () => { + it("allows owner to fund reward pool directly via fundRewardPool", async () => { await contract.connect(owner).fundRewardPool({ value: ethers.parseEther("0.1") }); expect(await contract.rewardPool()).to.equal(ethers.parseEther("0.1")); }); - it("allows claim when coins >= threshold", async () => { + it("accepts direct plain ETH transfer via receive fallback to fund reward pool", async () => { + await owner.sendTransaction({ + to: await contract.getAddress(), + value: ethers.parseEther("0.05"), + }); + expect(await contract.rewardPool()).to.equal(ethers.parseEther("0.05")); + }); + + it("reverts claim when player has insufficient coins (<100)", async () => { await contract.connect(owner).fundRewardPool({ value: ethers.parseEther("0.1") }); await contract.connect(player1).register("Alice"); + await contract.connect(player1).recordActivity(50, 5, 10, "addition"); // 10 coins - // Manually set coins via multiple perfect sessions (mock: just check logic) - // Record 4 perfect sessions to accumulate 30*4=120 coins > 100 threshold + await expect(contract.connect(player1).claimCeloReward()).to.be.revertedWith("Not enough coins"); + }); + + it("reverts claim when reward pool has insufficient CELO", async () => { + await contract.connect(player1).register("Alice"); + // Accumulate 120 coins with 4 perfect sessions (4 * 30 = 120) for (let i = 0; i < 4; i++) { - // Each call in same block counts as same day, only first updates streak await contract.connect(player1).recordActivity(100, 10, 10, "addition"); } - const p = await contract.getPlayer(player1.address); - if (p.coinsEarned >= 100n) { - const balBefore = await ethers.provider.getBalance(player1.address); - await contract.connect(player1).claimCeloReward(); - const balAfter = await ethers.provider.getBalance(player1.address); - expect(balAfter).to.be.gt(balBefore - ethers.parseEther("0.001")); + expect(p.coinsEarned).to.equal(120n); + + // Reward pool is 0 + await expect(contract.connect(player1).claimCeloReward()).to.be.revertedWith("Reward pool empty"); + }); + + it("successfully claims CELO reward, burns 100 coins, and transfers CELO", async () => { + await contract.connect(owner).fundRewardPool({ value: ethers.parseEther("0.1") }); + await contract.connect(player1).register("Alice"); + + // Accumulate 120 coins (4 perfect sessions * 30 coins) + for (let i = 0; i < 4; i++) { + await contract.connect(player1).recordActivity(100, 10, 10, "addition"); } + + const balBefore = await ethers.provider.getBalance(player1.address); + await contract.connect(player1).claimCeloReward(); + const balAfter = await ethers.provider.getBalance(player1.address); + expect(balAfter).to.be.gt(balBefore - ethers.parseEther("0.001")); + + const pAfter = await contract.getPlayer(player1.address); + expect(pAfter.coinsEarned).to.equal(20n); // 120 - 100 + expect(await contract.rewardPool()).to.equal(ethers.parseEther("0.099")); + }); + + it("allows owner to withdraw excess funds from reward pool", async () => { + await contract.connect(owner).fundRewardPool({ value: ethers.parseEther("0.1") }); + + const balBefore = await ethers.provider.getBalance(owner.address); + await contract.connect(owner).withdrawPool(ethers.parseEther("0.04")); + const balAfter = await ethers.provider.getBalance(owner.address); + expect(balAfter).to.be.gt(balBefore); + expect(await contract.rewardPool()).to.equal(ethers.parseEther("0.06")); + }); + + it("reverts withdrawPool if amount exceeds pool balance", async () => { + await contract.connect(owner).fundRewardPool({ value: ethers.parseEther("0.05") }); + await expect( + contract.connect(owner).withdrawPool(ethers.parseEther("0.1")) + ).to.be.revertedWith("Exceeds pool"); + }); + + it("reverts withdrawPool if called by non-owner", async () => { + await contract.connect(owner).fundRewardPool({ value: ethers.parseEther("0.05") }); + await expect( + contract.connect(player1).withdrawPool(ethers.parseEther("0.02")) + ).to.be.revertedWithCustomError(contract, "OwnableUnauthorizedAccount"); }); }); }); diff --git a/eslint.config.mjs b/eslint.config.mjs index f007422..468b290 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,11 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + "coverage/**", + "coverage.json", + "contracts/typechain-types/**", + "contracts/artifacts/**", + "contracts/cache/**", ]), { files: ["contracts/scripts/**/*.cjs"], @@ -23,6 +28,7 @@ const eslintConfig = defineConfig([ files: ["contracts/test/**/*.ts"], rules: { "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-explicit-any": "off", }, }, ]); diff --git a/hardhat.config.ts b/hardhat.config.ts index f8476e6..0fcd9b4 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -1,6 +1,7 @@ import { HardhatUserConfig } from "hardhat/config"; import "@nomicfoundation/hardhat-ethers"; import "@nomicfoundation/hardhat-chai-matchers"; +import "solidity-coverage"; import * as dotenv from "dotenv"; dotenv.config({ path: ".env.local" }); diff --git a/lib/Web3Context.tsx b/lib/Web3Context.tsx index e0cc22c..30c19e4 100644 --- a/lib/Web3Context.tsx +++ b/lib/Web3Context.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, type ReactNode } from 'react'; import { useContract, type OnChainPlayer, type LeaderboardEntry } from './useContract'; -interface Web3ContextValue { +export interface Web3ContextValue { connect: () => Promise; connected: boolean; address: string | null; diff --git a/package.json b/package.json index a0960ab..25bfa8c 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,20 @@ "version": "0.1.0", "private": true, "engines": { - "node": ">=20.9.0" + "node": ">=20.9.0", + "npm": ">=10.0.0" }, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", + "type-check": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "lint": "eslint", "compile": "hardhat compile", "test:contracts": "TS_NODE_PROJECT=tsconfig.hardhat.json hardhat test", + "test:coverage": "TS_NODE_PROJECT=tsconfig.hardhat.json hardhat coverage", "test:network": "TS_NODE_PROJECT=tsconfig.json ts-node --compiler-options '{\"module\":\"CommonJS\"}' tests/networkConfig.test.ts", "deploy:alfajores": "TS_NODE_PROJECT=tsconfig.hardhat.json hardhat run contracts/scripts/deploy.cjs --network alfajores", "deploy:celo": "TS_NODE_PROJECT=tsconfig.hardhat.json hardhat run contracts/scripts/deploy.cjs --network celo", diff --git a/tests/aiTutor.test.ts b/tests/aiTutor.test.ts new file mode 100644 index 0000000..95c59e1 --- /dev/null +++ b/tests/aiTutor.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import { + createTutorState, + updateTutor, + getTutorMessage, + shouldShowHint, + getEncouragement, +} from '../lib/aiTutor'; +import { SessionResult, Difficulty } from '../lib/types'; + +describe('aiTutor', () => { + describe('createTutorState', () => { + it('creates initial tutor state with default or custom difficulty', () => { + const defaultState = createTutorState(); + expect(defaultState.currentDifficulty).toBe(1); + expect(defaultState.weakAreas).toEqual([]); + expect(defaultState.recentResults).toEqual([]); + expect(defaultState.consecutiveCorrect).toBe(0); + expect(defaultState.consecutiveWrong).toBe(0); + expect(defaultState.hintsGiven).toBe(0); + + const customState = createTutorState(3 as Difficulty); + expect(customState.currentDifficulty).toBe(3); + }); + }); + + describe('updateTutor adaptive difficulty thresholds', () => { + const makeResult = (correct: boolean, hintsUsed = 0): SessionResult => ({ + questionId: `q-${Math.random()}`, + correct, + timeMs: 1500, + hintsUsed, + }); + + it('does not adjust difficulty before 5 answers in window', () => { + let state = createTutorState(2); + for (let i = 0; i < 4; i++) { + state = updateTutor(state, makeResult(true), 'addition'); + } + expect(state.currentDifficulty).toBe(2); + expect(state.recentResults).toHaveLength(4); + expect(state.consecutiveCorrect).toBe(4); + }); + + it('increases difficulty when reaching 4/5 correct threshold', () => { + let state = createTutorState(2); + // 4 correct, 1 wrong in a window of 5 answers + state = updateTutor(state, makeResult(true), 'addition'); + state = updateTutor(state, makeResult(true), 'addition'); + state = updateTutor(state, makeResult(true), 'addition'); + state = updateTutor(state, makeResult(false), 'addition'); + state = updateTutor(state, makeResult(true), 'addition'); + + expect(state.currentDifficulty).toBe(3); + expect(state.consecutiveCorrect).toBe(0); // reset on difficulty increase + }); + + it('caps maximum difficulty at 5', () => { + let state = createTutorState(5); + for (let i = 0; i < 5; i++) { + state = updateTutor(state, makeResult(true), 'addition'); + } + expect(state.currentDifficulty).toBe(5); + }); + + it('decreases difficulty when correct answers < 2 in window of 5', () => { + let state = createTutorState(3); + // 1 correct, 4 wrong + state = updateTutor(state, makeResult(false), 'addition'); + state = updateTutor(state, makeResult(false), 'addition'); + state = updateTutor(state, makeResult(true), 'addition'); + state = updateTutor(state, makeResult(false), 'addition'); + state = updateTutor(state, makeResult(false), 'addition'); + + expect(state.currentDifficulty).toBe(2); + expect(state.consecutiveWrong).toBe(0); // reset on difficulty decrease + }); + + it('caps minimum difficulty at 1', () => { + let state = createTutorState(1); + for (let i = 0; i < 5; i++) { + state = updateTutor(state, makeResult(false), 'addition'); + } + expect(state.currentDifficulty).toBe(1); + }); + + it('caps recentResults at 10 items max', () => { + let state = createTutorState(1); + for (let i = 0; i < 15; i++) { + state = updateTutor(state, makeResult(true), 'addition'); + } + expect(state.recentResults).toHaveLength(10); + }); + + it('increments hintsGiven count when hints are used', () => { + let state = createTutorState(1); + expect(state.hintsGiven).toBe(0); + + state = updateTutor(state, makeResult(true, 0), 'addition'); + expect(state.hintsGiven).toBe(0); + + state = updateTutor(state, makeResult(true, 1), 'addition'); + expect(state.hintsGiven).toBe(1); + + state = updateTutor(state, makeResult(false, 2), 'addition'); + expect(state.hintsGiven).toBe(2); + }); + }); + + describe('weak areas tracking', () => { + it('adds operation to weakAreas upon incorrect answer', () => { + let state = createTutorState(1); + state = updateTutor(state, { questionId: '1', correct: false, timeMs: 1000, hintsUsed: 0 }, 'multiplication'); + expect(state.weakAreas).toContain('multiplication'); + }); + + it('clears operation from weakAreas after 3 correct answers on that operation', () => { + let state = createTutorState(1); + state = updateTutor(state, { questionId: '1', correct: false, timeMs: 1000, hintsUsed: 0 }, 'division'); + expect(state.weakAreas).toContain('division'); + + // 3 correct answers on division + state = updateTutor(state, { questionId: '2', correct: true, timeMs: 1000, hintsUsed: 0 }, 'division'); + state = updateTutor(state, { questionId: '3', correct: true, timeMs: 1000, hintsUsed: 0 }, 'division'); + state = updateTutor(state, { questionId: '4', correct: true, timeMs: 1000, hintsUsed: 0 }, 'division'); + + expect(state.weakAreas).not.toContain('division'); + }); + }); + + describe('getTutorMessage', () => { + it('returns encouraging streak message when consecutiveCorrect >= 3', () => { + const state = { ...createTutorState(), consecutiveCorrect: 4 }; + const msg = getTutorMessage(state, true, 'Sam'); + expect(msg).toContain('🔥 4 in a row! You\'re on fire, Sam!'); + }); + + it('returns positive message for single correct answer', () => { + const state = { ...createTutorState(), consecutiveCorrect: 1 }; + const msg = getTutorMessage(state, true, 'Leo'); + expect(msg.length).toBeGreaterThan(0); + }); + + it('returns calming message when consecutiveWrong >= 3', () => { + const state = { ...createTutorState(), consecutiveWrong: 3 }; + const msg = getTutorMessage(state, false, 'Sam'); + expect(msg).toBe("Let's slow down and try an easier one! 😊"); + }); + + it('returns supportive message for incorrect answer', () => { + const state = { ...createTutorState(), consecutiveWrong: 1 }; + const msg = getTutorMessage(state, false, 'Mia'); + expect(msg.length).toBeGreaterThan(0); + }); + }); + + describe('shouldShowHint & getEncouragement', () => { + it('shows hint when consecutiveWrong >= 2', () => { + expect(shouldShowHint({ ...createTutorState(), consecutiveWrong: 0 })).toBe(false); + expect(shouldShowHint({ ...createTutorState(), consecutiveWrong: 1 })).toBe(false); + expect(shouldShowHint({ ...createTutorState(), consecutiveWrong: 2 })).toBe(true); + expect(shouldShowHint({ ...createTutorState(), consecutiveWrong: 5 })).toBe(true); + }); + + it('provides correct encouragement based on performance streaks', () => { + expect(getEncouragement({ ...createTutorState(), consecutiveWrong: 3 })).toBe("Take your time! 🐢"); + expect(getEncouragement({ ...createTutorState(), consecutiveCorrect: 5 })).toBe("You're a math superstar! ⭐"); + expect(getEncouragement({ ...createTutorState(), consecutiveCorrect: 2, consecutiveWrong: 1 })).toBe("You're doing great! 💪"); + }); + }); +}); diff --git a/tests/questionEngine.test.ts b/tests/questionEngine.test.ts new file mode 100644 index 0000000..044674e --- /dev/null +++ b/tests/questionEngine.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest'; +import { generateQuestion, getOperationsForAgeGroup } from '../lib/questionEngine'; +import { Operation, Difficulty } from '../lib/types'; + +describe('questionEngine', () => { + const operations: Operation[] = [ + 'recognition', + 'counting', + 'addition', + 'subtraction', + 'multiplication', + 'division', + ]; + + const difficulties: Difficulty[] = [1, 2, 3, 4, 5]; + + describe('generateQuestion invariants', () => { + operations.forEach(op => { + difficulties.forEach(diff => { + it(`generates valid question structure for ${op} at difficulty ${diff}`, () => { + for (let trial = 0; trial < 15; trial++) { + const q = generateQuestion(op, diff); + + expect(q.id).toBeTypeOf('string'); + expect(q.id.length).toBeGreaterThan(0); + expect(q.operation).toBe(op); + expect(q.difficulty).toBe(diff); + expect(q.prompt).toBeTypeOf('string'); + expect(q.prompt.length).toBeGreaterThan(0); + expect(q.answer).toBeTypeOf('number'); + expect(Number.isFinite(q.answer)).toBe(true); + expect(q.hint).toBeTypeOf('string'); + expect(q.hint.length).toBeGreaterThan(0); + + // Choices invariants + expect(q.choices).toHaveLength(4); + // All choices are finite numbers + q.choices.forEach(choice => { + expect(choice).toBeTypeOf('number'); + expect(Number.isFinite(choice)).toBe(true); + }); + // Choices are unique (no duplicates) + const uniqueChoices = new Set(q.choices); + expect(uniqueChoices.size).toBe(4); + // Answer must be present in choices + expect(q.choices).toContain(q.answer); + } + }); + }); + }); + }); + + describe('operation-specific question properties', () => { + describe('recognition', () => { + it('creates correct prompt, answer ranges and visualPrompt', () => { + for (let i = 0; i < 20; i++) { + const lowDiff = generateQuestion('recognition', 1); + expect(lowDiff.prompt).toBe('What number is this?'); + expect(lowDiff.visualPrompt).toHaveLength(1); + expect(lowDiff.visualPrompt![0]).toBe(String(lowDiff.answer)); + expect(lowDiff.answer).toBeGreaterThanOrEqual(1); + expect(lowDiff.answer).toBeLessThanOrEqual(5); + + const highDiff = generateQuestion('recognition', 4); + expect(highDiff.prompt).toBe('What number is this?'); + expect(highDiff.visualPrompt).toHaveLength(1); + expect(highDiff.visualPrompt![0]).toBe(String(highDiff.answer)); + expect(highDiff.answer).toBeGreaterThanOrEqual(1); + expect(highDiff.answer).toBeLessThanOrEqual(10); + } + }); + }); + + describe('counting', () => { + it('creates visualPrompt array matching the answer count', () => { + for (let i = 0; i < 20; i++) { + const q = generateQuestion('counting', 2); + expect(q.prompt).toBe('How many are there?'); + expect(q.visualPrompt).toBeDefined(); + expect(q.visualPrompt!.length).toBe(q.answer); + expect(q.answer).toBeGreaterThanOrEqual(1); + expect(q.answer).toBeLessThanOrEqual(5); + + const highQ = generateQuestion('counting', 4); + expect(highQ.visualPrompt!.length).toBe(highQ.answer); + expect(highQ.answer).toBeGreaterThanOrEqual(1); + expect(highQ.answer).toBeLessThanOrEqual(10); + } + }); + }); + + describe('addition', () => { + it('verifies equation arithmetic matching the answer', () => { + for (let i = 0; i < 30; i++) { + const q = generateQuestion('addition', 2); + const match = q.prompt.match(/^(\d+)\s*\+\s*(\d+)\s*=\s*\?$/); + expect(match).not.toBeNull(); + const a = Number(match![1]); + const b = Number(match![2]); + expect(a + b).toBe(q.answer); + expect(a).toBeGreaterThanOrEqual(1); + expect(a).toBeLessThanOrEqual(5); + expect(b).toBeGreaterThanOrEqual(1); + expect(b).toBeLessThanOrEqual(5); + + const hardQ = generateQuestion('addition', 4); + const hardMatch = hardQ.prompt.match(/^(\d+)\s*\+\s*(\d+)\s*=\s*\?$/); + expect(hardMatch).not.toBeNull(); + const hardA = Number(hardMatch![1]); + const hardB = Number(hardMatch![2]); + expect(hardA + hardB).toBe(hardQ.answer); + expect(hardA).toBeGreaterThanOrEqual(10); + expect(hardA).toBeLessThanOrEqual(50); + expect(hardB).toBeGreaterThanOrEqual(10); + expect(hardB).toBeLessThanOrEqual(50); + } + }); + }); + + describe('subtraction', () => { + it('verifies non-negative answer and valid operand ordering', () => { + for (let i = 0; i < 30; i++) { + const q = generateQuestion('subtraction', 2); + const match = q.prompt.match(/^(\d+)\s*-\s*(\d+)\s*=\s*\?$/); + expect(match).not.toBeNull(); + const a = Number(match![1]); + const b = Number(match![2]); + expect(a - b).toBe(q.answer); + expect(q.answer).toBeGreaterThanOrEqual(0); + expect(a).toBeGreaterThanOrEqual(b); + + const hardQ = generateQuestion('subtraction', 5); + const hardMatch = hardQ.prompt.match(/^(\d+)\s*-\s*(\d+)\s*=\s*\?$/); + expect(hardMatch).not.toBeNull(); + const hardA = Number(hardMatch![1]); + const hardB = Number(hardMatch![2]); + expect(hardA - hardB).toBe(hardQ.answer); + expect(hardQ.answer).toBeGreaterThanOrEqual(0); + expect(hardA).toBeGreaterThanOrEqual(hardB); + } + }); + }); + + describe('multiplication', () => { + it('verifies product matches answer correctly', () => { + for (let i = 0; i < 30; i++) { + const q = generateQuestion('multiplication', 3); + const match = q.prompt.match(/^(\d+)\s*×\s*(\d+)\s*=\s*\?$/); + expect(match).not.toBeNull(); + const a = Number(match![1]); + const b = Number(match![2]); + expect(a * b).toBe(q.answer); + expect(a).toBeGreaterThanOrEqual(1); + expect(b).toBeGreaterThanOrEqual(1); + } + }); + }); + + describe('division', () => { + it('verifies clean integer division without remainder and no division by zero', () => { + for (let i = 0; i < 30; i++) { + const q = generateQuestion('division', 2); + const match = q.prompt.match(/^(\d+)\s*÷\s*(\d+)\s*=\s*\?$/); + expect(match).not.toBeNull(); + const a = Number(match![1]); + const b = Number(match![2]); + expect(b).toBeGreaterThan(0); + expect(a % b).toBe(0); + expect(Math.floor(a / b)).toBe(q.answer); + expect(q.answer).toBeGreaterThanOrEqual(1); + } + }); + }); + }); + + describe('getOperationsForAgeGroup', () => { + it('returns age-appropriate operations for all groups', () => { + expect(getOperationsForAgeGroup('2-3')).toEqual(['recognition', 'counting']); + expect(getOperationsForAgeGroup('4-5')).toEqual(['counting', 'addition', 'subtraction']); + expect(getOperationsForAgeGroup('6-7')).toEqual(['addition', 'subtraction', 'multiplication']); + expect(getOperationsForAgeGroup('8-9')).toEqual(['multiplication', 'division', 'addition', 'subtraction']); + expect(getOperationsForAgeGroup('unknown')).toEqual(['recognition', 'counting']); + expect(getOperationsForAgeGroup('')).toEqual(['recognition', 'counting']); + }); + }); +}); diff --git a/tests/routes.test.tsx b/tests/routes.test.tsx new file mode 100644 index 0000000..2a0788b --- /dev/null +++ b/tests/routes.test.tsx @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import HomePage from '../app/page'; +import GamePage from '../app/game/page'; +import DashboardPage from '../app/dashboard/page'; +import PracticePage from '../app/game/practice/page'; +import ChallengePage from '../app/game/challenge/page'; +import StoryPage from '../app/game/story/page'; +import { useAppStore } from '../lib/store'; + +// Mock Next.js navigation +const mockPush = vi.fn(); +const mockReplace = vi.fn(); +let mockSearchParams = new URLSearchParams('op=addition'); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + replace: mockReplace, + }), + useSearchParams: () => mockSearchParams, +})); + +// Mock speech synthesis +vi.mock('@/lib/data', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + speak: vi.fn(), + }; +}); + +// Mock Web3Context hook +vi.mock('@/lib/Web3Context', () => ({ + useWeb3: () => ({ + connect: vi.fn(), + connected: false, + address: null, + player: null, + loading: false, + error: null, + isDeployed: true, + contractAddress: '0x47e8c3F53eE2ebE822d56a3501867Fea4B4D5815', + correctNetwork: true, + currentChainId: 44787, + deploymentChainId: 44787, + switchNetwork: vi.fn(), + recordActivity: vi.fn(), + register: vi.fn(), + claimReward: vi.fn(), + getLeaderboard: vi.fn().mockResolvedValue([]), + refreshPlayer: vi.fn(), + }), + Web3Provider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +describe('App Routes and Page Navigation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSearchParams = new URLSearchParams('op=addition'); + useAppStore.setState({ + profiles: [], + activeProfileId: null, + progress: {}, + tutorStates: {}, + session: null, + }); + }); + + describe('HomePage (/)', () => { + it('renders landing screen with title, mascot and profile selector when no profile is active', () => { + render(); + + expect(screen.getByText('MathBloc')).toBeInTheDocument(); + expect(screen.getByText('Fun Math for Kids! 🎉')).toBeInTheDocument(); + expect(screen.getByText('Pick a player to start! 👇')).toBeInTheDocument(); + expect(screen.queryByText('🎮 Play Now!')).not.toBeInTheDocument(); + }); + + it('renders Play and Dashboard buttons when a profile is selected', () => { + const profile = { + id: 'profile-1', + name: 'Lily', + ageGroup: '4-5' as const, + avatar: '🐱', + coins: 10, + stars: 2, + badges: [], + createdAt: Date.now(), + }; + + useAppStore.setState({ + profiles: [profile], + activeProfileId: 'profile-1', + }); + + render(); + + expect(screen.getByText(/Hi Lily! Ready to play\?/)).toBeInTheDocument(); + const playBtn = screen.getByText('🎮 Play Now!'); + const dashBtn = screen.getByText('📊 Parent Dashboard'); + + expect(playBtn).toBeInTheDocument(); + expect(dashBtn).toBeInTheDocument(); + + fireEvent.click(playBtn); + expect(mockPush).toHaveBeenCalledWith('/game'); + + fireEvent.click(dashBtn); + expect(mockPush).toHaveBeenCalledWith('/dashboard'); + }); + }); + + describe('GamePage (/game)', () => { + it('redirects to home if no active profile exists', () => { + render(); + expect(mockReplace).toHaveBeenCalledWith('/'); + }); + + it('renders game mode options when profile is active and navigates correctly', () => { + const profile = { + id: 'profile-1', + name: 'Tommy', + ageGroup: '6-7' as const, + avatar: '🚀', + coins: 20, + stars: 5, + badges: [], + createdAt: Date.now(), + }; + + useAppStore.setState({ + profiles: [profile], + activeProfileId: 'profile-1', + }); + + render(); + + expect(screen.getByText('Choose Mode')).toBeInTheDocument(); + expect(screen.getByText('Practice')).toBeInTheDocument(); + expect(screen.getByText('Challenge')).toBeInTheDocument(); + expect(screen.getByText('Story')).toBeInTheDocument(); + + // Select Practice Mode + fireEvent.click(screen.getByText('Practice')); + expect(screen.getByText('Choose Topic')).toBeInTheDocument(); + + // Select Addition Topic + fireEvent.click(screen.getByText('Addition')); + + // Click Let's Go + const startBtn = screen.getByText("🚀 Let's Go!"); + fireEvent.click(startBtn); + + expect(mockPush).toHaveBeenCalledWith('/game/practice?op=addition'); + }); + }); + + describe('DashboardPage (/dashboard)', () => { + it('renders Parent Dashboard with Web3 Panel', () => { + render(); + expect(screen.getByText('📊 Parent Dashboard')).toBeInTheDocument(); + }); + }); + + describe('Game Mode Pages (/game/practice, /game/challenge, /game/story)', () => { + beforeEach(() => { + const profile = { + id: 'profile-1', + name: 'Alice', + ageGroup: '4-5' as const, + avatar: '🌸', + coins: 10, + stars: 2, + badges: [], + createdAt: Date.now(), + }; + + useAppStore.setState({ + profiles: [profile], + activeProfileId: 'profile-1', + }); + }); + + it('renders PracticePage with active profile and question card', () => { + render(); + expect(screen.getByText(/Let's practice addition!/)).toBeInTheDocument(); + }); + + it('renders ChallengePage with timer and countdown', () => { + render(); + expect(screen.getByText(/⏱/)).toBeInTheDocument(); + }); + + it('renders StoryPage with chapter selection or story prompt', () => { + render(); + expect(screen.getByText(/Adventure/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/tests/store.test.ts b/tests/store.test.ts new file mode 100644 index 0000000..42043fa --- /dev/null +++ b/tests/store.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useAppStore, getActiveProfile, getProfileProgress, getAccuracy } from '../lib/store'; +import { ProgressRecord } from '../lib/types'; + +describe('useAppStore', () => { + beforeEach(() => { + // Reset store state before each test + useAppStore.setState({ + profiles: [], + activeProfileId: null, + progress: {}, + tutorStates: {}, + session: null, + }); + }); + + describe('Profile management', () => { + it('adds a new profile with default stats and sets creation timestamp', () => { + const { addProfile } = useAppStore.getState(); + addProfile({ name: 'Emma', ageGroup: '4-5', avatar: '🐱' }); + + const state = useAppStore.getState(); + expect(state.profiles).toHaveLength(1); + const profile = state.profiles[0]; + expect(profile.name).toBe('Emma'); + expect(profile.ageGroup).toBe('4-5'); + expect(profile.avatar).toBe('🐱'); + expect(profile.coins).toBe(0); + expect(profile.stars).toBe(0); + expect(profile.badges).toEqual([]); + expect(profile.id).toMatch(/^profile-\d+/); + expect(profile.createdAt).toBeTypeOf('number'); + }); + + it('sets active profile and retrieves with getActiveProfile selector', () => { + const profile = { + id: 'profile-1', + name: 'Alex', + ageGroup: '6-7' as const, + avatar: '🚀', + coins: 0, + stars: 0, + badges: [], + createdAt: Date.now(), + }; + useAppStore.setState({ profiles: [profile] }); + + useAppStore.getState().setActiveProfile('profile-1'); + expect(useAppStore.getState().activeProfileId).toBe('profile-1'); + + const active = getActiveProfile(useAppStore.getState()); + expect(active).toBeDefined(); + expect(active?.id).toBe('profile-1'); + expect(active?.name).toBe('Alex'); + }); + + it('deletes profile and resets activeProfileId if active profile was deleted', () => { + const p1 = { id: 'p-1', name: 'Player1', ageGroup: '2-3' as const, avatar: '🍎', coins: 0, stars: 0, badges: [], createdAt: 1 }; + const p2 = { id: 'p-2', name: 'Player2', ageGroup: '8-9' as const, avatar: '🌟', coins: 0, stars: 0, badges: [], createdAt: 2 }; + useAppStore.setState({ profiles: [p1, p2], activeProfileId: 'p-1' }); + + const { deleteProfile } = useAppStore.getState(); + deleteProfile('p-1'); + + expect(useAppStore.getState().profiles).toHaveLength(1); + expect(useAppStore.getState().profiles[0].id).toBe('p-2'); + expect(useAppStore.getState().activeProfileId).toBeNull(); + }); + + it('deletes profile without resetting activeProfileId when non-active is deleted', () => { + const p1 = { id: 'p-1', name: 'P1', ageGroup: '2-3' as const, avatar: '🍎', coins: 0, stars: 0, badges: [], createdAt: 1 }; + const p2 = { id: 'p-2', name: 'P2', ageGroup: '8-9' as const, avatar: '🌟', coins: 0, stars: 0, badges: [], createdAt: 2 }; + useAppStore.setState({ profiles: [p1, p2], activeProfileId: 'p-2' }); + + const { deleteProfile } = useAppStore.getState(); + deleteProfile('p-1'); + + expect(useAppStore.getState().activeProfileId).toBe('p-2'); + expect(useAppStore.getState().profiles).toHaveLength(1); + }); + }); + + describe('Progress and result recording', () => { + it('records first result for an operation correctly', () => { + const { recordResult } = useAppStore.getState(); + const profileId = 'test-profile-1'; + + recordResult(profileId, 'addition', true, 1200, 0); + + const state = useAppStore.getState(); + const records = getProfileProgress(state, profileId); + expect(records).toHaveLength(1); + + const record = records[0]; + expect(record.operation).toBe('addition'); + expect(record.totalAttempts).toBe(1); + expect(record.correctAttempts).toBe(1); + expect(record.avgTimeMs).toBe(1200); + expect(record.streak).toBe(1); + expect(record.lastPlayed).toBeTypeOf('number'); + + // Check tutor state was initialized and updated + const tutor = state.getTutorState(profileId); + expect(tutor).toBeDefined(); + expect(tutor.recentResults).toHaveLength(1); + }); + + it('accumulates multiple results and calculates weighted average time and streak', () => { + const { recordResult } = useAppStore.getState(); + const profileId = 'test-profile-2'; + + // 1st: correct, 1000ms + recordResult(profileId, 'addition', true, 1000, 0); + // 2nd: correct, 2000ms + recordResult(profileId, 'addition', true, 2000, 0); + // 3rd: wrong, 3000ms + recordResult(profileId, 'addition', false, 3000, 0); + + const records = getProfileProgress(useAppStore.getState(), profileId); + expect(records).toHaveLength(1); + const record = records[0]; + + expect(record.totalAttempts).toBe(3); + expect(record.correctAttempts).toBe(2); + // avg: (1000 + 2000 + 3000) / 3 = 2000 + expect(record.avgTimeMs).toBe(2000); + expect(record.streak).toBe(0); // reset on wrong answer + }); + + it('maintains separate progress records per operation', () => { + const { recordResult } = useAppStore.getState(); + const profileId = 'test-profile-3'; + + recordResult(profileId, 'addition', true, 1000, 0); + recordResult(profileId, 'subtraction', false, 1500, 0); + + const records = getProfileProgress(useAppStore.getState(), profileId); + expect(records).toHaveLength(2); + expect(records.find(r => r.operation === 'addition')?.correctAttempts).toBe(1); + expect(records.find(r => r.operation === 'subtraction')?.correctAttempts).toBe(0); + }); + }); + + describe('Rewards and Badges', () => { + it('adds coins and stars to active profile', () => { + const profile = { id: 'p-maya', name: 'Maya', ageGroup: '4-5' as const, avatar: '🐱', coins: 0, stars: 0, badges: [], createdAt: 1 }; + useAppStore.setState({ profiles: [profile], activeProfileId: 'p-maya' }); + + const { addReward } = useAppStore.getState(); + addReward('p-maya', 50, 5); + let updated = useAppStore.getState().profiles[0]; + expect(updated.coins).toBe(50); + expect(updated.stars).toBe(5); + + addReward('p-maya', 25, 2); + updated = useAppStore.getState().profiles[0]; + expect(updated.coins).toBe(75); + expect(updated.stars).toBe(7); + }); + + it('awards badge and avoids duplicate badges with same id', () => { + const profile = { id: 'p-maya', name: 'Maya', ageGroup: '4-5' as const, avatar: '🐱', coins: 0, stars: 0, badges: [], createdAt: 1 }; + useAppStore.setState({ profiles: [profile], activeProfileId: 'p-maya' }); + + const { awardBadge } = useAppStore.getState(); + const badge = { + id: 'speed_demon', + name: 'Speed Demon', + emoji: '⚡', + }; + + awardBadge('p-maya', badge); + expect(useAppStore.getState().profiles[0].badges).toHaveLength(1); + expect(useAppStore.getState().profiles[0].badges[0].id).toBe('speed_demon'); + expect(useAppStore.getState().profiles[0].badges[0].earnedAt).toBeTypeOf('number'); + + // Attempt duplicate award + awardBadge('p-maya', badge); + expect(useAppStore.getState().profiles[0].badges).toHaveLength(1); + }); + }); + + describe('Game Session State', () => { + it('starts and ends game sessions properly', () => { + const { startSession, endSession } = useAppStore.getState(); + expect(useAppStore.getState().session).toBeNull(); + + startSession('practice', 'multiplication', 2); + const session = useAppStore.getState().session; + expect(session).not.toBeNull(); + expect(session?.mode).toBe('practice'); + expect(session?.operation).toBe('multiplication'); + expect(session?.chapterId).toBe(2); + expect(session?.score).toBe(0); + expect(session?.total).toBe(0); + expect(session?.timeLeft).toBe(60); + expect(session?.active).toBe(true); + + endSession(); + expect(useAppStore.getState().session).toBeNull(); + }); + }); + + describe('Selectors and Helper Functions', () => { + it('computes accuracy percentage accurately across records', () => { + expect(getAccuracy([])).toBe(0); + + const records: ProgressRecord[] = [ + { + profileId: 'p1', + operation: 'addition', + totalAttempts: 10, + correctAttempts: 8, + avgTimeMs: 1000, + difficulty: 1, + lastPlayed: Date.now(), + streak: 2, + }, + { + profileId: 'p1', + operation: 'subtraction', + totalAttempts: 10, + correctAttempts: 6, + avgTimeMs: 1500, + difficulty: 1, + lastPlayed: Date.now(), + streak: 1, + }, + ]; + + // total: 20 attempts, 14 correct -> 70% + expect(getAccuracy(records)).toBe(70); + }); + }); +}); diff --git a/tests/timer.test.tsx b/tests/timer.test.tsx new file mode 100644 index 0000000..53b84d3 --- /dev/null +++ b/tests/timer.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import Timer from '../components/ui/Timer'; + +describe('Timer Component', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const advanceSeconds = (seconds: number) => { + for (let i = 0; i < seconds; i++) { + act(() => { + vi.advanceTimersByTime(1000); + }); + } + }; + + it('renders initial countdown seconds and progress bar', () => { + render(); + expect(screen.getByText(/60s/)).toBeInTheDocument(); + }); + + it('counts down second by second when running is true', () => { + const onExpire = vi.fn(); + render(); + + expect(screen.getByText(/10s/)).toBeInTheDocument(); + + advanceSeconds(1); + expect(screen.getByText(/9s/)).toBeInTheDocument(); + + advanceSeconds(3); + expect(screen.getByText(/6s/)).toBeInTheDocument(); + expect(onExpire).not.toHaveBeenCalled(); + }); + + it('pauses countdown when running is false', () => { + const onExpire = vi.fn(); + const { rerender } = render(); + + advanceSeconds(5); + expect(screen.getByText(/25s/)).toBeInTheDocument(); + + // Pause timer + rerender(); + + advanceSeconds(10); + // Remains 25s while paused + expect(screen.getByText(/25s/)).toBeInTheDocument(); + expect(onExpire).not.toHaveBeenCalled(); + + // Resume timer + rerender(); + advanceSeconds(2); + expect(screen.getByText(/23s/)).toBeInTheDocument(); + }); + + it('triggers onExpire callback when timer reaches 0', () => { + const onExpire = vi.fn(); + render(); + + advanceSeconds(3); + expect(screen.getByText(/0s/)).toBeInTheDocument(); + expect(onExpire).toHaveBeenCalledTimes(1); + + // Ensure it does not keep triggering onExpire after 0 + advanceSeconds(2); + expect(onExpire).toHaveBeenCalledTimes(1); + }); + + it('resets remaining time when seconds prop updates', () => { + const onExpire = vi.fn(); + const { rerender } = render(); + + advanceSeconds(5); + expect(screen.getByText(/15s/)).toBeInTheDocument(); + + // Prop reset + rerender(); + expect(screen.getByText(/60s/)).toBeInTheDocument(); + + advanceSeconds(1); + expect(screen.getByText(/59s/)).toBeInTheDocument(); + }); + + it('updates color and pulse indicator as time runs low', () => { + const { container } = render(); + + // >50% -> green + expect(container.querySelector('.text-green-600')).toBeInTheDocument(); + + // Advance to 15s (37.5% -> yellow) + advanceSeconds(25); + expect(container.querySelector('.text-yellow-500')).toBeInTheDocument(); + + // Advance to 8s (20% -> red and <=10s animate-pulse) + advanceSeconds(7); + expect(container.querySelector('.text-red-500')).toBeInTheDocument(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + + it('cleans up timeout on unmount without errors or memory leaks', () => { + const onExpire = vi.fn(); + const { unmount } = render(); + + advanceSeconds(2); + + unmount(); + + advanceSeconds(15); + expect(onExpire).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/useSessionSubmit.test.ts b/tests/useSessionSubmit.test.ts new file mode 100644 index 0000000..448be96 --- /dev/null +++ b/tests/useSessionSubmit.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useSessionSubmit, SessionPayload } from '../lib/useSessionSubmit'; +import { Web3ContextValue } from '../lib/Web3Context'; + +let mockWeb3State: Web3ContextValue = { + connect: vi.fn(), + connected: true, + address: '0x1234567890123456789012345678901234567890', + player: { + username: 'Alice', + totalScore: 100n, + totalCorrect: 10n, + totalAttempts: 10n, + streak: 1n, + coinsEarned: 30n, + lastActivityDay: 1n, + registeredAt: BigInt(Date.now()), + exists: true, + }, + loading: false, + error: null, + isDeployed: true, + contractAddress: '0x47e8c3F53eE2ebE822d56a3501867Fea4B4D5815', + correctNetwork: true, + currentChainId: 44787, + deploymentChainId: 44787, + switchNetwork: vi.fn(), + recordActivity: vi.fn(), + register: vi.fn(), + claimReward: vi.fn(), + getLeaderboard: vi.fn().mockResolvedValue([]), + refreshPlayer: vi.fn(), +}; + +vi.mock('@/lib/Web3Context', () => ({ + useWeb3: () => mockWeb3State, +})); + +describe('useSessionSubmit', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + mockWeb3State = { + connect: vi.fn(), + connected: true, + address: '0x1234567890123456789012345678901234567890', + player: { + username: 'Alice', + totalScore: 100n, + totalCorrect: 10n, + totalAttempts: 10n, + streak: 1n, + coinsEarned: 30n, + lastActivityDay: 1n, + registeredAt: BigInt(Date.now()), + exists: true, + }, + loading: false, + error: null, + isDeployed: true, + contractAddress: '0x47e8c3F53eE2ebE822d56a3501867Fea4B4D5815', + correctNetwork: true, + currentChainId: 44787, + deploymentChainId: 44787, + switchNetwork: vi.fn(), + recordActivity: vi.fn().mockResolvedValue(undefined), + register: vi.fn(), + claimReward: vi.fn(), + getLeaderboard: vi.fn().mockResolvedValue([]), + refreshPlayer: vi.fn().mockResolvedValue(undefined), + }; + }); + + const payload: SessionPayload = { + mode: 'practice', + topic: 'addition', + score: 10, + correct: 10, + attempts: 10, + profileId: 'p-1', + }; + + it('submits session and transitions to confirmed upon success', async () => { + const { result } = renderHook(() => useSessionSubmit()); + expect(result.current.submitStatus).toBe('idle'); + + await act(async () => { + result.current.submitSession(payload); + }); + + expect(mockWeb3State.recordActivity).toHaveBeenCalledWith(10, 10, 10, 'addition'); + expect(mockWeb3State.refreshPlayer).toHaveBeenCalled(); + expect(result.current.submitStatus).toBe('confirmed'); + }); + + it('sets not-connected status when wallet is not connected', async () => { + mockWeb3State.connected = false; + const { result } = renderHook(() => useSessionSubmit()); + + await act(async () => { + result.current.submitSession(payload); + }); + + expect(result.current.submitStatus).toBe('not-connected'); + expect(mockWeb3State.recordActivity).not.toHaveBeenCalled(); + }); + + it('sets not-registered status when player is not registered on chain', async () => { + mockWeb3State.player = null; + const { result } = renderHook(() => useSessionSubmit()); + + await act(async () => { + result.current.submitSession(payload); + }); + + expect(result.current.submitStatus).toBe('not-registered'); + expect(mockWeb3State.recordActivity).not.toHaveBeenCalled(); + }); + + it('sets wrong-network status when connected to incorrect chain', async () => { + mockWeb3State.correctNetwork = false; + const { result } = renderHook(() => useSessionSubmit()); + + await act(async () => { + result.current.submitSession(payload); + }); + + expect(result.current.submitStatus).toBe('wrong-network'); + expect(result.current.lastError).toContain('Wrong network'); + expect(mockWeb3State.recordActivity).not.toHaveBeenCalled(); + }); + + it('handles transaction rejection / error and allows retry', async () => { + mockWeb3State.recordActivity = vi.fn().mockRejectedValueOnce(new Error('User rejected transaction')); + const { result } = renderHook(() => useSessionSubmit()); + + await act(async () => { + result.current.submitSession(payload); + }); + + expect(result.current.submitStatus).toBe('rejected'); + expect(result.current.lastError).toBe('User rejected transaction'); + + // Retry after failure + mockWeb3State.recordActivity = vi.fn().mockResolvedValueOnce(undefined); + await act(async () => { + result.current.retry(); + }); + + expect(result.current.submitStatus).toBe('confirmed'); + }); + + it('prevents duplicate submissions using localStorage dedup', async () => { + const { result } = renderHook(() => useSessionSubmit()); + + await act(async () => { + result.current.submitSession(payload); + }); + + expect(mockWeb3State.recordActivity).toHaveBeenCalledTimes(1); + expect(result.current.submitStatus).toBe('confirmed'); + + // Second submit of exact same payload + await act(async () => { + result.current.submitSession(payload); + }); + + expect(mockWeb3State.recordActivity).toHaveBeenCalledTimes(1); // not called again + expect(result.current.submitStatus).toBe('confirmed'); + }); +});