diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml
index 9c1f411..4a705ae 100644
--- a/.github/workflows/action.yml
+++ b/.github/workflows/action.yml
@@ -23,5 +23,6 @@ jobs:
- name: Backend Test Coverage Results
run: docker compose exec backend coverage report
-# - name: Run React tests
-# run: docker compose exec frontend npm test -- --watchAll=false
+ - name: Run React coverage test
+ run: docker compose exec -iT frontend npm test -- --coverage --watchAll=false
+
diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js
index 1f03afe..ec6adcb 100644
--- a/frontend/src/App.test.js
+++ b/frontend/src/App.test.js
@@ -1,8 +1,240 @@
-import { render, screen } from '@testing-library/react';
-import App from './App';
+/**
+ * @jest-environment jsdom
+ */
+const { render, screen } = require("@testing-library/react");
+require("@testing-library/jest-dom");
-test('renders learn react link', () => {
- render();
- const linkElement = screen.getByText(/learn react/i);
- expect(linkElement).toBeInTheDocument();
+jest.mock("./App.css", () => ({}));
+
+jest.mock("./AuthContext.js", () => {
+ const React = require("react");
+ return {
+ AuthProvider: ({ children }) =>
+ React.createElement("div", { "data-testid": "auth-provider" }, children),
+ };
+});
+
+jest.mock("./components/checkLevelPassed.js", () => {
+ const React = require("react");
+ return {
+ ProgressProvider: ({ children }) =>
+ React.createElement("div", { "data-testid": "progress-provider" }, children),
+ };
+});
+
+jest.mock("./ThemeContext.js", () => ({
+ useTheme: jest.fn(),
+}));
+
+jest.mock("react-router-dom", () => {
+ const React = require("react");
+
+ return {
+ BrowserRouter: ({ children }) =>
+ React.createElement("div", { "data-testid": "browser-router" }, children),
+
+ Routes: ({ children }) =>
+ React.createElement("div", { "data-testid": "routes" }, children),
+
+ Route: ({ path, element }) =>
+ React.createElement("div", { "data-testid": `route-${path}` }, element),
+ };
+});
+
+jest.mock("./pages/Home.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Home Page");
+});
+
+jest.mock("./pages/Login.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Login Page");
+});
+
+jest.mock("./pages/Register.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Register Page");
+});
+
+jest.mock("./pages/Levels.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Levels Page");
+});
+
+jest.mock("./pages/levels/levelTest.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level Test Page");
+});
+
+jest.mock("./pages/levels/Level1.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 1 Page");
+});
+jest.mock("./pages/levels/Level2.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 2 Page");
+});
+jest.mock("./pages/levels/Level3.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 3 Page");
+});
+jest.mock("./pages/levels/Level4.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 4 Page");
+});
+jest.mock("./pages/levels/Level5.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 5 Page");
+});
+jest.mock("./pages/levels/Level6.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 6 Page");
+});
+jest.mock("./pages/levels/Level7.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 7 Page");
+});
+jest.mock("./pages/levels/Level8.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 8 Page");
+});
+jest.mock("./pages/levels/Level9.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 9 Page");
+});
+jest.mock("./pages/levels/Level10.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 10 Page");
+});
+jest.mock("./pages/levels/Level11.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 11 Page");
+});
+jest.mock("./pages/levels/Level12.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 12 Page");
});
+jest.mock("./pages/levels/Level13.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 13 Page");
+});
+jest.mock("./pages/levels/Level14.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 14 Page");
+});
+jest.mock("./pages/levels/Level15.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 15 Page");
+});
+jest.mock("./pages/levels/Level16.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 16 Page");
+});
+jest.mock("./pages/levels/Level17.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 17 Page");
+});
+jest.mock("./pages/levels/Level18.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 18 Page");
+});
+jest.mock("./pages/levels/Level19.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 19 Page");
+});
+jest.mock("./pages/levels/Level20.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 20 Page");
+});
+jest.mock("./pages/levels/Level21.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 21 Page");
+});
+jest.mock("./pages/levels/Level22.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 22 Page");
+});
+jest.mock("./pages/levels/Level23.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 23 Page");
+});
+jest.mock("./pages/levels/Level24.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 24 Page");
+});
+jest.mock("./pages/levels/Level25.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 25 Page");
+});
+jest.mock("./pages/levels/Level26.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 26 Page");
+});
+jest.mock("./pages/levels/Level27.js", () => {
+ const React = require("react");
+ return () => React.createElement("div", null, "Level 27 Page");
+});
+
+const React = require("react");
+const { useTheme } = jest.requireMock("./ThemeContext.js");
+const App = require("./App").default;
+
+beforeEach(() => {
+ useTheme.mockReset();
+ useTheme.mockReturnValue({ theme: "dark" });
+});
+
+describe("App", () => {
+ it("wraps the app in ProgressProvider and AuthProvider", () => {
+ render(React.createElement(App));
+
+ expect(screen.getByTestId("progress-provider")).toBeInTheDocument();
+ expect(screen.getByTestId("auth-provider")).toBeInTheDocument();
+ });
+
+ it("uses dark theme app styling", () => {
+ const { container } = render(React.createElement(App));
+
+ expect(container.querySelector(".bg-slate-950")).toBeInTheDocument();
+ expect(container.querySelector(".text-white")).toBeInTheDocument();
+ });
+
+ it("uses light theme app styling", () => {
+ useTheme.mockReturnValue({ theme: "light" });
+
+ const { container } = render(React.createElement(App));
+
+ expect(container.querySelector(".bg-slate-50")).toBeInTheDocument();
+ expect(container.querySelector(".text-slate-900")).toBeInTheDocument();
+ });
+
+ it("renders main routes", () => {
+ render(React.createElement(App));
+
+ expect(screen.getByTestId("route-/")).toBeInTheDocument();
+ expect(screen.getByTestId("route-/login")).toBeInTheDocument();
+ expect(screen.getByTestId("route-/register")).toBeInTheDocument();
+ expect(screen.getByTestId("route-/levels")).toBeInTheDocument();
+
+ expect(screen.getByText("Home Page")).toBeInTheDocument();
+ expect(screen.getByText("Login Page")).toBeInTheDocument();
+ expect(screen.getByText("Register Page")).toBeInTheDocument();
+ expect(screen.getByText("Levels Page")).toBeInTheDocument();
+ });
+
+ it("renders all level routes", () => {
+ render(React.createElement(App));
+
+ for (let i = 1; i <= 27; i += 1) {
+ expect(screen.getByTestId(`route-/levels/${i}`)).toBeInTheDocument();
+ expect(screen.getByText(`Level ${i} Page`)).toBeInTheDocument();
+ }
+ });
+
+ it("renders the test level route", () => {
+ render(React.createElement(App));
+
+ expect(screen.getByTestId("route-/levels/test")).toBeInTheDocument();
+ expect(screen.getByText("Level Test Page")).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/AuthContext.test.js b/frontend/src/AuthContext.test.js
new file mode 100644
index 0000000..9c771e8
--- /dev/null
+++ b/frontend/src/AuthContext.test.js
@@ -0,0 +1,187 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require("react");
+const { render, screen, waitFor, fireEvent } = require("@testing-library/react");
+require("@testing-library/jest-dom");
+
+jest.mock("./api", () => ({
+ get: jest.fn(),
+ post: jest.fn(),
+}));
+
+jest.mock("./components/checkLevelPassed", () => ({
+ useProgress: jest.fn(),
+ fetchProgress: jest.fn(),
+}));
+
+const api = require("./api");
+const { useProgress } = jest.requireMock("./components/checkLevelPassed");
+const { AuthProvider, useAuth } = require("./AuthContext");
+
+let clearProgress;
+let fetchProgress;
+let consoleLogSpy;
+
+function AuthConsumer() {
+ const { user, login, logout, loading } = useAuth();
+
+ return (
+
+
Loading: {loading ? "yes" : "no"}
+
User: {user ? user.username : "none"}
+
+
+
+ );
+}
+
+beforeEach(() => {
+ localStorage.clear();
+
+ api.get.mockReset();
+ api.post.mockReset();
+
+ clearProgress = jest.fn();
+ fetchProgress = jest.fn();
+
+ useProgress.mockReset();
+ useProgress.mockReturnValue({
+ clearProgress,
+ fetchProgress,
+ });
+
+ consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ consoleLogSpy.mockRestore();
+});
+
+describe("AuthContext", () => {
+ it("sets loading false when there is no stored token", async () => {
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Loading: no")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("User: none")).toBeInTheDocument();
+ expect(api.get).not.toHaveBeenCalled();
+ });
+
+ it("loads current user when access token exists", async () => {
+ localStorage.setItem("access", "stored-token");
+
+ api.get.mockResolvedValue({
+ data: {
+ username: "storeduser",
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("User: storeduser")).toBeInTheDocument();
+ });
+
+ expect(api.get).toHaveBeenCalledWith("/api/auth/me/");
+ expect(fetchProgress).toHaveBeenCalled();
+ expect(screen.getByText("Loading: no")).toBeInTheDocument();
+ });
+
+ it("clears localStorage when loading current user fails", async () => {
+ localStorage.setItem("access", "bad-token");
+
+ api.get.mockRejectedValue(new Error("auth failed"));
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Loading: no")).toBeInTheDocument();
+ });
+
+ expect(localStorage.getItem("access")).toBeNull();
+ });
+
+ it("login saves tokens, loads user, clears old progress, and fetches progress", async () => {
+ api.post.mockResolvedValue({
+ data: {
+ access: "access-token",
+ refresh: "refresh-token",
+ },
+ });
+
+ api.get.mockResolvedValue({
+ data: {
+ username: "testuser",
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Loading: no")).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText("Login"));
+
+ await waitFor(() => {
+ expect(screen.getByText("User: testuser")).toBeInTheDocument();
+ });
+
+ expect(clearProgress).toHaveBeenCalled();
+ expect(api.post).toHaveBeenCalledWith("/api/auth/login/", {
+ username: "testuser",
+ password: "password123",
+ });
+ expect(localStorage.getItem("access")).toBe("access-token");
+ expect(localStorage.getItem("refresh")).toBe("refresh-token");
+ expect(api.get).toHaveBeenCalledWith("/api/auth/me/");
+ expect(fetchProgress).toHaveBeenCalled();
+ });
+
+ it("logout clears progress, clears localStorage, and removes user", async () => {
+ localStorage.setItem("access", "access-token");
+ localStorage.setItem("refresh", "refresh-token");
+
+ api.get.mockResolvedValue({
+ data: {
+ username: "testuser",
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("User: testuser")).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText("Logout"));
+
+ expect(clearProgress).toHaveBeenCalled();
+ expect(localStorage.getItem("access")).toBeNull();
+ expect(localStorage.getItem("refresh")).toBeNull();
+ expect(screen.getByText("User: none")).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/ProtectedRoute.test.js b/frontend/src/ProtectedRoute.test.js
new file mode 100644
index 0000000..109c11e
--- /dev/null
+++ b/frontend/src/ProtectedRoute.test.js
@@ -0,0 +1,77 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require("react");
+const { render, screen } = require("@testing-library/react");
+const { MemoryRouter, Routes, Route } = require("react-router-dom");
+require("@testing-library/jest-dom");
+
+jest.mock("./AuthContext", () => ({
+ useAuth: jest.fn(),
+}));
+
+const { useAuth } = jest.requireMock("./AuthContext");
+const { ProtectedRoute } = require("./ProtectedRoute");
+
+beforeEach(() => {
+ useAuth.mockReset();
+});
+
+describe("ProtectedRoute", () => {
+ it("shows loading when auth is loading", () => {
+ useAuth.mockReturnValue({ user: null, loading: true });
+
+ render(
+
+
+ Secret Page
+
+
+ );
+
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+ });
+
+ it("renders children when user is logged in", () => {
+ useAuth.mockReturnValue({
+ user: { username: "testuser" },
+ loading: false,
+ });
+
+ render(
+
+
+ Secret Page
+
+
+ );
+
+ expect(screen.getByText("Secret Page")).toBeInTheDocument();
+ });
+
+ it("redirects to login when user is not logged in", () => {
+ useAuth.mockReturnValue({ user: null, loading: false });
+
+ render(
+
+
+
+ Secret Page
+
+ }
+ />
+ Login Page} />
+
+
+ );
+
+ expect(screen.getByText("Login Page")).toBeInTheDocument();
+ expect(screen.queryByText("Secret Page")).not.toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/ThemeContext.test.js b/frontend/src/ThemeContext.test.js
new file mode 100644
index 0000000..9df5189
--- /dev/null
+++ b/frontend/src/ThemeContext.test.js
@@ -0,0 +1,111 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require("react");
+const { render, screen, fireEvent } = require("@testing-library/react");
+require("@testing-library/jest-dom");
+
+const { ThemeProvider, useTheme } = require("./ThemeContext");
+
+function ThemeConsumer() {
+ const { theme, setTheme, toggleTheme } = useTheme();
+
+ return (
+
+
Theme: {theme}
+
+
+
+
+ );
+}
+
+function BrokenConsumer() {
+ useTheme();
+ return Broken
;
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.removeAttribute("data-theme");
+});
+
+describe("ThemeContext", () => {
+ it("defaults to dark theme when localStorage has no theme", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Theme: dark")).toBeInTheDocument();
+ expect(localStorage.getItem("theme")).toBe("dark");
+ expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
+ });
+
+ it("loads theme from localStorage", () => {
+ localStorage.setItem("theme", "light");
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Theme: light")).toBeInTheDocument();
+ });
+
+ it("toggles from dark to light", () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("Toggle Theme"));
+
+ expect(screen.getByText("Theme: light")).toBeInTheDocument();
+ expect(localStorage.getItem("theme")).toBe("light");
+ expect(document.documentElement.getAttribute("data-theme")).toBe("light");
+ });
+
+ it("toggles from light to dark", () => {
+ localStorage.setItem("theme", "light");
+
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("Toggle Theme"));
+
+ expect(screen.getByText("Theme: dark")).toBeInTheDocument();
+ expect(localStorage.getItem("theme")).toBe("dark");
+ expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
+ });
+
+ it("allows setTheme to update the theme directly", () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("Set Light"));
+ expect(screen.getByText("Theme: light")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText("Set Dark"));
+ expect(screen.getByText("Theme: dark")).toBeInTheDocument();
+ });
+
+ it("throws when useTheme is used outside ThemeProvider", () => {
+ const spy = jest.spyOn(console, "error").mockImplementation(() => {});
+
+ expect(() => render()).toThrow(
+ "useTheme must be used inside ThemeProvider"
+ );
+
+ spy.mockRestore();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/api.test.js b/frontend/src/api.test.js
new file mode 100644
index 0000000..45c2dfe
--- /dev/null
+++ b/frontend/src/api.test.js
@@ -0,0 +1,92 @@
+/**
+ * @jest-environment jsdom
+ */
+require("@testing-library/jest-dom");
+
+const mockApi = jest.fn();
+
+mockApi.interceptors = {
+ request: {
+ use: jest.fn(),
+ },
+ response: {
+ use: jest.fn(),
+ },
+};
+
+jest.mock("axios", () => ({
+ create: jest.fn(() => mockApi),
+ post: jest.fn(),
+}));
+
+require("./api").default;
+
+const requestHandler = mockApi.interceptors.request.use.mock.calls[0][0];
+const responseSuccessHandler = mockApi.interceptors.response.use.mock.calls[0][0];
+const responseErrorHandler = mockApi.interceptors.response.use.mock.calls[0][1];
+
+beforeEach(() => {
+ localStorage.clear();
+ mockApi.mockClear();
+});
+
+describe("api axios instance", () => {
+ it("adds Authorization header when access token exists", () => {
+ localStorage.setItem("access", "abc123");
+
+ const config = {
+ headers: {},
+ };
+
+ const result = requestHandler(config);
+
+ expect(result.headers.Authorization).toBe("Bearer abc123");
+ });
+
+ it("does not add Authorization header when token does not exist", () => {
+ const config = {
+ headers: {},
+ };
+
+ const result = requestHandler(config);
+
+ expect(result.headers.Authorization).toBeUndefined();
+ });
+
+ it("returns successful responses directly", () => {
+ const response = {
+ data: {
+ ok: true,
+ },
+ };
+
+ expect(responseSuccessHandler(response)).toBe(response);
+ });
+
+ it("rejects non-401 errors", async () => {
+ const err = {
+ config: {
+ headers: {},
+ },
+ response: {
+ status: 500,
+ },
+ };
+
+ await expect(responseErrorHandler(err)).rejects.toBe(err);
+ });
+
+ it("rejects 401 errors that already retried", async () => {
+ const err = {
+ config: {
+ _retry: true,
+ headers: {},
+ },
+ response: {
+ status: 401,
+ },
+ };
+
+ await expect(responseErrorHandler(err)).rejects.toBe(err);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/checkLevelPassed.test.js b/frontend/src/components/checkLevelPassed.test.js
new file mode 100644
index 0000000..395d9d7
--- /dev/null
+++ b/frontend/src/components/checkLevelPassed.test.js
@@ -0,0 +1,177 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require('react');
+const { render, screen, act } = require('@testing-library/react');
+require('@testing-library/jest-dom');
+
+jest.mock('../progress', () => ({
+ loadProgress: jest.fn(),
+}));
+
+const { loadProgress } = jest.requireMock('../progress');
+const { ProgressProvider, useProgress } = require('./checkLevelPassed');
+const useCheckLevel = require('./checkLevelPassed').default;
+
+beforeEach(() => {
+ loadProgress.mockReset();
+ loadProgress.mockResolvedValue({});
+});
+
+// Reads context and passes it out via callback so tests can inspect it
+function ProgressConsumer({ onRender }) {
+ const ctx = useProgress();
+ onRender(ctx);
+ return null;
+}
+
+// Reads useCheckLevel result and passes it out via callback
+function LevelConsumer({ levelNum, onResult }) {
+ const passed = useCheckLevel(levelNum);
+ onResult(passed);
+ return null;
+}
+
+describe('ProgressProvider', () => {
+ it('renders children', () => {
+ render(
+
+ child content
+
+ );
+ expect(screen.getByText('child content')).toBeInTheDocument();
+ });
+
+ it('provides progress, loading, clearProgress, fetchProgress, and levelPassed', () => {
+ let ctx;
+ render(
+
+ { ctx = c; }} />
+
+ );
+ expect(ctx).toHaveProperty('progress');
+ expect(ctx).toHaveProperty('loading');
+ expect(ctx).toHaveProperty('clearProgress');
+ expect(ctx).toHaveProperty('fetchProgress');
+ expect(ctx).toHaveProperty('levelPassed');
+ });
+
+ it('progress starts as empty object', () => {
+ let ctx;
+ render(
+
+ { ctx = c; }} />
+
+ );
+ expect(ctx.progress).toEqual({});
+ });
+
+ it('levelPassed updates progress state for that level', () => {
+ let ctx;
+ render(
+
+ { ctx = c; }} />
+
+ );
+ act(() => { ctx.levelPassed(5); });
+ expect(ctx.progress).toEqual({ level_5: { passed: true } });
+ });
+
+ it('levelPassed preserves existing progress', () => {
+ let ctx;
+ render(
+
+ { ctx = c; }} />
+
+ );
+ act(() => { ctx.levelPassed(1); });
+ act(() => { ctx.levelPassed(2); });
+ expect(ctx.progress).toHaveProperty('level_1');
+ expect(ctx.progress).toHaveProperty('level_2');
+ });
+
+ it('clearProgress resets progress to empty', () => {
+ let ctx;
+ render(
+
+ { ctx = c; }} />
+
+ );
+ act(() => { ctx.levelPassed(1); });
+ act(() => { ctx.clearProgress(); });
+ expect(ctx.progress).toEqual({});
+ });
+
+ it('fetchProgress calls loadProgress', async () => {
+ loadProgress.mockResolvedValue({ level_3: { passed: true } });
+ let ctx;
+ render(
+
+ { ctx = c; }} />
+
+ );
+ await act(async () => { ctx.fetchProgress(); });
+ expect(loadProgress).toHaveBeenCalled();
+ });
+});
+
+describe('useProgress', () => {
+ it('throws when used outside ProgressProvider', () => {
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ expect(() => {
+ render( {}} />);
+ }).toThrow('useProgress must be used inside ');
+ spy.mockRestore();
+ });
+});
+
+describe('useCheckLevel', () => {
+ it('returns false when level has not been passed', () => {
+ let result;
+ render(
+
+ { result = r; }} />
+
+ );
+ expect(result).toBe(false);
+ });
+
+ it('returns true after levelPassed is called for that level', () => {
+ let ctx;
+ let result;
+ render(
+
+ { ctx = c; }} />
+ { result = r; }} />
+
+ );
+ act(() => { ctx.levelPassed(1); });
+ expect(result).toBe(true);
+ });
+
+ it('returns false for a different level than the one passed', () => {
+ let ctx;
+ let result;
+ render(
+
+ { ctx = c; }} />
+ { result = r; }} />
+
+ );
+ act(() => { ctx.levelPassed(1); });
+ expect(result).toBe(false);
+ });
+
+ it('defaults to level 0 when no argument is passed', () => {
+ let ctx;
+ let result;
+ render(
+
+ { ctx = c; }} />
+ { result = r; }} />
+
+ );
+ act(() => { ctx.levelPassed(0); });
+ expect(result).toBe(true);
+ });
+});
diff --git a/frontend/src/components/hint.test.js b/frontend/src/components/hint.test.js
new file mode 100644
index 0000000..67bfbd8
--- /dev/null
+++ b/frontend/src/components/hint.test.js
@@ -0,0 +1,78 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require('react');
+const { render, screen, fireEvent } = require('@testing-library/react');
+require('@testing-library/jest-dom');
+
+jest.mock('../ThemeContext', () => ({
+ useTheme: jest.fn(),
+}));
+
+const { useTheme } = jest.requireMock('../ThemeContext');
+const DropDown = require('./hint').default;
+
+beforeEach(() => {
+ useTheme.mockReset();
+ useTheme.mockReturnValue({ theme: 'dark' });
+});
+
+describe('DropDown', () => {
+ it('renders the title', () => {
+ render();
+ expect(screen.getByText('Hint')).toBeInTheDocument();
+ });
+
+ it('content wrapper has max-h-0 when closed by default', () => {
+ const { container } = render();
+ expect(container.querySelector('.max-h-0')).toBeInTheDocument();
+ });
+
+ it('content is in the DOM even when closed', () => {
+ render();
+ expect(screen.getByText('some hint text')).toBeInTheDocument();
+ });
+
+ it('content wrapper expands to max-h-96 when opened', () => {
+ const { container } = render();
+ fireEvent.click(screen.getByRole('button'));
+ expect(container.querySelector('.max-h-96')).toBeInTheDocument();
+ expect(container.querySelector('.max-h-0')).not.toBeInTheDocument();
+ });
+
+ it('collapses back to max-h-0 when clicked again', () => {
+ const { container } = render();
+ fireEvent.click(screen.getByRole('button'));
+ fireEvent.click(screen.getByRole('button'));
+ expect(container.querySelector('.max-h-0')).toBeInTheDocument();
+ expect(container.querySelector('.max-h-96')).not.toBeInTheDocument();
+ });
+
+ it('shows the down arrow indicator', () => {
+ render();
+ expect(screen.getByText('▼')).toBeInTheDocument();
+ });
+
+ it('rotates the arrow when opened', () => {
+ render();
+ const arrow = screen.getByText('▼');
+ fireEvent.click(screen.getByRole('button'));
+ expect(arrow.className).toContain('rotate-180');
+ });
+
+ it('renders correctly in light mode', () => {
+ useTheme.mockReturnValue({ theme: 'light' });
+ render();
+ expect(screen.getByText('Hint')).toBeInTheDocument();
+ });
+
+ it('applies moreClass to the wrapper', () => {
+ const { container } = render();
+ expect(container.firstChild.className).toContain('my-custom-class');
+ });
+
+ it('renders JSX contents', () => {
+ render(jsx content} />);
+ expect(screen.getByText('jsx content')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/login.test.js b/frontend/src/components/login.test.js
new file mode 100644
index 0000000..a80f785
--- /dev/null
+++ b/frontend/src/components/login.test.js
@@ -0,0 +1,83 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require('react');
+const { render, screen, fireEvent } = require('@testing-library/react');
+const { MemoryRouter } = require('react-router-dom');
+require('@testing-library/jest-dom');
+
+jest.mock('../AuthContext', () => ({
+ useAuth: jest.fn(),
+}));
+
+jest.mock('../ThemeContext', () => ({
+ useTheme: jest.fn(),
+}));
+
+const { useAuth } = jest.requireMock('../AuthContext');
+const { useTheme } = jest.requireMock('../ThemeContext');
+const Login = require('./login').default;
+
+beforeEach(() => {
+ useAuth.mockReset();
+ useTheme.mockReset();
+ useAuth.mockReturnValue({ user: null, logout: jest.fn() });
+ useTheme.mockReturnValue({ theme: 'dark', toggleTheme: jest.fn() });
+});
+
+describe('Login nav component', () => {
+ it('shows login link when no user is logged in', () => {
+ render();
+ expect(screen.getByText('Login')).toBeInTheDocument();
+ });
+
+ it('login link points to /login', () => {
+ render();
+ expect(screen.getByText('Login').closest('a')).toHaveAttribute('href', '/login');
+ });
+
+ it('shows username when user is logged in', () => {
+ useAuth.mockReturnValue({ user: { username: 'testuser' }, logout: jest.fn() });
+ render();
+ expect(screen.getByText('testuser')).toBeInTheDocument();
+ });
+
+ it('shows logout button when user is logged in', () => {
+ useAuth.mockReturnValue({ user: { username: 'testuser' }, logout: jest.fn() });
+ render();
+ expect(screen.getByText('Logout')).toBeInTheDocument();
+ });
+
+ it('does not show login link when user is logged in', () => {
+ useAuth.mockReturnValue({ user: { username: 'testuser' }, logout: jest.fn() });
+ render();
+ expect(screen.queryByText('Login')).not.toBeInTheDocument();
+ });
+
+ it('calls logout when logout button is clicked', () => {
+ const logout = jest.fn();
+ useAuth.mockReturnValue({ user: { username: 'testuser' }, logout });
+ render();
+ fireEvent.click(screen.getByText('Logout'));
+ expect(logout).toHaveBeenCalled();
+ });
+
+ it('shows Light Mode button in dark mode', () => {
+ render();
+ expect(screen.getByText('Light Mode')).toBeInTheDocument();
+ });
+
+ it('shows Dark Mode button in light mode', () => {
+ useTheme.mockReturnValue({ theme: 'light', toggleTheme: jest.fn() });
+ render();
+ expect(screen.getByText('Dark Mode')).toBeInTheDocument();
+ });
+
+ it('calls toggleTheme when theme button is clicked', () => {
+ const toggleTheme = jest.fn();
+ useTheme.mockReturnValue({ theme: 'dark', toggleTheme });
+ render();
+ fireEvent.click(screen.getByText('Light Mode'));
+ expect(toggleTheme).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/passedLevel.test.js b/frontend/src/components/passedLevel.test.js
new file mode 100644
index 0000000..c349910
--- /dev/null
+++ b/frontend/src/components/passedLevel.test.js
@@ -0,0 +1,86 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require('react');
+const { render, screen } = require('@testing-library/react');
+const { MemoryRouter } = require('react-router-dom');
+require('@testing-library/jest-dom');
+
+jest.mock('../ThemeContext', () => ({
+ useTheme: jest.fn(),
+}));
+
+const { useTheme } = jest.requireMock('../ThemeContext');
+const PassedLevel = require('./passedLevel').default;
+
+beforeEach(() => {
+ useTheme.mockReset();
+ useTheme.mockReturnValue({ theme: 'dark' });
+});
+
+describe('PassedLevel', () => {
+ it('renders the passed message', () => {
+ render();
+ expect(screen.getByText('You passed!')).toBeInTheDocument();
+ });
+
+ it('links to the next level', () => {
+ render();
+ expect(screen.getByText('Level 4').closest('a')).toHaveAttribute('href', '/levels/4');
+ });
+
+ it('links back to home', () => {
+ render();
+ expect(screen.getByText('Home').closest('a')).toHaveAttribute('href', '/');
+ });
+
+ it('uses the correct next level number', () => {
+ render();
+ expect(screen.getByText('Level 11').closest('a')).toHaveAttribute('href', '/levels/11');
+ });
+
+ it('defaults to level 0 when no levelNum is passed', () => {
+ render();
+ expect(screen.getByText('Level 1').closest('a')).toHaveAttribute('href', '/levels/1');
+ });
+
+ it('renders correctly in light mode', () => {
+ useTheme.mockReturnValue({ theme: 'light' });
+ render();
+ expect(screen.getByText('You passed!')).toBeInTheDocument();
+ });
+ it('displays current strokes and time from result prop', () => {
+ useTheme.mockReturnValue({ theme: 'dark' });
+ render(
+
+ );
+ expect(screen.getByText('5')).toBeInTheDocument();
+ expect(screen.getByText('00:30')).toBeInTheDocument();
+ });
+
+ it('displays best clear section when result has bestStrokes', () => {
+ useTheme.mockReturnValue({ theme: 'dark' });
+ render(
+
+ );
+ expect(screen.getByText('Best Clear')).toBeInTheDocument();
+ expect(screen.getByText('3')).toBeInTheDocument();
+ expect(screen.getByText('00:20')).toBeInTheDocument();
+ });
+
+ it('does not display best clear section when no best exists', () => {
+ useTheme.mockReturnValue({ theme: 'dark' });
+ render(
+
+ );
+ expect(screen.queryByText('Best Clear')).not.toBeInTheDocument();
+ });
+
+ it('renders stat section in light theme', () => {
+ useTheme.mockReturnValue({ theme: 'light' });
+ render(
+
+ );
+ expect(screen.getByText('Best Clear')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/sidebar.js b/frontend/src/components/sidebar.js
index 385d1d2..fbec50e 100644
--- a/frontend/src/components/sidebar.js
+++ b/frontend/src/components/sidebar.js
@@ -5,7 +5,6 @@ import ThemeToggle from "./themeToggle";
import { useAuth } from "../AuthContext";
import { useTheme } from "../ThemeContext";
-function callBackend() {}
function LevelCheck({ levelNum = 0, levelDesc = "", theme = "dark" }) {
const passed = useCheckLevel(levelNum);
@@ -242,28 +241,6 @@ export default function Sidebar() {
"ease-in-out"
].join(" ");
- const bottomClass =
- theme === "dark"
- ? [
- "absolute",
- "left-5",
- "bottom-5",
- "flex",
- "items-center",
- "gap-3",
- "text-2xl",
- "text-white"
- ].join(" ")
- : [
- "absolute",
- "left-5",
- "bottom-5",
- "flex",
- "items-center",
- "gap-3",
- "text-2xl",
- "text-slate-900"
- ].join(" ");
return (
@@ -408,4 +385,4 @@ export default function Sidebar() {
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/components/sidebar.test.js b/frontend/src/components/sidebar.test.js
new file mode 100644
index 0000000..1b69078
--- /dev/null
+++ b/frontend/src/components/sidebar.test.js
@@ -0,0 +1,218 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import Sidebar from "./sidebar";
+jest.mock("./themeToggle", () => () => );
+
+const mockNavigate = jest.fn();
+jest.mock("react-router-dom", () => ({
+ ...jest.requireActual("react-router-dom"),
+ useNavigate: () => mockNavigate,
+}));
+jest.mock("../AuthContext", () => ({
+ useAuth: jest.fn(() => ({ user: { username: "testuser" }, logout: jest.fn() })),
+}));
+jest.mock("../ThemeContext", () => ({
+ useTheme: jest.fn(() => ({ theme: "dark" })),
+}));
+jest.mock("../components/checkLevelPassed", () => ({
+ __esModule: true,
+ default: jest.fn(() => false),
+}));
+
+const { useAuth } = jest.requireMock("../AuthContext");
+const { useTheme } = jest.requireMock("../ThemeContext");
+const checkLevelMock = jest.requireMock("../components/checkLevelPassed");
+
+beforeEach(() => {
+ mockNavigate.mockReset();
+ useAuth.mockReturnValue({ user: { username: "testuser" }, logout: jest.fn() });
+ useTheme.mockReturnValue({ theme: "dark" });
+ checkLevelMock.default.mockReturnValue(false);
+});
+
+test("renders the sidebar title", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Navigation")).toBeInTheDocument();
+});
+
+test("renders the main sidebar sections", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Home")).toBeInTheDocument();
+ expect(screen.getByText("Levels")).toBeInTheDocument();
+ expect(screen.getByText("Normal Mode Basics")).toBeInTheDocument();
+ expect(screen.getByText("Insert Mode")).toBeInTheDocument();
+ expect(screen.getByText("Search & Navigation")).toBeInTheDocument();
+ expect(screen.getByText("Editing Commands")).toBeInTheDocument();
+ expect(screen.getByText("Advanced Tools")).toBeInTheDocument();
+ expect(screen.getByText("Challenges")).toBeInTheDocument();
+});
+
+test("renders level links", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Basic Navigation")).toBeInTheDocument();
+ expect(screen.getByText("How to exit a vim file")).toBeInTheDocument();
+ expect(screen.getByText("Insert Mode and typing")).toBeInTheDocument();
+ expect(screen.getByText("Basic Search")).toBeInTheDocument();
+ expect(screen.getByText("Delete a line")).toBeInTheDocument();
+ expect(screen.getByText("Find and replace")).toBeInTheDocument();
+ expect(screen.getByText("Challenge - Easy")).toBeInTheDocument();
+});
+
+test("level links go to the correct routes", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Basic Navigation").closest("a")).toHaveAttribute(
+ "href",
+ "/levels/1"
+ );
+
+ expect(screen.getByText("How to exit a vim file").closest("a")).toHaveAttribute(
+ "href",
+ "/levels/2"
+ );
+
+ expect(screen.getByText("Insert Mode and typing").closest("a")).toHaveAttribute(
+ "href",
+ "/levels/3"
+ );
+
+ expect(screen.getByText("Challenge - Easy").closest("a")).toHaveAttribute(
+ "href",
+ "/levels/5"
+ );
+});
+
+test("can collapse and reopen a sidebar section", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Basic Navigation")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText("Normal Mode Basics"));
+
+ expect(screen.queryByText("Basic Navigation")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByText("Normal Mode Basics"));
+
+ expect(screen.getByText("Basic Navigation")).toBeInTheDocument();
+});
+
+test("renders the logged in user and logout button", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("testuser")).toBeInTheDocument();
+ expect(screen.getByText("Logout")).toBeInTheDocument();
+});
+test("renders a passed level link in green in light theme", () => {
+ useTheme.mockReturnValue({ theme: "light" });
+ checkLevelMock.default.mockReturnValue(true);
+ render(
+
+
+
+ );
+ const link = screen.getByText("Basic Navigation").closest("a");
+ expect(link.className).toContain("text-green");
+});
+
+test("renders an unpassed level link in light theme", () => {
+ useTheme.mockReturnValue({ theme: "light" });
+ checkLevelMock.default.mockReturnValue(false);
+ render(
+
+
+
+ );
+ const link = screen.getByText("Basic Navigation").closest("a");
+ expect(link.className).toContain("text-slate-800");
+});
+
+test("can collapse all other sidebar sections", () => {
+ render(
+
+
+
+ );
+ ["Insert Mode", "Search & Navigation", "Editing Commands", "Advanced Tools", "Challenges"].forEach(section => {
+ fireEvent.click(screen.getByText(section));
+ });
+ expect(screen.queryByText("Insert Mode and typing")).not.toBeInTheDocument();
+ expect(screen.queryByText("Basic Search")).not.toBeInTheDocument();
+ expect(screen.queryByText("Delete a line")).not.toBeInTheDocument();
+ expect(screen.queryByText("Find and replace")).not.toBeInTheDocument();
+ expect(screen.queryByText("Challenge - Easy")).not.toBeInTheDocument();
+});
+test("renders a passed level link in green", () => {
+ checkLevelMock.default.mockReturnValue(true);
+ render(
+
+
+
+ );
+ const link = screen.getByText("Basic Navigation").closest("a");
+ expect(link.className).toContain("text-green");
+});
+
+test("renders correctly in light theme", () => {
+ useTheme.mockReturnValue({ theme: "light" });
+ render(
+
+
+
+ );
+ expect(screen.getByText("Navigation")).toBeInTheDocument();
+});
+
+test("renders Guest when no user is logged in", () => {
+ useAuth.mockReturnValue({ user: null, logout: jest.fn() });
+ render(
+
+
+
+ );
+ expect(screen.getByText("Guest")).toBeInTheDocument();
+});
+
+test("logout button calls navigate to /login", () => {
+ render(
+
+
+
+ );
+ fireEvent.click(screen.getByText("Logout"));
+ expect(mockNavigate).toHaveBeenCalledWith("/login");
+});
+test("renders the theme toggle in the sidebar", () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText("Theme Toggle")).toBeInTheDocument();
+});
diff --git a/frontend/src/components/themeToggle.test.js b/frontend/src/components/themeToggle.test.js
new file mode 100644
index 0000000..ab4f1d9
--- /dev/null
+++ b/frontend/src/components/themeToggle.test.js
@@ -0,0 +1,51 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require('react');
+const { render, screen, fireEvent } = require('@testing-library/react');
+require('@testing-library/jest-dom');
+
+jest.mock('../ThemeContext', () => ({
+ useTheme: jest.fn(),
+}));
+
+const { useTheme } = jest.requireMock('../ThemeContext');
+const ThemeToggle = require('./themeToggle').default;
+
+beforeEach(() => {
+ useTheme.mockReset();
+});
+
+describe('ThemeToggle', () => {
+ it('shows moon icon in dark mode', () => {
+ useTheme.mockReturnValue({ theme: 'dark', toggleTheme: jest.fn() });
+ render();
+ expect(screen.getByText('🌙')).toBeInTheDocument();
+ });
+
+ it('shows sun icon in light mode', () => {
+ useTheme.mockReturnValue({ theme: 'light', toggleTheme: jest.fn() });
+ render();
+ expect(screen.getByText('🌞')).toBeInTheDocument();
+ });
+
+ it('calls toggleTheme when clicked', () => {
+ const toggleTheme = jest.fn();
+ useTheme.mockReturnValue({ theme: 'dark', toggleTheme });
+ const { container } = render();
+ fireEvent.click(container.firstChild);
+ expect(toggleTheme).toHaveBeenCalled();
+ });
+
+ it('applies indigo background in dark mode', () => {
+ useTheme.mockReturnValue({ theme: 'dark', toggleTheme: jest.fn() });
+ const { container } = render();
+ expect(container.firstChild.className).toContain('bg-indigo-600');
+ });
+
+ it('applies gray background in light mode', () => {
+ useTheme.mockReturnValue({ theme: 'light', toggleTheme: jest.fn() });
+ const { container } = render();
+ expect(container.firstChild.className).toContain('bg-gray-300');
+ });
+});
diff --git a/frontend/src/editor/vimEditor.js b/frontend/src/editor/vimEditor.js
index 4e6ec78..4e494aa 100644
--- a/frontend/src/editor/vimEditor.js
+++ b/frontend/src/editor/vimEditor.js
@@ -345,7 +345,7 @@ export default function VimEditor({
//true just watching the statusNode with an eventListening, but it wasnt working
const observer = new MutationObserver(() => {
- const modeText = statusNode.innerText.toLowerCase();
+ const modeText = (statusNode.textContent || "").toLowerCase();
currentModeRef.current = modeText.includes("insert")
? "insert"
: modeText.includes("visual")
diff --git a/frontend/src/editor/vimEditor.test.js b/frontend/src/editor/vimEditor.test.js
new file mode 100644
index 0000000..62ee0a4
--- /dev/null
+++ b/frontend/src/editor/vimEditor.test.js
@@ -0,0 +1,515 @@
+/**
+ * @jest-environment jsdom
+ */
+
+const React = require('react');
+const { render, screen, fireEvent, waitFor, act } = require('@testing-library/react');
+require('@testing-library/jest-dom');
+
+// Mock out all external dependencies
+// Factories can't reference variables defined outside them here, so we set up
+// the real behavior in beforeEach using jest.requireMock instead
+
+jest.mock('monaco-vim', () => ({
+ __esModule: true,
+ initVimMode: jest.fn(),
+ VimMode: {
+ Vim: {
+ defineEx: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('@monaco-editor/react', () => {
+ const ReactInner = require('react');
+
+ // Named function so we can attach ._editor and ._handlers to it
+ // and read them back in tests via jest.requireMock
+ const MockEditor = function MockEditor({ onMount, defaultValue }) {
+ const hostRef = ReactInner.useRef(null);
+
+ ReactInner.useEffect(() => {
+ const handlers = {};
+ let value = defaultValue ?? '';
+
+ const editor = {
+ getValue: jest.fn(() => value),
+ setValue: jest.fn((v) => { value = v; }),
+ getPosition: jest.fn(() => ({ lineNumber: 1, column: 1 })),
+ getDomNode: jest.fn(() => hostRef.current),
+ addCommand: jest.fn(),
+ onDidChangeCursorSelection: jest.fn((cb) => { handlers.cursor = cb; }),
+ onDidChangeModelContent: jest.fn((cb) => { handlers.content = cb; }),
+ onKeyDown: jest.fn((cb) => { handlers.keydown = cb; }),
+ };
+
+ MockEditor._editor = editor;
+ MockEditor._handlers = handlers;
+
+ if (onMount) onMount(editor, { KeyCode: { UpArrow: 16 } });
+ }, []);
+
+ return (
+
+ {defaultValue}
+
+ );
+ };
+
+ return MockEditor;
+});
+
+
+// Mock Components
+jest.mock('../progress.js', () => ({
+ saveProgress: jest.fn(() => Promise.resolve()),
+ loadProgress: jest.fn(() => Promise.resolve({})),
+}));
+
+jest.mock('../ThemeContext.js', () => ({
+ useTheme: () => ({ theme: 'light' }),
+}));
+
+jest.mock('../components/checkLevelPassed.js', () => ({
+ useProgress: () => ({ levelPassed: jest.fn() }),
+}));
+
+// Grab references to the mocked modules
+const monacoVimMock = jest.requireMock('monaco-vim');
+const monacoEditorMock = jest.requireMock('@monaco-editor/react');
+const progressMock = jest.requireMock('../progress.js');
+
+const VimEditor = require('./vimEditor').default;
+
+// Shared objects that track vim key/command handlers across tests
+const mockVimHandlers = {};
+const mockExHandlers = {};
+
+const mockVimMode = {
+ on: jest.fn((event, cb) => {
+ mockVimHandlers[event] = cb;
+ }),
+};
+
+beforeEach(() => {
+ // Clear out handlers from the previous test
+ Object.keys(mockVimHandlers).forEach((k) => delete mockVimHandlers[k]);
+ Object.keys(mockExHandlers).forEach((k) => delete mockExHandlers[k]);
+
+ // Reset editor references
+ monacoEditorMock._editor = undefined;
+ monacoEditorMock._handlers = {};
+
+ // Make initVimMode return our stub so vimMode.on() works
+ monacoVimMock.initVimMode.mockReset();
+ monacoVimMock.initVimMode.mockReturnValue(mockVimMode);
+ mockVimMode.on.mockClear();
+ mockVimMode.on.mockImplementation((event, cb) => {
+ mockVimHandlers[event] = cb;
+ });
+
+ // Make defineEx store callbacks so we can trigger :commands in tests
+ monacoVimMock.VimMode.Vim.defineEx.mockReset();
+ monacoVimMock.VimMode.Vim.defineEx.mockImplementation((name, abbrev, cb) => {
+ mockExHandlers[`:${abbrev}`] = cb;
+ });
+
+ progressMock.saveProgress.mockResolvedValue(undefined);
+ progressMock.loadProgress.mockResolvedValue({});
+});
+
+// Renders the editor and returns helpers for triggering editor events
+function renderEditor(props = {}) {
+ const onWin = props.onWin ?? jest.fn();
+ render();
+
+ return {
+ onWin,
+
+ triggerContentChange() {
+ act(() => { monacoEditorMock._handlers?.content?.(); });
+ },
+
+ triggerCursor(line, col) {
+ monacoEditorMock._editor?.getPosition.mockReturnValue({ lineNumber: line, column: col });
+ act(() => {
+ monacoEditorMock._handlers?.cursor?.({
+ selection: { positionLineNumber: line, positionColumn: col },
+ });
+ });
+ },
+
+ triggerVimKey(key) {
+ act(() => { mockVimHandlers['vim-keypress']?.(key); });
+ },
+
+ triggerCommandDone() {
+ act(() => { mockVimHandlers['vim-command-done']?.(); });
+ },
+
+ triggerExCommand(cmd) {
+ act(() => { mockExHandlers[cmd]?.(); });
+ },
+
+ triggerKeydown(key) {
+ act(() => {
+ monacoEditorMock._handlers?.keydown?.({ browserEvent: { key } });
+ });
+ },
+ };
+}
+
+describe('VimEditor', () => {
+ it('renders the editor', () => {
+ render();
+ expect(screen.getByTestId('mock-editor')).toBeInTheDocument();
+ });
+
+ it('renders the reset button by default', () => {
+ render();
+ expect(
+ screen.getByRole('button', { name: /reset level/i })
+ ).toBeInTheDocument();
+ });
+
+ it('does not render reset button when showResetLevel is false', () => {
+ render();
+ expect(
+ screen.queryByRole('button', { name: /reset level/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it('calls reset when reset button is clicked', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /reset level/i }));
+ expect(monacoEditorMock._editor.setValue).toHaveBeenCalledWith('starting text');
+ });
+
+ it('allows winning again after a reset', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct' });
+ h.triggerContentChange();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(1));
+
+ fireEvent.click(screen.getByRole('button', { name: /reset level/i }));
+
+ h.triggerContentChange();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(2));
+ });
+
+ it('resets command tracking so the command needs to be used again after reset', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct', commands: [':w'] });
+ h.triggerExCommand(':w');
+ await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(1));
+
+ fireEvent.click(screen.getByRole('button', { name: /reset level/i }));
+
+ h.triggerContentChange();
+ expect(h.onWin).toHaveBeenCalledTimes(1);
+
+ h.triggerExCommand(':w');
+ await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(2));
+ });
+
+ it('calls onWin when finalText matches user input', async () => {
+ const h = renderEditor({
+ value: 'correct answer',
+ finalText: 'correct answer',
+ });
+
+ h.triggerContentChange();
+
+ await waitFor(() => {
+ expect(h.onWin).toHaveBeenCalled();
+ });
+ });
+
+ it('doesn\'t call onWin when finalText doesn\'t match', () => {
+ const h = renderEditor({ value: 'wrong answer', finalText: 'correct answer' });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('calls onWin when content contains target string', async () => {
+ const h = renderEditor({ value: 'hello world', finalTextContains: 'hello' });
+ h.triggerContentChange();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('doesnt call onWin when content doesnt contain target string', () => {
+ const h = renderEditor({ value: 'goodbye world', finalTextContains: 'hello' });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('calls onWin when content matches the regex', async () => {
+ const h = renderEditor({ value: 'hello world', finalTextRegex: /hello/ });
+ h.triggerContentChange();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin when content does not match regex', () => {
+ const h = renderEditor({ value: 'goodbye world', finalTextRegex: /hello/ });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('calls onWin when cursor reaches the required line and column', async () => {
+ const h = renderEditor({ cursorLine: 3, cursorCol: 5 });
+ h.triggerCursor(3, 5);
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin when cursor is on the wrong line', () => {
+ const h = renderEditor({ cursorLine: 3, cursorCol: 5 });
+ h.triggerCursor(1, 5);
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('does not call onWin when cursor is on the wrong column', () => {
+ const h = renderEditor({ cursorLine: 3, cursorCol: 5 });
+ h.triggerCursor(3, 1);
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('calls onWin when the required mode is normal (the default)', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct', mode: 'normal' });
+ h.triggerContentChange();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin when mode requirement is not yet met', () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct', mode: 'insert' });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('does not call onWin when canWin is false even if all conditions are met', () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct', canWin: false });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+ it('does not call onWin until the required command is used', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct', commands: [':w'] });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+
+ h.triggerExCommand(':w');
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin if none of the possibleCommands have been used', () => {
+ const h = renderEditor({
+ value: 'correct',
+ finalText: 'correct',
+ possibleCommands: [':w', ':wq'],
+ });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('calls onWin once any one of the possibleCommands is used', async () => {
+ const h = renderEditor({
+ value: 'correct',
+ finalText: 'correct',
+ possibleCommands: [':w', ':wq'],
+ });
+ h.triggerExCommand(':w');
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin until the required keystroke is pressed', async () => {
+ const keystrokes = ['j'];
+ const h = renderEditor({ value: 'correct', finalText: 'correct', keystrokes });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+
+ h.triggerKeydown('j');
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin when an unrelated key is pressed', () => {
+ const keystrokes = ['j'];
+ const h = renderEditor({ value: 'correct', finalText: 'correct', keystrokes });
+ h.triggerKeydown('k');
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+ it('does not call onWin when finalText does not match', () => {
+ const h = renderEditor({ value: 'wrong answer', finalText: 'correct answer' });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('calls onWin when content contains the target string', async () => {
+ const h = renderEditor({ value: 'hello world', finalTextContains: 'hello' });
+ h.triggerContentChange();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin when content does not contain the target string', () => {
+ const h = renderEditor({ value: 'goodbye world', finalTextContains: 'hello' });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('calls onWin when content matches the regex', async () => {
+ const h = renderEditor({ value: 'hello world', finalTextRegex: /hello/ });
+ h.triggerContentChange();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin when content does not match the regex', () => {
+ const h = renderEditor({ value: 'goodbye world', finalTextRegex: /hello/ });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('calls onWin when cursor reaches the required line and column', async () => {
+ const h = renderEditor({ cursorLine: 3, cursorCol: 5 });
+ h.triggerCursor(3, 5);
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin when cursor is on the wrong line', () => {
+ const h = renderEditor({ cursorLine: 3, cursorCol: 5 });
+ h.triggerCursor(1, 5);
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('does not call onWin when cursor is on the wrong column', () => {
+ const h = renderEditor({ cursorLine: 3, cursorCol: 5 });
+ h.triggerCursor(3, 1);
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('calls onWin when the required mode is normal (the default)', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct', mode: 'normal' });
+ h.triggerContentChange();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin when mode requirement is not yet met', () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct', mode: 'insert' });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('does not call onWin when canWin is false even if all conditions are met', () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct', canWin: false });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('canWin blocks win even when triggered by cursor move', () => {
+ const h = renderEditor({ cursorLine: 1, cursorCol: 1, canWin: false });
+ h.triggerCursor(1, 1);
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('does not call onWin until the required command is used', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct', commands: [':w'] });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+
+ h.triggerExCommand(':w');
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin if none of the possibleCommands have been used', () => {
+ const h = renderEditor({
+ value: 'correct',
+ finalText: 'correct',
+ possibleCommands: [':w', ':wq'],
+ });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('calls onWin once any one of the possibleCommands is used', async () => {
+ const h = renderEditor({
+ value: 'correct',
+ finalText: 'correct',
+ possibleCommands: [':w', ':wq'],
+ });
+ h.triggerExCommand(':w');
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin until the required keystroke is pressed', async () => {
+ const keystrokes = ['j'];
+ const h = renderEditor({ value: 'correct', finalText: 'correct', keystrokes });
+ h.triggerContentChange();
+ expect(h.onWin).not.toHaveBeenCalled();
+
+ h.triggerKeydown('j');
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('does not call onWin when an unrelated key is pressed', () => {
+ const keystrokes = ['j'];
+ const h = renderEditor({ value: 'correct', finalText: 'correct', keystrokes });
+ h.triggerKeydown('k');
+ expect(h.onWin).not.toHaveBeenCalled();
+ });
+
+ it('allows winning again after a reset', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct' });
+ h.triggerContentChange();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(1));
+
+ fireEvent.click(screen.getByRole('button', { name: /reset level/i }));
+
+ h.triggerContentChange();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(2));
+ });
+
+ it('resets command tracking so the command needs to be used again after reset', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct', commands: [':w'] });
+ h.triggerExCommand(':w');
+ await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(1));
+
+ fireEvent.click(screen.getByRole('button', { name: /reset level/i }));
+
+ h.triggerContentChange();
+ expect(h.onWin).toHaveBeenCalledTimes(1);
+
+ h.triggerExCommand(':w');
+ await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(2));
+ });
+
+ it('command-done after a colon command still checks win conditions', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct' });
+ h.triggerVimKey(':');
+ h.triggerCommandDone();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('clears colon flag on escape so command-done goes through normal branch', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct' });
+ h.triggerVimKey(':');
+ h.triggerVimKey('');
+ h.triggerCommandDone();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+
+ it('increments stroke count on command done without throwing', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct' });
+ h.triggerCommandDone();
+ await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(1));
+ h.triggerCommandDone(); // wonRef is now true, hits the early return on line 174
+ await new Promise((r) => setTimeout(r, 50));
+ expect(h.onWin).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates mode to insert when status bar changes', async () => {
+ const h = renderEditor({ value: 'correct', finalText: 'correct', mode: 'insert' });
+ const editorDiv = screen.getByTestId('mock-editor');
+ const statusNode = editorDiv.children[0]; // children[] skips text nodes, firstChild did not
+
+ await act(async () => {
+ statusNode.textContent = '-- INSERT --';
+ await new Promise((r) => setTimeout(r, 0));
+ });
+
+ await waitFor(() => expect(h.onWin).toHaveBeenCalled());
+ });
+});
diff --git a/frontend/src/pages/Home.test.js b/frontend/src/pages/Home.test.js
new file mode 100644
index 0000000..d8a6043
--- /dev/null
+++ b/frontend/src/pages/Home.test.js
@@ -0,0 +1,280 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require("react");
+const { render, screen, fireEvent } = require("@testing-library/react");
+const { MemoryRouter } = require("react-router-dom");
+require("@testing-library/jest-dom");
+
+jest.mock("../components/login", () => () => Mock Login
);
+
+jest.mock("../editor/vimEditor", () => () => (
+ Mock Vim Editor
+));
+
+jest.mock("../ThemeContext", () => ({
+ useTheme: jest.fn(),
+}));
+
+jest.mock("../components/checkLevelPassed", () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+const { useTheme } = jest.requireMock("../ThemeContext");
+const useCheckLevel = jest.requireMock("../components/checkLevelPassed").default;
+const Home = require("./Home").default;
+
+let consoleErrorSpy;
+
+beforeEach(() => {
+ useTheme.mockReset();
+ useCheckLevel.mockReset();
+
+ useTheme.mockReturnValue({ theme: "dark" });
+ useCheckLevel.mockReturnValue(false);
+
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ consoleErrorSpy.mockRestore();
+});
+
+describe("Home page", () => {
+ it("renders the home title", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Arch-Vim")).toBeInTheDocument();
+ });
+
+ it("renders the subtitle", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Learn Vim, One step at a time")).toBeInTheDocument();
+ });
+
+ it("renders the mocked login component", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Mock Login")).toBeInTheDocument();
+ });
+
+ it("shows the welcome section by default", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("What is Arch-Vim?")).toBeInTheDocument();
+ expect(screen.getByText("What is VIM?")).toBeInTheDocument();
+ expect(screen.getByText("Getting Started")).toBeInTheDocument();
+ });
+
+ it("renders getting started level links", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Learn Navigation").closest("a")).toHaveAttribute("href", "/levels/1");
+ expect(screen.getByText("How to exit a vim file").closest("a")).toHaveAttribute("href", "/levels/2");
+ expect(screen.getByText("Insert Mode and typing").closest("a")).toHaveAttribute("href", "/levels/3");
+ expect(screen.getByText("How to save files").closest("a")).toHaveAttribute("href", "/levels/4");
+ expect(screen.getByText("Challenge!").closest("a")).toHaveAttribute("href", "/levels/5");
+ });
+
+ it("switches to levels section when Levels button is clicked", () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("Levels"));
+
+ expect(screen.getByText("Normal Mode Basics")).toBeInTheDocument();
+ expect(screen.getByText("Insert Mode")).toBeInTheDocument();
+ expect(screen.getByText("Search & Navigation")).toBeInTheDocument();
+ expect(screen.getByText("Editing Commands")).toBeInTheDocument();
+ expect(screen.getByText("Advanced Tools")).toBeInTheDocument();
+ expect(screen.getByText("Challenges")).toBeInTheDocument();
+ });
+
+ it("renders level links in the levels section", () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("Levels"));
+
+ expect(screen.getByText("Basic Navigation").closest("a")).toHaveAttribute("href", "/levels/1");
+ expect(screen.getByText("Delete a line").closest("a")).toHaveAttribute("href", "/levels/8");
+ expect(screen.getByText("Basic Search").closest("a")).toHaveAttribute("href", "/levels/11");
+ expect(screen.getByText("Find and replace").closest("a")).toHaveAttribute("href", "/levels/24");
+ expect(screen.getByText("Challenge - Expert").closest("a")).toHaveAttribute("href", "/levels/27");
+ });
+
+ it("switches to FAQ section when FAQ button is clicked", () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("FAQ"));
+
+ expect(screen.getByText("ATTENTION: Found a swap file...")).toBeInTheDocument();
+ expect(screen.getByText("Mock Vim Editor")).toBeInTheDocument();
+ });
+
+ it("switches from FAQ back to Welcome", () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("FAQ"));
+ expect(screen.getByText("ATTENTION: Found a swap file...")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText("Welcome"));
+ expect(screen.getByText("What is Arch-Vim?")).toBeInTheDocument();
+ });
+
+ it("switches from Levels back to Welcome", () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("Levels"));
+ expect(screen.getByText("Normal Mode Basics")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText("Welcome"));
+ expect(screen.getByText("What is Arch-Vim?")).toBeInTheDocument();
+ });
+
+ it("uses light theme classes when theme is light", () => {
+ useTheme.mockReturnValue({ theme: "light" });
+
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.firstChild.className).toContain("bg-slate-50");
+ });
+
+ it("uses dark theme classes when theme is dark", () => {
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.firstChild.className).toContain("bg-gray-950");
+ });
+
+ it("uses passed level styling when a level is passed", () => {
+ useCheckLevel.mockReturnValue(true);
+
+ render(
+
+
+
+ );
+
+ const link = screen.getByText("Learn Navigation").closest("a");
+ expect(link.className).toContain("text-green");
+ });
+
+ it("uses default level styling when a level is not passed", () => {
+ useCheckLevel.mockReturnValue(false);
+
+ render(
+
+
+
+ );
+
+ const link = screen.getByText("Learn Navigation").closest("a");
+ expect(link.className).toContain("text-gray-100");
+ });
+
+ it("uses default light theme level styling when not passed", () => {
+ useTheme.mockReturnValue({ theme: "light" });
+ useCheckLevel.mockReturnValue(false);
+
+ render(
+
+
+
+ );
+
+ const link = screen.getByText("Learn Navigation").closest("a");
+ expect(link.className).toContain("text-slate-700");
+ });
+
+ it("uses passed light theme level styling when passed", () => {
+ useTheme.mockReturnValue({ theme: "light" });
+ useCheckLevel.mockReturnValue(true);
+
+ render(
+
+
+
+ );
+
+ const link = screen.getByText("Learn Navigation").closest("a");
+ expect(link.className).toContain("text-green-600");
+ });
+
+ it("updates title and chevron styles when home page scrolls", () => {
+ Object.defineProperty(window, "innerHeight", {
+ writable: true,
+ configurable: true,
+ value: 1000,
+ });
+
+ const { container } = render(
+
+
+
+ );
+
+ const scrollContainer = container.firstChild;
+ const titleCard = screen.getByText("Arch-Vim").closest("div");
+ const chevron = container.querySelector("svg").closest("div");
+
+ Object.defineProperty(scrollContainer, "scrollTop", {
+ writable: true,
+ configurable: true,
+ value: 300,
+ });
+
+ fireEvent.scroll(scrollContainer);
+
+ expect(titleCard.style.opacity).not.toBe("");
+ expect(chevron.style.opacity).not.toBe("");
+ expect(chevron.style.transform).toContain("rotate");
+ });
+});
diff --git a/frontend/src/pages/Levels.test.js b/frontend/src/pages/Levels.test.js
new file mode 100644
index 0000000..65f2a88
--- /dev/null
+++ b/frontend/src/pages/Levels.test.js
@@ -0,0 +1,64 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require("react");
+const { render, screen } = require("@testing-library/react");
+const { MemoryRouter } = require("react-router-dom");
+require("@testing-library/jest-dom");
+
+const Levels = require("./Levels").default;
+
+describe("Levels page", () => {
+ it("renders the Levels heading", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Levels")).toBeInTheDocument();
+ });
+
+ it("renders all level links", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Level 1")).toBeInTheDocument();
+ expect(screen.getByText("Level 2")).toBeInTheDocument();
+ expect(screen.getByText("Level 3")).toBeInTheDocument();
+ expect(screen.getByText("Level 4")).toBeInTheDocument();
+ expect(screen.getByText("Challenge!")).toBeInTheDocument();
+ expect(screen.getByText("Test Level")).toBeInTheDocument();
+ });
+
+ it("level links point to the correct routes", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Level 1").closest("a")).toHaveAttribute("href", "/levels/1");
+ expect(screen.getByText("Level 2").closest("a")).toHaveAttribute("href", "/levels/2");
+ expect(screen.getByText("Level 3").closest("a")).toHaveAttribute("href", "/levels/3");
+ expect(screen.getByText("Level 4").closest("a")).toHaveAttribute("href", "/levels/4");
+ expect(screen.getByText("Challenge!").closest("a")).toHaveAttribute("href", "/levels/5");
+ expect(screen.getByText("Test Level").closest("a")).toHaveAttribute("href", "/levels/test");
+ });
+
+ it("renders the level descriptions", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText(/Learn Navigation/i)).toBeInTheDocument();
+ expect(screen.getByText(/How to exit a vim file/i)).toBeInTheDocument();
+ expect(screen.getByText(/Insert Mode and typing/i)).toBeInTheDocument();
+ expect(screen.getByText(/How to save your changes/i)).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/Login.test.js b/frontend/src/pages/Login.test.js
new file mode 100644
index 0000000..97d0ba3
--- /dev/null
+++ b/frontend/src/pages/Login.test.js
@@ -0,0 +1,172 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require("react");
+const { render, screen, fireEvent, waitFor } = require("@testing-library/react");
+const { MemoryRouter } = require("react-router-dom");
+require("@testing-library/jest-dom");
+
+jest.mock("../AuthContext.js", () => ({
+ useAuth: jest.fn(),
+}));
+
+jest.mock("../ThemeContext", () => ({
+ useTheme: jest.fn(),
+}));
+
+jest.mock("../components/themeToggle", () => () => (
+
+));
+
+const mockNavigate = jest.fn();
+
+jest.mock("react-router-dom", () => ({
+ ...jest.requireActual("react-router-dom"),
+ useNavigate: () => mockNavigate,
+}));
+
+const { useAuth } = jest.requireMock("../AuthContext.js");
+const { useTheme } = jest.requireMock("../ThemeContext");
+const Login = require("./Login").default;
+
+beforeEach(() => {
+ mockNavigate.mockReset();
+ useAuth.mockReset();
+ useTheme.mockReset();
+
+ useAuth.mockReturnValue({
+ login: jest.fn(),
+ });
+
+ useTheme.mockReturnValue({
+ theme: "dark",
+ });
+});
+
+describe("Login page", () => {
+ it("renders the login page", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole("heading", { name: "Login" })).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Username")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Password")).toBeInTheDocument();
+ expect(screen.getByText("Theme Toggle")).toBeInTheDocument();
+ });
+
+ it("renders the sign up link", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Sign Up Here").closest("a")).toHaveAttribute("href", "/register");
+ });
+
+ it("updates the username and password fields", () => {
+ render(
+
+
+
+ );
+
+ fireEvent.change(screen.getByPlaceholderText("Username"), {
+ target: { value: "testuser" },
+ });
+
+ fireEvent.change(screen.getByPlaceholderText("Password"), {
+ target: { value: "password123" },
+ });
+
+ expect(screen.getByPlaceholderText("Username")).toHaveValue("testuser");
+ expect(screen.getByPlaceholderText("Password")).toHaveValue("password123");
+ });
+
+ it("calls login and navigates home on successful submit", async () => {
+ const login = jest.fn().mockResolvedValue({});
+ useAuth.mockReturnValue({ login });
+
+ render(
+
+
+
+ );
+
+ fireEvent.change(screen.getByPlaceholderText("Username"), {
+ target: { value: "testuser" },
+ });
+
+ fireEvent.change(screen.getByPlaceholderText("Password"), {
+ target: { value: "password123" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Login" }));
+
+ await waitFor(() => {
+ expect(login).toHaveBeenCalledWith("testuser", "password123");
+ expect(mockNavigate).toHaveBeenCalledWith("/");
+ });
+ });
+
+ it("shows an error message when login fails", async () => {
+ const login = jest.fn().mockRejectedValue(new Error("bad login"));
+ useAuth.mockReturnValue({ login });
+
+ render(
+
+
+
+ );
+
+ fireEvent.change(screen.getByPlaceholderText("Username"), {
+ target: { value: "wronguser" },
+ });
+
+ fireEvent.change(screen.getByPlaceholderText("Password"), {
+ target: { value: "wrongpass" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Login" }));
+
+ expect(await screen.findByText("Invalid username or password")).toBeInTheDocument();
+ });
+
+ it("uses dark theme page styling", () => {
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.firstChild.className).toContain("bg-gray-950");
+ });
+
+ it("uses light theme page styling", () => {
+ useTheme.mockReturnValue({ theme: "light" });
+
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.firstChild.className).toContain("bg-slate-50");
+ });
+
+ it("uses light theme input and link styling", () => {
+ useTheme.mockReturnValue({ theme: "light" });
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByPlaceholderText("Username").className).toContain("bg-white");
+ expect(screen.getByText("Sign Up Here").className).toContain("text-indigo-600");
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/Register.test.js b/frontend/src/pages/Register.test.js
new file mode 100644
index 0000000..8c50ccc
--- /dev/null
+++ b/frontend/src/pages/Register.test.js
@@ -0,0 +1,238 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require("react");
+const { render, screen, fireEvent, waitFor } = require("@testing-library/react");
+const { MemoryRouter } = require("react-router-dom");
+require("@testing-library/jest-dom");
+
+jest.mock("axios", () => ({
+ post: jest.fn(),
+}));
+
+jest.mock("../ThemeContext", () => ({
+ useTheme: jest.fn(),
+}));
+
+jest.mock("../components/themeToggle", () => () => (
+
+));
+
+const mockNavigate = jest.fn();
+
+jest.mock("react-router-dom", () => ({
+ ...jest.requireActual("react-router-dom"),
+ useNavigate: () => mockNavigate,
+}));
+
+const axios = require("axios");
+const { useTheme } = jest.requireMock("../ThemeContext");
+const Register = require("./Register").default;
+
+let consoleErrorSpy;
+
+beforeEach(() => {
+ mockNavigate.mockReset();
+ axios.post.mockReset();
+ useTheme.mockReset();
+
+ useTheme.mockReturnValue({
+ theme: "dark",
+ });
+
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ consoleErrorSpy.mockRestore();
+});
+
+describe("Register page", () => {
+ it("renders the register page", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole("heading", { name: "Register" })).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Username")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Email (optional)")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Password")).toBeInTheDocument();
+ expect(screen.getByText("Theme Toggle")).toBeInTheDocument();
+ });
+
+ it("renders the login link", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Login").closest("a")).toHaveAttribute("href", "/login");
+ });
+
+ it("updates username, email, and password fields", () => {
+ render(
+
+
+
+ );
+
+ fireEvent.change(screen.getByPlaceholderText("Username"), {
+ target: { value: "testuser" },
+ });
+
+ fireEvent.change(screen.getByPlaceholderText("Email (optional)"), {
+ target: { value: "test@example.com" },
+ });
+
+ fireEvent.change(screen.getByPlaceholderText("Password"), {
+ target: { value: "password123" },
+ });
+
+ expect(screen.getByPlaceholderText("Username")).toHaveValue("testuser");
+ expect(screen.getByPlaceholderText("Email (optional)")).toHaveValue("test@example.com");
+ expect(screen.getByPlaceholderText("Password")).toHaveValue("password123");
+ });
+
+ it("posts register data and navigates to login on success", async () => {
+ axios.post.mockResolvedValue({ data: { ok: true } });
+
+ render(
+
+
+
+ );
+
+ fireEvent.change(screen.getByPlaceholderText("Username"), {
+ target: { value: "testuser" },
+ });
+
+ fireEvent.change(screen.getByPlaceholderText("Email (optional)"), {
+ target: { value: "test@example.com" },
+ });
+
+ fireEvent.change(screen.getByPlaceholderText("Password"), {
+ target: { value: "password123" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Register" }));
+
+ await waitFor(() => {
+ expect(axios.post).toHaveBeenCalledWith("http://localhost:8000/api/auth/register/", {
+ username: "testuser",
+ email: "test@example.com",
+ password: "password123",
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith("/login");
+ });
+ });
+
+ it("shows backend error message when register fails with response error", async () => {
+ axios.post.mockRejectedValue({
+ response: {
+ status: 400,
+ data: {
+ error: "Username already exists",
+ },
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ fireEvent.change(screen.getByPlaceholderText("Username"), {
+ target: { value: "testuser" },
+ });
+
+ fireEvent.change(screen.getByPlaceholderText("Password"), {
+ target: { value: "password123" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Register" }));
+
+ expect(await screen.findByText("Username already exists")).toBeInTheDocument();
+ });
+
+ it("shows default error message when register fails without response error", async () => {
+ axios.post.mockRejectedValue(new Error("Network Error"));
+
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Register" }));
+
+ expect(await screen.findByText("Registration failed")).toBeInTheDocument();
+ });
+
+ it("clears old error before submitting again", async () => {
+ axios.post
+ .mockRejectedValueOnce({
+ response: {
+ data: {
+ error: "Username already exists",
+ },
+ },
+ })
+ .mockResolvedValueOnce({ data: { ok: true } });
+
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Register" }));
+
+ expect(await screen.findByText("Username already exists")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "Register" }));
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith("/login");
+ });
+ });
+
+ it("uses dark theme page styling", () => {
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.firstChild.className).toContain("bg-gray-950");
+ });
+
+ it("uses light theme page styling", () => {
+ useTheme.mockReturnValue({ theme: "light" });
+
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.firstChild.className).toContain("bg-slate-50");
+ });
+
+ it("uses light theme input and link styling", () => {
+ useTheme.mockReturnValue({ theme: "light" });
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByPlaceholderText("Username").className).toContain("bg-white");
+ expect(screen.getByText("Login").className).toContain("text-indigo-600");
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/levels/levels.test.js b/frontend/src/pages/levels/levels.test.js
new file mode 100644
index 0000000..de21b8d
--- /dev/null
+++ b/frontend/src/pages/levels/levels.test.js
@@ -0,0 +1,110 @@
+/**
+ * @jest-environment jsdom
+ */
+const React = require('react');
+const { render, screen } = require('@testing-library/react');
+const { MemoryRouter } = require('react-router-dom');
+const { act } = require('@testing-library/react');
+require('@testing-library/jest-dom');
+
+// Mock all heavy dependencies so we're just testing the level page structure
+jest.mock('../../components/sidebar', () => () => );
+jest.mock('../../components/hint', () => () => );
+jest.mock('../../components/passedLevel', () => () => );
+jest.mock('../../components/checkLevelPassed', () => ({
+ __esModule: true,
+ default: jest.fn(() => false),
+}));
+jest.mock('../../ThemeContext', () => ({
+ useTheme: jest.fn(() => ({ theme: 'dark' })),
+}));
+const mockOnWin = jest.fn();
+jest.mock('../../editor/vimEditor', () => (props) => {
+ if (props.onWin) mockOnWin.mockImplementation(props.onWin);
+ return ;
+});
+beforeEach(() => {
+ const themeContext = jest.requireMock('../../ThemeContext');
+ themeContext.useTheme.mockReturnValue({ theme: 'dark' });
+ const checkLevel = jest.requireMock('../../components/checkLevelPassed');
+ checkLevel.default.mockReturnValue(false);
+});
+jest.mock('../../components/checkLevelPassed', () => ({
+ __esModule: true,
+ default: jest.fn(() => false),
+ useProgress: jest.fn(() => ({ progress: {} })),
+}));
+beforeEach(() => {
+ const checkLevel = jest.requireMock('../../components/checkLevelPassed');
+ checkLevel.default.mockReturnValue(false);
+ checkLevel.useProgress.mockReturnValue({ progress: {} });
+
+ const themeContext = jest.requireMock('../../ThemeContext');
+ themeContext.useTheme.mockReturnValue({ theme: 'dark' });
+});
+describe.each(
+ Array.from({ length: 27 }, (_, i) => [i + 1])
+)('Level %i passed state', (num) => {
+ it('renders PassedLevel when level is already passed', () => {
+ const checkLevel = jest.requireMock('../../components/checkLevelPassed');
+ checkLevel.default.mockReturnValue(true);
+ const Level = require(`./Level${num}`).default;
+ render();
+ expect(screen.getByTestId('passed-level')).toBeInTheDocument();
+ checkLevel.default.mockReturnValue(false);
+ });
+ it('calls onWin and shows PassedLevel', () => {
+ const checkLevel = jest.requireMock('../../components/checkLevelPassed');
+ checkLevel.default.mockReturnValue(false);
+ const Level = require(`./Level${num}`).default;
+ render();
+ act(() => { mockOnWin(); });
+ expect(screen.getByTestId('passed-level')).toBeInTheDocument();
+ });
+ it('renders without crashing', () => {
+ const Level = require(`./Level${num}`).default;
+ render();
+ });
+
+ it('shows the level number', () => {
+ const Level = require(`./Level${num}`).default;
+ render();
+ expect(screen.getByText(`Level ${num}`)).toBeInTheDocument();
+ });
+
+ it('renders the sidebar and vim editor', () => {
+ const Level = require(`./Level${num}`).default;
+ render();
+ expect(screen.getByTestId('sidebar')).toBeInTheDocument();
+ expect(screen.getByTestId('vim-editor')).toBeInTheDocument();
+ });
+ it('renders correctly in light theme', () => {
+ const themeContext = jest.requireMock('../../ThemeContext');
+ themeContext.useTheme.mockReturnValue({ theme: 'light' });
+ const Level = require(`./Level${num}`).default;
+ render();
+ expect(screen.getByText(`Level ${num}`)).toBeInTheDocument();
+ themeContext.useTheme.mockReturnValue({ theme: 'dark' });
+ });
+});
+
+// LevelTest is a dev sandbox — no sidebar, different structure
+describe('LevelTest', () => {
+ it('renders without crashing', () => {
+ const LevelTest = require('./levelTest').default;
+ render();
+ });
+
+ it('renders the vim editor', () => {
+ const LevelTest = require('./levelTest').default;
+ render();
+ expect(screen.getByTestId('vim-editor')).toBeInTheDocument();
+ });
+
+ it('shows win message when onWin is called', () => {
+ const LevelTest = require('./levelTest').default;
+ render();
+ act(() => { mockOnWin(); });
+ expect(screen.getByText('winner winner chicken dinner')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/progress.test.js b/frontend/src/progress.test.js
new file mode 100644
index 0000000..dc13291
--- /dev/null
+++ b/frontend/src/progress.test.js
@@ -0,0 +1,71 @@
+/**
+ * @jest-environment jsdom
+ */
+require("@testing-library/jest-dom");
+
+jest.mock("./api.js", () => ({
+ get: jest.fn(),
+ post: jest.fn(),
+}));
+
+const api = require("./api.js");
+const { loadProgress, saveProgress } = require("./progress");
+
+let consoleErrorSpy;
+
+beforeEach(() => {
+ api.get.mockReset();
+ api.post.mockReset();
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ consoleErrorSpy.mockRestore();
+});
+
+describe("progress helpers", () => {
+ it("loadProgress returns progress data when api call succeeds", async () => {
+ api.get.mockResolvedValue({
+ data: {
+ level_1: { passed: true },
+ },
+ });
+
+ const result = await loadProgress();
+
+ expect(api.get).toHaveBeenCalledWith("/api/progress/");
+ expect(result).toEqual({
+ level_1: { passed: true },
+ });
+ });
+
+ it("loadProgress returns empty object when api call fails", async () => {
+ api.get.mockRejectedValue(new Error("Network Error"));
+
+ const result = await loadProgress();
+
+ expect(api.get).toHaveBeenCalledWith("/api/progress/");
+ expect(result).toEqual({});
+ });
+
+ it("saveProgress posts progress data", async () => {
+ api.post.mockResolvedValue({ data: { ok: true } });
+
+ const data = {
+ level_2: { passed: true },
+ };
+
+ await saveProgress(data);
+
+ expect(api.post).toHaveBeenCalledWith("/api/progress/save/", data);
+ });
+
+ it("saveProgress catches errors and logs them", async () => {
+ const err = new Error("Save failed");
+ api.post.mockRejectedValue(err);
+
+ await saveProgress({ level_3: { passed: true } });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save progress", err);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/utils/session.test.js b/frontend/src/utils/session.test.js
new file mode 100644
index 0000000..92b593c
--- /dev/null
+++ b/frontend/src/utils/session.test.js
@@ -0,0 +1,58 @@
+/**
+ * @jest-environment jsdom
+ */
+require("@testing-library/jest-dom");
+
+const { write, read } = require("./session");
+
+beforeEach(() => {
+ document.cookie.split(";").forEach((cookie) => {
+ const name = cookie.split("=")[0].trim();
+ if (name) {
+ document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
+ }
+ });
+});
+
+describe("session utils", () => {
+ it("writes data to the app_state cookie", () => {
+ write({ username: "testuser", level: 3 });
+
+ expect(document.cookie).toContain("app_state=");
+ });
+
+ it("reads data from the app_state cookie", () => {
+ const data = {
+ username: "testuser",
+ level: 3,
+ };
+
+ write(data);
+
+ expect(read()).toEqual(data);
+ });
+
+ it("returns null when app_state cookie does not exist", () => {
+ expect(read()).toBeNull();
+ });
+
+ it("returns null when app_state cookie has invalid JSON", () => {
+ document.cookie = "app_state=not-valid-json; path=/; SameSite=Lax";
+
+ expect(read()).toBeNull();
+ });
+
+ it("supports custom expiration days", () => {
+ write({ theme: "dark" }, 1);
+
+ expect(document.cookie).toContain("app_state=");
+ expect(read()).toEqual({ theme: "dark" });
+ });
+
+ it("overwrites existing app_state cookie", () => {
+ write({ level: 1 });
+ write({ level: 2 });
+
+ expect(read()).toEqual({ level: 2 });
+ });
+});
\ No newline at end of file