From 2e1223223e834681182f278041dffbb5dc4a47d1 Mon Sep 17 00:00:00 2001 From: MOHITKOURAV01 Date: Mon, 10 Aug 2026 20:08:56 +0530 Subject: [PATCH 1/2] fix: derive leaderboard stats from recorded activity (#671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A first-time visitor was told they had filed 2 reports, had 1 verified, answered 55 quizzes and earned 125 points. It was a hardcoded seed, presented in the first person — "Your Rank", "(You)" — with nothing marking it as a placeholder, and nothing a user could check it against. The panel also invented a storage key of its own, pollution-hub-user-points, that nothing else in the codebase writes to. Filing a real report in the Community Hub did not move the total; nor did earning challenge points, nor answering quizzes. src/utils/contributionStats.js derives the figures from the keys the rest of the app already maintains — pollution-community-reports, pollution_hub_total_points — plus a quiz-answer count it maintains itself from QUIZ_COMPLETED, since nothing persisted one before. Derived rather than accumulated: a stored total can drift from the activity it summarises, and there is no way to tell once it has. The three "Simulate Action" buttons rendered unconditionally in production, so any visitor could click +50 Verified Report and persist a verified report that was never submitted. Between those and the seed, no number on this panel corresponded to anything. What remains is dev-only and writes through the real recording path. Also: - rows keyed on user.name, so a stored name matching a mock entry produced duplicate React keys and one row silently won; keyed on id now - "Your Rank #0" on first paint, from findIndex(...) + 1 on an empty list - localStorage.setItem called inside a setState updater — unguarded against quota errors, and impure, so StrictMode wrote twice - mock contributors now carry a "sample" label, and the panel says plainly that there is no shared backend behind the board yet --- src/App.jsx | 3 + src/components/Leaderboard.jsx | 153 +++++++++++++-------- src/components/Leaderboard.test.jsx | 145 ++++++++++++++++++++ src/utils/contributionStats.js | 175 ++++++++++++++++++++++++ src/utils/contributionStats.test.js | 198 ++++++++++++++++++++++++++++ 5 files changed, 616 insertions(+), 58 deletions(-) create mode 100644 src/components/Leaderboard.test.jsx create mode 100644 src/utils/contributionStats.js create mode 100644 src/utils/contributionStats.test.js diff --git a/src/App.jsx b/src/App.jsx index 9b32f9f..d79fe64 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -34,6 +34,9 @@ import { fetchWindData, } from "./services/airQualityService"; import { eventBus } from "./core/events"; +// Imported for its side effect: it subscribes to QUIZ_COMPLETED so the count is +// recorded whether or not the leaderboard has ever been mounted. +import "./utils/contributionStats"; import { ThemeProvider, useTheme } from "./context/ThemeContext"; import ThemeSwitcher from "./components/ThemeSwitcher"; import CarbonFootprintCalculator from "./components/CarbonFootprintCalculator"; diff --git a/src/components/Leaderboard.jsx b/src/components/Leaderboard.jsx index a2ac7af..e6aa2f0 100644 --- a/src/components/Leaderboard.jsx +++ b/src/components/Leaderboard.jsx @@ -1,59 +1,77 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; +import { eventBus } from "../core/events"; +import { + readContributionStats, + recordQuizAnswers, + POINT_VALUES, + STATS_CHANGED_EVENT, +} from "../utils/contributionStats"; -// Point breakdown constants matching issue requirements +// Point breakdown constants matching issue requirements. The weights come from +// the scoring module so the table and the arithmetic cannot drift apart. const POINT_SYSTEM = [ - { action: "Verified Report Submitted", points: 50, badge: "🛡️ Verified" }, - { action: "New Report Submitted", points: 10, badge: "📝 Contributor" }, - { action: "Quiz Answer Completed", points: 1, badge: "🧠 Learner" }, + { action: "Verified Report Submitted", points: POINT_VALUES.verifiedReport, badge: "🛡️ Verified" }, + { action: "New Report Submitted", points: POINT_VALUES.report, badge: "📝 Contributor" }, + { action: "Quiz Answer Completed", points: POINT_VALUES.quizAnswer, badge: "🧠 Learner" }, ]; -const INITIAL_MOCK_LEADERBOARD = [ - { id: 1, name: "Aarav Sharma", points: 420, reports: 6, verified: 5, quizzes: 70, avatar: "👨‍💻" }, - { id: 2, name: "Ananya Patel", points: 365, reports: 8, verified: 4, quizzes: 65, avatar: "👩‍🔬" }, - { id: 3, name: "Rohan Gupta", points: 290, reports: 5, verified: 3, quizzes: 40, avatar: "🌱" }, - { id: 4, name: "Priya Singh", points: 215, reports: 4, verified: 2, quizzes: 15, avatar: "🛰️" }, - { id: 5, name: "Vikram Verma", points: 180, reports: 3, verified: 2, quizzes: 30, avatar: "🚴" }, +/** + * Illustrative entries, so the board is not empty on a fresh install. + * + * Flagged as samples and labelled as such in the table. They are not real people + * and the panel should not imply they are — the visitor's own row is the only one + * carrying real numbers until there is a backend behind this (#152). + */ +const SAMPLE_CONTRIBUTORS = [ + { id: "sample-1", name: "Aarav Sharma", points: 420, reports: 6, verified: 5, quizzes: 70, avatar: "👨‍💻", isSample: true }, + { id: "sample-2", name: "Ananya Patel", points: 365, reports: 8, verified: 4, quizzes: 65, avatar: "👩‍🔬", isSample: true }, + { id: "sample-3", name: "Rohan Gupta", points: 290, reports: 5, verified: 3, quizzes: 40, avatar: "🌱", isSample: true }, + { id: "sample-4", name: "Priya Singh", points: 215, reports: 4, verified: 2, quizzes: 15, avatar: "🛰️", isSample: true }, + { id: "sample-5", name: "Vikram Verma", points: 180, reports: 3, verified: 2, quizzes: 30, avatar: "🚴", isSample: true }, ]; -const USER_STORAGE_KEY = "pollution-hub-user-points"; - export default function Leaderboard() { - const [userStats, setUserStats] = useState(() => { - try { - const saved = localStorage.getItem(USER_STORAGE_KEY); - return saved - ? JSON.parse(saved) - : { name: "You (Guest)", points: 125, reports: 2, verified: 1, quizzes: 55, avatar: "🌟" }; - } catch { - return { name: "You (Guest)", points: 125, reports: 2, verified: 1, quizzes: 55, avatar: "🌟" }; - } - }); - - const [leaderboard, setLeaderboard] = useState([]); + // Derived from what the app recorded, not from a seed. A visitor who has done + // nothing sees zeros. + const [stats, setStats] = useState(() => readContributionStats()); + + const refresh = useCallback(() => setStats(readContributionStats()), []); useEffect(() => { - // Combine user stats with community leaderboard and rank by total points - const combined = [...INITIAL_MOCK_LEADERBOARD, { ...userStats, isCurrentUser: true }]; - combined.sort((a, b) => b.points - a.points); - setLeaderboard(combined); - }, [userStats]); + refresh(); + + eventBus.on(STATS_CHANGED_EVENT, refresh); + eventBus.on("COMMUNITY_REPORT_SUBMITTED", refresh); + + return () => { + eventBus.off(STATS_CHANGED_EVENT, refresh); + eventBus.off("COMMUNITY_REPORT_SUBMITTED", refresh); + }; + }, [refresh]); + + const userRow = useMemo( + () => ({ + id: "current-user", + name: "You (Guest)", + avatar: "🌟", + isCurrentUser: true, + ...stats, + }), + [stats] + ); + + const leaderboard = useMemo( + () => [...SAMPLE_CONTRIBUTORS, userRow].sort((a, b) => b.points - a.points), + [userRow] + ); const currentUserRank = leaderboard.findIndex((item) => item.isCurrentUser) + 1; - // Handler to simulate point earning for testing UI updates - const addPoints = (amount, type) => { - setUserStats((prev) => { - const updated = { - ...prev, - points: prev.points + amount, - reports: type === "report" ? prev.reports + 1 : prev.reports, - verified: type === "verified" ? prev.verified + 1 : prev.verified, - quizzes: type === "quiz" ? prev.quizzes + 1 : prev.quizzes, - }; - localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(updated)); - return updated; - }); - }; + // Dev-only seeding, so the ranking can be exercised without filing real reports. + // It writes through the same recording path as the app, rather than inventing a + // separate total — and it is not present in a production build. + const isDev = Boolean(import.meta.env?.DEV); + const simulateQuiz = () => recordQuizAnswers(10); return (
@@ -79,13 +97,13 @@ export default function Leaderboard() { }} >
- {userStats.avatar} + {userRow.avatar}

