diff --git a/package-lock.json b/package-lock.json index 2134f81..e61d980 100644 --- a/package-lock.json +++ b/package-lock.json @@ -223,9 +223,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -233,9 +233,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -267,13 +267,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -327,19 +327,29 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -1714,6 +1724,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", + "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.6", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.6", + "vitest": "4.1.6" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", @@ -1944,6 +1985,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", + "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/backend": { "resolved": "packages/backend", "link": true @@ -3245,6 +3305,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3423,6 +3490,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4012,6 +4118,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5675,6 +5822,7 @@ "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^16.0.0", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.6", "jsdom": "^27.0.0", "react": "^19.2.5", "react-dom": "^19.2.5", diff --git a/packages/tests/frontend/Leaderboard.test.jsx b/packages/tests/frontend/Leaderboard.test.jsx index 9980105..d72dc57 100644 --- a/packages/tests/frontend/Leaderboard.test.jsx +++ b/packages/tests/frontend/Leaderboard.test.jsx @@ -92,4 +92,28 @@ describe("Leaderboard", () => { expect.objectContaining({ credentials: "include" }), ); }); + + it("renders an error message when the leaderboard request fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + mockFetchResponse({ error: "Service unavailable" }, false), + ), + ); + + render(); + + expect(await screen.findByText(/service unavailable/i)).toBeInTheDocument(); + }); + + it("renders nothing when the leaderboard is disabled for the current user", () => { + const disabledUser = { + totalHours: 42.5, + featureSettings: { leaderboardEnabled: false }, + }; + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/packages/tests/frontend/Timer.test.jsx b/packages/tests/frontend/Timer.test.jsx index 17e3b8a..2e56fe4 100644 --- a/packages/tests/frontend/Timer.test.jsx +++ b/packages/tests/frontend/Timer.test.jsx @@ -1,15 +1,68 @@ -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@frontend/components/MouseLock.jsx", async () => { + const React = await import("react"); + + return { + default: function MockMouseLock({ canvasRef, onLockChange }) { + return React.createElement("canvas", { + ref: canvasRef, + "data-testid": "mouse-lock-canvas", + onClick: () => onLockChange(true), + onDoubleClick: () => onLockChange(false), + }); + }, + }; +}); + import Timer from "@frontend/components/Timer.jsx"; +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + + Object.defineProperty(document, "pointerLockElement", { + value: null, + configurable: true, + }); + + document.exitPointerLock = vi.fn(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function startSession(container) { + const canvas = container.querySelector("canvas"); + + canvas.requestPointerLock = vi.fn().mockResolvedValue(); + + fireEvent.click(screen.getByRole("button", { name: /start session/i })); + + await act(async () => { + await Promise.resolve(); + }); + + fireEvent.click(canvas); + + return canvas; +} + +function renderTimer(props = {}) { + return render( + + + , + ); +} + describe("Timer", () => { it("renders the initial timer, controls, and canvas", () => { - const { container } = render( - - - , - ); + const { container } = renderTimer(); expect(screen.getByRole("heading", { name: "25:00" })).toBeInTheDocument(); expect( @@ -20,4 +73,204 @@ describe("Timer", () => { ).toBeInTheDocument(); expect(container.querySelector("canvas")).toBeInTheDocument(); }); + + it("shows an error when pointer lock fails with SecurityError", async () => { + const { container } = renderTimer(); + + const canvas = container.querySelector("canvas"); + const button = screen.getByRole("button", { name: /start session/i }); + + vi.spyOn(canvas, "requestPointerLock").mockRejectedValueOnce( + Object.assign(new Error("Denied"), { name: "SecurityError" }), + ); + + fireEvent.click(button); + + expect( + await screen.findByText(/cannot start the session that quick :\//i), + ).toBeInTheDocument(); + }); + + it("changes the session button when the lock becomes active and stops the session", async () => { + const { container } = renderTimer(); + + const canvas = await startSession(container); + + expect( + screen.getByRole("button", { name: /stop session/i }), + ).toBeInTheDocument(); + + Object.defineProperty(document, "pointerLockElement", { + value: canvas, + configurable: true, + }); + + fireEvent.click(screen.getByRole("button", { name: /stop session/i })); + + expect(document.exitPointerLock).toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: /start session/i }), + ).toBeInTheDocument(); + }); + + it("clears pointer lock SecurityError message after five seconds", async () => { + vi.useFakeTimers(); + + const { container } = renderTimer(); + + const canvas = container.querySelector("canvas"); + + canvas.requestPointerLock = vi.fn().mockRejectedValueOnce( + Object.assign(new Error("Denied"), { name: "SecurityError" }), + ); + + fireEvent.click(screen.getByRole("button", { name: /start session/i })); + + await act(async () => { + await Promise.resolve(); + }); + + expect( + screen.getByText(/cannot start the session that quick :\//i), + ).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect( + screen.queryByText(/cannot start the session that quick :\//i), + ).not.toBeInTheDocument(); +}); + +it("falls back to normal pointer lock when unadjusted movement is unsupported", async () => { + const { container } = renderTimer(); + + const canvas = container.querySelector("canvas"); + + canvas.requestPointerLock = vi + .fn() + .mockRejectedValueOnce( + Object.assign(new Error("Unsupported"), { name: "NotSupportedError" }), + ) + .mockResolvedValueOnce(); + + fireEvent.click(screen.getByRole("button", { name: /start session/i })); + + await act(async () => { + await Promise.resolve(); + }); + + expect(canvas.requestPointerLock).toHaveBeenCalledTimes(2); + expect(canvas.requestPointerLock).toHaveBeenNthCalledWith(1, { + unadjustedMovement: true, + }); + expect(canvas.requestPointerLock).toHaveBeenNthCalledWith(2); +}); + +it("logs unexpected pointer lock errors", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { container } = renderTimer(); + + const canvas = container.querySelector("canvas"); + const error = Object.assign(new Error("Unexpected"), { + name: "UnknownError", + }); + + canvas.requestPointerLock = vi.fn().mockRejectedValueOnce(error); + + fireEvent.click(screen.getByRole("button", { name: /start session/i })); + + await act(async () => { + await Promise.resolve(); + }); + + expect(consoleError).toHaveBeenCalledWith(error); + expect( + screen.queryByText(/cannot start the session that quick :\//i), + ).not.toBeInTheDocument(); +}); + +it("counts down while running", async () => { + vi.useFakeTimers(); + + const { container } = renderTimer(); + + await startSession(container); + + expect( + screen.getByRole("button", { name: /stop session/i }), + ).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(screen.getByRole("heading", { name: "24:59" })).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.getByRole("heading", { name: "24:57" })).toBeInTheDocument(); +}); + +it("saves completed focus time and switches to a short break", async () => { + vi.useFakeTimers(); + + fetch.mockResolvedValueOnce({ ok: true }); + + const { container } = renderTimer({ userId: "user-123" }); + + await startSession(container); + + await act(async () => { + vi.advanceTimersByTime(1500 * 1000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(fetch).toHaveBeenCalledTimes(1); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/users/user-123/study-time"), + expect.objectContaining({ + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hours: 1500 / 3600 }), + }), + ); + + expect( + screen.getByText(/session complete! press the button to continue/i), + ).toBeInTheDocument(); + + expect(screen.getByRole("heading", { name: "05:00" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /start session/i }), + ).toBeInTheDocument(); +}); + +it("shows a network error when saving completed study time fails", async () => { + vi.useFakeTimers(); + + fetch.mockResolvedValueOnce({ ok: false }); + + const { container } = renderTimer(); + + await startSession(container); + + await act(async () => { + vi.advanceTimersByTime(1500 * 1000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect( + screen.getByText(/network error: failed to update study time/i), + ).toBeInTheDocument(); +}); + }); diff --git a/packages/tests/package.json b/packages/tests/package.json index 2392b2c..b6cea2f 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -4,12 +4,13 @@ "version": "1.0.0", "type": "module", "scripts": { - "test": "vitest run" + "test": "vitest run --coverage" }, "devDependencies": { "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^16.0.0", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.6", "jsdom": "^27.0.0", "react": "^19.2.5", "react-dom": "^19.2.5", diff --git a/packages/tests/vitest.config.js b/packages/tests/vitest.config.js index 0612ea4..435e904 100644 --- a/packages/tests/vitest.config.js +++ b/packages/tests/vitest.config.js @@ -18,5 +18,23 @@ export default defineConfig({ environment: "jsdom", globals: true, setupFiles: ["./setupTests.js"], + coverage: { + provider: 'v8', + + // collect coverage for files outside the tests package + allowExternal: true, + + // Only collect frontend source coverage + include: ["frontend/src/**/*.{js,jsx}"], + + // Exclude frontend entry/config files and the entire backend + exclude: [ + "frontend/src/main.jsx", + "frontend/src/vite-env.d.ts", + "backend/**", + "backend/**/*", + ], + + }, }, });