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