- {userStats.name} Current User + {userRow.name} Current User

-

- {userStats.verified} Verified Reports • {userStats.reports} Submissions • {userStats.quizzes} Quizzes Answered +

+ {stats.verified} Verified Reports • {stats.reports} Submissions • {stats.quizzes} Quizzes Answered

@@ -93,11 +111,14 @@ export default function Leaderboard() {
Your Rank -
#{currentUserRank}
+ {/* findIndex returns -1 before the list exists, which rendered "#0". */} +
+ {currentUserRank > 0 ? `#${currentUserRank}` : "—"} +
Total Points -
{userStats.points} pts
+
{userRow.points} pts
@@ -136,7 +157,8 @@ export default function Leaderboard() { return ( {user.avatar} {user.name} {user.isCurrentUser && (You)} + {user.isSample && ( + + sample + + )} {user.verified} {user.reports} @@ -162,13 +189,23 @@ export default function Leaderboard() { - {/* Developer Action Simulator for Testing */} -
- Simulate Action: - - - -
+

+ Your figures come from the reports you've filed and the quizzes you've + answered on this device. The other contributors are sample data — there is no + shared backend behind this board yet. +

+ + {/* Dev-only. Shipping this to production let any visitor click their way up + the ranking, which — together with the seeded starting figures — meant no + number on this panel corresponded to anything. */} + {isDev && ( +
+ Dev only: + +
+ )}
); } diff --git a/src/components/Leaderboard.test.jsx b/src/components/Leaderboard.test.jsx new file mode 100644 index 0000000..7b84b26 --- /dev/null +++ b/src/components/Leaderboard.test.jsx @@ -0,0 +1,145 @@ +import { render, screen, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Leaderboard from './Leaderboard'; +import { eventBus } from '../core/events'; +import { REPORTS_KEY, CHALLENGE_POINTS_KEY, QUIZ_ANSWERS_KEY } from '../utils/contributionStats'; + +function report(status = 'Pending') { + return { id: crypto.randomUUID(), title: 'Smoke', status, votes: 0 }; +} + +describe('Leaderboard - the visitor row is real (#671)', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('starts a first-time visitor at zero', () => { + render(); + + expect(screen.getByTestId('leaderboard-user-summary')).toHaveTextContent( + '0 Verified Reports • 0 Submissions • 0 Quizzes Answered' + ); + expect(screen.getByTestId('leaderboard-user-points')).toHaveTextContent('0 pts'); + }); + + it('does not show the old seeded figures', () => { + render(); + + const summary = screen.getByTestId('leaderboard-user-summary'); + expect(summary).not.toHaveTextContent('55 Quizzes'); + expect(summary).not.toHaveTextContent('2 Submissions'); + expect(screen.getByTestId('leaderboard-user-points')).not.toHaveTextContent('125 pts'); + }); + + it('reflects reports filed in the Community Hub', () => { + localStorage.setItem( + REPORTS_KEY, + JSON.stringify([report('Verified'), report(), report()]) + ); + + render(); + + expect(screen.getByTestId('leaderboard-user-summary')).toHaveTextContent( + '1 Verified Reports • 3 Submissions' + ); + // 3 submissions (30) + 1 verified (50) + expect(screen.getByTestId('leaderboard-user-points')).toHaveTextContent('80 pts'); + }); + + it('reflects daily challenge points, which it used to ignore entirely', () => { + localStorage.setItem(CHALLENGE_POINTS_KEY, '70'); + + render(); + + expect(screen.getByTestId('leaderboard-user-points')).toHaveTextContent('70 pts'); + }); + + it('reflects quiz answers', () => { + localStorage.setItem(QUIZ_ANSWERS_KEY, '25'); + + render(); + + expect(screen.getByTestId('leaderboard-user-summary')).toHaveTextContent( + '25 Quizzes Answered' + ); + }); + + it('refreshes when a report is submitted while it is open', () => { + render(); + expect(screen.getByTestId('leaderboard-user-summary')).toHaveTextContent('0 Submissions'); + + localStorage.setItem(REPORTS_KEY, JSON.stringify([report()])); + act(() => { + eventBus.emit('COMMUNITY_REPORT_SUBMITTED', report()); + }); + + expect(screen.getByTestId('leaderboard-user-summary')).toHaveTextContent('1 Submissions'); + }); +}); + +describe('Leaderboard - ranking (#671)', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('ranks a zero-point visitor last rather than showing #0', () => { + render(); + + // Five sample contributors, so the visitor is sixth. + expect(screen.getByTestId('leaderboard-user-rank')).toHaveTextContent('#6'); + expect(screen.getByTestId('leaderboard-user-rank')).not.toHaveTextContent('#0'); + }); + + it('moves the visitor up as their real total grows', () => { + localStorage.setItem(CHALLENGE_POINTS_KEY, '500'); + + render(); + + expect(screen.getByTestId('leaderboard-user-rank')).toHaveTextContent('#1'); + }); + + it('labels the mock entries as sample data', () => { + render(); + + expect(screen.getAllByText('sample')).toHaveLength(5); + }); + + it('does not label the visitor row as sample', () => { + render(); + + const userRow = screen.getByTestId('leaderboard-user-row'); + expect(userRow).toHaveTextContent('(You)'); + expect(userRow).not.toHaveTextContent('sample'); + }); + + it('keys rows on a stable id, so a visitor named after a sample does not collide', () => { + // Duplicate React keys would drop a row; six rows plus a header must survive. + render(); + + expect(screen.getAllByRole('row')).toHaveLength(7); + }); +}); + +describe('Leaderboard - the point simulator (#671)', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('is absent from a production build', () => { + vi.stubEnv('DEV', false); + + render(); + + expect(screen.queryByTestId('leaderboard-dev-tools')).not.toBeInTheDocument(); + expect(screen.queryByText('+50 Verified Report')).not.toBeInTheDocument(); + + vi.unstubAllEnvs(); + }); + + it('offers no way to award verified reports that were never filed', () => { + render(); + + expect(screen.queryByText('+50 Verified Report')).not.toBeInTheDocument(); + expect(screen.queryByText('+10 New Report')).not.toBeInTheDocument(); + }); +}); diff --git a/src/utils/contributionStats.js b/src/utils/contributionStats.js new file mode 100644 index 0000000..fbacbe0 --- /dev/null +++ b/src/utils/contributionStats.js @@ -0,0 +1,175 @@ +import { eventBus } from '../core/events'; + +/** + * The visitor's own contribution record, derived from what the app actually stored. + * + * The leaderboard used to seed a new visitor with `{ points: 125, reports: 2, + * verified: 1, quizzes: 55 }` and present it, in the first person, as their + * history. None of it had happened, and none of it could be checked against + * anything. It also invented a storage key of its own — `pollution-hub-user-points` + * — that nothing else in the codebase ever wrote to, so real activity elsewhere in + * the app never moved the total. + * + * Everything here reads the keys the rest of the app already maintains. + */ + +/** Written by CommunityHub. */ +export const REPORTS_KEY = 'pollution-community-reports'; +/** Written by ChallengesWidget. */ +export const CHALLENGE_POINTS_KEY = 'pollution_hub_total_points'; +/** Maintained here, from QUIZ_COMPLETED. Nothing persisted an answer count before. */ +export const QUIZ_ANSWERS_KEY = 'pollution-hub-quiz-answers'; + +/** Emitted when a recorded figure changes, so open views can refresh. */ +export const STATS_CHANGED_EVENT = 'CONTRIBUTION_STATS_CHANGED'; + +/** + * What each recorded action is worth. + * + * A verified report earns the submission points as well — it is still a + * submission — so a verified report is worth 60, not 50. + */ +export const POINT_VALUES = { + report: 10, + verifiedReport: 50, + quizAnswer: 1, +}; + +/** @param {string} key */ +function readRaw(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +/** + * @param {string} key + * @param {string} value + */ +function writeRaw(key, value) { + try { + localStorage.setItem(key, value); + return true; + } catch { + return false; + } +} + +/** + * A non-negative integer from whatever is in storage. + * + * @param {any} value + * @returns {number} + */ +function toCount(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return 0; + return Math.floor(parsed); +} + +/** + * The reports CommunityHub has stored locally. + * + * Reports are localStorage-only today (see #152), so every report in the store was + * filed by this visitor. If that changes, this is the place that needs an author + * filter rather than the component. + * + * @returns {any[]} + */ +function readReports() { + const raw = readRaw(REPORTS_KEY); + if (!raw) return []; + + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * @typedef {Object} ContributionStats + * @property {number} reports - Reports submitted. + * @property {number} verified - Of those, how many reached a verified status. + * @property {number} quizzes - Quiz questions answered. + * @property {number} challengePoints - Points earned from daily challenges. + * @property {number} points - The weighted total. + */ + +/** + * The visitor's stats, derived fresh from storage. + * + * Derived rather than accumulated: a stored total can drift from the activity it + * is meant to summarise, and there is no way to tell once it has. + * + * @returns {ContributionStats} + */ +export function readContributionStats() { + const reports = readReports(); + const reportCount = reports.length; + const verifiedCount = reports.filter( + (report) => typeof report?.status === 'string' && report.status.startsWith('Verified') + ).length; + + const quizzes = toCount(readRaw(QUIZ_ANSWERS_KEY)); + const challengePoints = toCount(readRaw(CHALLENGE_POINTS_KEY)); + + const points = + reportCount * POINT_VALUES.report + + verifiedCount * POINT_VALUES.verifiedReport + + quizzes * POINT_VALUES.quizAnswer + + challengePoints; + + return { reports: reportCount, verified: verifiedCount, quizzes, challengePoints, points }; +} + +/** + * Adds to the running count of quiz questions answered. + * + * @param {number} count + * @returns {number} The new total. + */ +export function recordQuizAnswers(count) { + const delta = toCount(count); + const total = toCount(readRaw(QUIZ_ANSWERS_KEY)) + delta; + + if (delta > 0) { + writeRaw(QUIZ_ANSWERS_KEY, String(total)); + eventBus.emit(STATS_CHANGED_EVENT, readContributionStats()); + } + + return total; +} + +/** @param {any} payload */ +function handleQuizCompleted(payload) { + // `total` is the number of questions in the quiz, all of which were answered by + // the time the completion event fires. `score` is how many were right, which is + // not what the leaderboard counts. + recordQuizAnswers(payload?.total); +} + +function handleReportSubmitted() { + eventBus.emit(STATS_CHANGED_EVENT, readContributionStats()); +} + +let tracking = false; + +/** + * Subscribes to the events that change the stats. Safe to call more than once. + */ +export function initContributionTracking() { + if (tracking) return; + tracking = true; + + eventBus.on('QUIZ_COMPLETED', handleQuizCompleted); + eventBus.on('COMMUNITY_REPORT_SUBMITTED', handleReportSubmitted); +} + +// Self-register on first import, matching achievementsStore, so no extra wiring is +// needed. App.jsx imports this for its side effect: a quiz can be completed +// without the leaderboard ever having been mounted. +initContributionTracking(); diff --git a/src/utils/contributionStats.test.js b/src/utils/contributionStats.test.js new file mode 100644 index 0000000..bc31700 --- /dev/null +++ b/src/utils/contributionStats.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + readContributionStats, + recordQuizAnswers, + initContributionTracking, + POINT_VALUES, + REPORTS_KEY, + CHALLENGE_POINTS_KEY, + QUIZ_ANSWERS_KEY, + STATS_CHANGED_EVENT, +} from './contributionStats'; +import { eventBus } from '../core/events'; + +/** A stored report in CommunityHub's shape. */ +function report(status = 'Pending') { + return { id: crypto.randomUUID(), title: 'Smoke', status, votes: 0 }; +} + +describe('readContributionStats', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('starts a new visitor at zero, not at the old seeded figures', () => { + expect(readContributionStats()).toEqual({ + reports: 0, + verified: 0, + quizzes: 0, + challengePoints: 0, + points: 0, + }); + }); + + it('counts reports from the store CommunityHub writes to', () => { + localStorage.setItem(REPORTS_KEY, JSON.stringify([report(), report(), report()])); + + const stats = readContributionStats(); + expect(stats.reports).toBe(3); + expect(stats.points).toBe(3 * POINT_VALUES.report); + }); + + it('counts verified reports as verified and as submissions', () => { + localStorage.setItem( + REPORTS_KEY, + JSON.stringify([report('Verified'), report('Pending')]) + ); + + const stats = readContributionStats(); + expect(stats.reports).toBe(2); + expect(stats.verified).toBe(1); + expect(stats.points).toBe(2 * POINT_VALUES.report + POINT_VALUES.verifiedReport); + }); + + it('recognises the suffixed verified statuses CommunityHub produces', () => { + localStorage.setItem( + REPORTS_KEY, + JSON.stringify([report('Verified via consensus'), report('Addressed')]) + ); + + expect(readContributionStats().verified).toBe(1); + }); + + it('includes daily challenge points', () => { + localStorage.setItem(CHALLENGE_POINTS_KEY, '40'); + expect(readContributionStats()).toMatchObject({ challengePoints: 40, points: 40 }); + }); + + it('counts quiz answers', () => { + localStorage.setItem(QUIZ_ANSWERS_KEY, '12'); + expect(readContributionStats()).toMatchObject({ + quizzes: 12, + points: 12 * POINT_VALUES.quizAnswer, + }); + }); + + it('adds every source into one total', () => { + localStorage.setItem(REPORTS_KEY, JSON.stringify([report('Verified'), report()])); + localStorage.setItem(QUIZ_ANSWERS_KEY, '7'); + localStorage.setItem(CHALLENGE_POINTS_KEY, '30'); + + expect(readContributionStats().points).toBe( + 2 * POINT_VALUES.report + POINT_VALUES.verifiedReport + 7 * POINT_VALUES.quizAnswer + 30 + ); + }); + + it.each([ + ['not json', 'corrupt'], + ['{}', 'an object rather than an array'], + ['null', 'null'], + ])('treats %s report storage (%s) as no reports', (raw) => { + localStorage.setItem(REPORTS_KEY, raw); + expect(readContributionStats().reports).toBe(0); + }); + + it.each([ + ['NaN', 'a stringified NaN'], + ['-5', 'a negative count'], + ['abc', 'junk'], + ])('treats %s (%s) as zero', (raw) => { + localStorage.setItem(QUIZ_ANSWERS_KEY, raw); + localStorage.setItem(CHALLENGE_POINTS_KEY, raw); + + const stats = readContributionStats(); + expect(stats.quizzes).toBe(0); + expect(stats.points).toBe(0); + }); + + it('never returns NaN points', () => { + localStorage.setItem(CHALLENGE_POINTS_KEY, 'NaN'); + expect(Number.isFinite(readContributionStats().points)).toBe(true); + }); + + it('survives localStorage throwing', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + + expect(() => readContributionStats()).not.toThrow(); + expect(readContributionStats().points).toBe(0); + + vi.restoreAllMocks(); + }); +}); + +describe('recordQuizAnswers', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('accumulates across calls', () => { + recordQuizAnswers(5); + recordQuizAnswers(3); + expect(readContributionStats().quizzes).toBe(8); + }); + + it('ignores a zero, negative or non-numeric count', () => { + recordQuizAnswers(0); + recordQuizAnswers(-4); + recordQuizAnswers('abc'); + expect(readContributionStats().quizzes).toBe(0); + }); + + it('announces the change', () => { + const seen = []; + const listener = (payload) => seen.push(payload); + eventBus.on(STATS_CHANGED_EVENT, listener); + + try { + recordQuizAnswers(4); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ quizzes: 4 }); + } finally { + eventBus.off(STATS_CHANGED_EVENT, listener); + } + }); +}); + +describe('quiz tracking', () => { + beforeEach(() => { + localStorage.clear(); + initContributionTracking(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('records every question answered, not just the correct ones', () => { + // A 10-question quiz scored 3/10 is still 10 answers. + eventBus.emit('QUIZ_COMPLETED', { quizId: 'basics', score: 3, total: 10, percent: 30 }); + + expect(readContributionStats().quizzes).toBe(10); + }); + + it('accumulates over several quizzes', () => { + eventBus.emit('QUIZ_COMPLETED', { quizId: 'a', score: 5, total: 5, percent: 100 }); + eventBus.emit('QUIZ_COMPLETED', { quizId: 'b', score: 2, total: 8, percent: 25 }); + + expect(readContributionStats().quizzes).toBe(13); + }); + + it('ignores a malformed payload', () => { + eventBus.emit('QUIZ_COMPLETED', null); + eventBus.emit('QUIZ_COMPLETED', {}); + eventBus.emit('QUIZ_COMPLETED', { total: 'lots' }); + + expect(readContributionStats().quizzes).toBe(0); + }); + + it('subscribes only once however often init is called', () => { + initContributionTracking(); + initContributionTracking(); + + eventBus.emit('QUIZ_COMPLETED', { quizId: 'a', score: 1, total: 4, percent: 25 }); + + expect(readContributionStats().quizzes).toBe(4); + }); +}); From c7098c8661fd1d6528b8acd41a3f9b3c23aeb7e9 Mon Sep 17 00:00:00 2001 From: MOHITKOURAV01 Date: Mon, 10 Aug 2026 20:16:02 +0530 Subject: [PATCH 2/2] test: stub localStorage via its own prototype rather than the Storage global --- src/utils/contributionStats.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/contributionStats.test.js b/src/utils/contributionStats.test.js index bc31700..f201439 100644 --- a/src/utils/contributionStats.test.js +++ b/src/utils/contributionStats.test.js @@ -111,7 +111,7 @@ describe('readContributionStats', () => { }); it('survives localStorage throwing', () => { - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + vi.spyOn(Object.getPrototypeOf(localStorage), 'getItem').mockImplementation(() => { throw new Error('SecurityError'); });