diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cf781e2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [ main, claude/** ] + pull_request: + branches: [ main ] + +jobs: + test: + name: Test and Build + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'yarn' + + - name: Install dependencies + run: make install + + - name: Run tests with coverage + run: make test-coverage + + - name: Build + run: make build + + - name: Upload coverage to Codecov + if: matrix.node-version == '20.x' + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: false + files: ./coverage/lcov.info + continue-on-error: true + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: 'yarn' + + - name: Install dependencies + run: make install + + - name: Check formatting with Prettier + run: make lint diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..264d266 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +.PHONY: help install test test-coverage build lint lint-fix clean ci start + +# Default target +help: + @echo "Available targets:" + @echo " make install - Install dependencies" + @echo " make test - Run tests" + @echo " make test-coverage - Run tests with coverage" + @echo " make build - Build production bundle" + @echo " make lint - Check code formatting" + @echo " make lint-fix - Fix code formatting" + @echo " make clean - Clean build artifacts" + @echo " make ci - Run all CI checks (test + build + lint)" + @echo " make start - Start development server" + +# Install dependencies +install: + yarn install --frozen-lockfile + +# Run tests without coverage +test: + yarn test --watchAll=false + +# Run tests with coverage +test-coverage: + yarn test --watchAll=false --coverage + +# Build production bundle +build: + yarn build + +# Check code formatting with Prettier +lint: + yarn prettier --check "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}" + +# Fix code formatting with Prettier +lint-fix: + yarn prettier --write "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}" + +# Clean build artifacts +clean: + rm -rf build + rm -rf coverage + rm -rf node_modules + +# Run all CI checks +ci: test-coverage build lint + +# Start development server +start: + yarn start diff --git a/src/App.test.tsx b/src/App.test.tsx index d76787e..6b503eb 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,9 +1,132 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import App from "./App"; -test("renders learn react link", () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); +describe("App", () => { + test("renders MathJojo heading", () => { + render(); + const heading = screen.getByText(/MathJojo/i); + expect(heading).toBeInTheDocument(); + }); + + test("renders with default LaTeX value", () => { + render(); + const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; + expect(textarea.value).toBe( + "\\zeta(s) = \\sum_{n=1}^\\infty \\frac{1}{n^s}" + ); + }); + + test("updates textarea value on user input", () => { + render(); + const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; + + fireEvent.change(textarea, { target: { value: "x^2 + y^2 = z^2" } }); + + expect(textarea.value).toBe("x^2 + y^2 = z^2"); + }); + + test("shows cheatsheet toggle", () => { + render(); + const toggleLink = screen.getByText(/Hide Cheatsheet/i); + expect(toggleLink).toBeInTheDocument(); + }); + + test("shows settings toggle", () => { + render(); + const toggleLink = screen.getByText(/Show Settings/i); + expect(toggleLink).toBeInTheDocument(); + }); + + test("cheatsheet can be toggled", () => { + render(); + + // Initially shows "Hide Cheatsheet" + let toggleLink = screen.getByText(/Hide Cheatsheet/i); + expect(toggleLink).toBeInTheDocument(); + + // Click to hide + fireEvent.click(toggleLink); + + // Now shows "Show Cheatsheet" + toggleLink = screen.getByText(/Show Cheatsheet/i); + expect(toggleLink).toBeInTheDocument(); + + // Click to show again + fireEvent.click(toggleLink); + + // Back to "Hide Cheatsheet" + toggleLink = screen.getByText(/Hide Cheatsheet/i); + expect(toggleLink).toBeInTheDocument(); + }); + + test("settings can be toggled", () => { + render(); + + // Initially shows "Show Settings" + let toggleLink = screen.getByText(/Show Settings/i); + expect(toggleLink).toBeInTheDocument(); + + // Click to show + fireEvent.click(toggleLink); + + // Now shows "Hide Settings" + toggleLink = screen.getByText(/Hide Settings/i); + expect(toggleLink).toBeInTheDocument(); + }); +}); + +describe("URL parameter handling", () => { + const originalLocation = window.location; + + beforeEach(() => { + // Mock window.location + delete (window as any).location; + window.location = { ...originalLocation, search: "" } as any; + }); + + afterEach(() => { + window.location = originalLocation; + }); + + test("loads value from URL parameter", () => { + // Set up URL with compressed value + window.location.search = "?v=NobAgB"; + + render(); + const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; + + // The compressed value should be decompressed + expect(textarea.value).toBeTruthy(); + }); + + test("handles empty compressed value", () => { + window.location.search = "?v=Q"; + + render(); + const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; + + // Should render empty value when v=Q + expect(textarea.value).toBe(""); + }); + + test("loads displayMode from URL parameter", () => { + window.location.search = "?displayMode=0"; + + render(); + + // Open settings to check display mode + const toggleLink = screen.getByText(/Show Settings/i); + fireEvent.click(toggleLink); + + // The "No" option for display mode should be bold (active) + const displayModeOptions = screen.getAllByText(/^(Yes|No)$/); + const noOption = displayModeOptions.find( + (el) => + el.textContent === "No" && + el.getAttribute("href") === "#disable-displaymode" + ); + + expect(noOption).toHaveStyle({ fontWeight: "bold" }); + }); }); diff --git a/src/cheatsheet.test.tsx b/src/cheatsheet.test.tsx new file mode 100644 index 0000000..3f453be --- /dev/null +++ b/src/cheatsheet.test.tsx @@ -0,0 +1,182 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import CheatSheet from "./cheatsheet"; + +// Mock the QuickInsert component +jest.mock("./quickinsert", () => { + return function QuickInsert(props: any) { + return ( + { + e.preventDefault(); + props.onClick(); + }} + data-testid={`quick-insert-${props.source}`} + > + {props.source} + + ); + }; +}); + +describe("CheatSheet", () => { + const defaultProps = { + displayCheatSheet: false, + insertSource: jest.fn(), + toggleCheatSheet: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("when cheatsheet is hidden", () => { + test("shows 'Show Cheatsheet' link", () => { + render(); + const link = screen.getByText(/Show Cheatsheet/i); + expect(link).toBeInTheDocument(); + }); + + test("calls toggleCheatSheet when clicked", () => { + render(); + const link = screen.getByText(/Show Cheatsheet/i); + + fireEvent.click(link); + + expect(defaultProps.toggleCheatSheet).toHaveBeenCalledTimes(1); + }); + + test("does not show greek letters", () => { + render(); + const greekLettersText = screen.queryByText(/Greek Letters/i); + expect(greekLettersText).not.toBeInTheDocument(); + }); + }); + + describe("when cheatsheet is shown", () => { + const visibleProps = { ...defaultProps, displayCheatSheet: true }; + + test("shows 'Hide Cheatsheet' link", () => { + render(); + const link = screen.getByText(/Hide Cheatsheet/i); + expect(link).toBeInTheDocument(); + }); + + test("calls toggleCheatSheet when Hide Cheatsheet is clicked", () => { + render(); + const link = screen.getByText(/Hide Cheatsheet/i); + + fireEvent.click(link); + + expect(defaultProps.toggleCheatSheet).toHaveBeenCalledTimes(1); + }); + + test("shows Greek Letters section", () => { + render(); + const greekLettersText = screen.getByText(/Greek Letters/i); + expect(greekLettersText).toBeInTheDocument(); + }); + + test("shows Symbols section", () => { + render(); + const symbolsText = screen.getByText(/Symbols/i); + expect(symbolsText).toBeInTheDocument(); + }); + + test("shows Accents section", () => { + render(); + const accentsText = screen.getByText(/Accents/i); + expect(accentsText).toBeInTheDocument(); + }); + + test("shows Layout / Common section", () => { + render(); + const layoutText = screen.getByText(/Layout \/ Common/i); + expect(layoutText).toBeInTheDocument(); + }); + + test("renders greek letter quick inserts", () => { + render(); + + const alphaInsert = screen.getByTestId("quick-insert-\\alpha"); + expect(alphaInsert).toBeInTheDocument(); + + const betaInsert = screen.getByTestId("quick-insert-\\beta"); + expect(betaInsert).toBeInTheDocument(); + }); + + test("calls insertSource when a greek letter is clicked", () => { + render(); + + const alphaInsert = screen.getByTestId("quick-insert-\\alpha"); + fireEvent.click(alphaInsert); + + expect(defaultProps.insertSource).toHaveBeenCalledWith("\\alpha"); + }); + + test("renders symbol quick inserts", () => { + render(); + + const infinityInsert = screen.getByTestId("quick-insert-\\infty"); + expect(infinityInsert).toBeInTheDocument(); + }); + + test("calls insertSource when a symbol is clicked", () => { + render(); + + const infinityInsert = screen.getByTestId("quick-insert-\\infty"); + fireEvent.click(infinityInsert); + + expect(defaultProps.insertSource).toHaveBeenCalledWith("\\infty"); + }); + + test("renders accent quick inserts", () => { + render(); + + const hatInsert = screen.getByTestId("quick-insert-\\hat{x}"); + expect(hatInsert).toBeInTheDocument(); + }); + + test("calls insertSource when an accent is clicked", () => { + render(); + + const hatInsert = screen.getByTestId("quick-insert-\\hat{x}"); + fireEvent.click(hatInsert); + + expect(defaultProps.insertSource).toHaveBeenCalledWith("\\hat{x}"); + }); + + test("renders layout/common quick inserts", () => { + render(); + + const fracInsert = screen.getByTestId("quick-insert-\\frac{a}{b}"); + expect(fracInsert).toBeInTheDocument(); + }); + + test("calls insertSource when a layout item is clicked", () => { + render(); + + const fracInsert = screen.getByTestId("quick-insert-\\frac{a}{b}"); + fireEvent.click(fracInsert); + + expect(defaultProps.insertSource).toHaveBeenCalledWith("\\frac{a}{b}"); + }); + + test("renders uppercase greek letters", () => { + render(); + + const gammaInsert = screen.getByTestId("quick-insert-\\Gamma"); + expect(gammaInsert).toBeInTheDocument(); + }); + + test("calls insertSource when an uppercase greek letter is clicked", () => { + render(); + + const gammaInsert = screen.getByTestId("quick-insert-\\Gamma"); + fireEvent.click(gammaInsert); + + expect(defaultProps.insertSource).toHaveBeenCalledWith("\\Gamma"); + }); + }); +}); diff --git a/src/katex.test.tsx b/src/katex.test.tsx new file mode 100644 index 0000000..326212a --- /dev/null +++ b/src/katex.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import Katex from "./katex"; +import katex from "katex"; + +// Mock katex +jest.mock("katex", () => ({ + render: jest.fn(), +})); + +describe("Katex", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders span element", () => { + const { container } = render(); + const span = container.querySelector("span"); + expect(span).toBeInTheDocument(); + }); + + test("calls katex.render on mount", () => { + render(); + + expect(katex.render).toHaveBeenCalledTimes(1); + expect(katex.render).toHaveBeenCalledWith( + "x^2 + y^2 = z^2", + expect.any(HTMLElement), + expect.objectContaining({ + throwOnError: false, + }) + ); + }); + + test("calls katex.render when source changes", () => { + const { rerender } = render(); + + expect(katex.render).toHaveBeenCalledTimes(1); + + rerender(); + + expect(katex.render).toHaveBeenCalledTimes(2); + expect(katex.render).toHaveBeenLastCalledWith( + "y^2", + expect.any(HTMLElement), + expect.objectContaining({ + throwOnError: false, + }) + ); + }); + + test("does not re-render when source is unchanged", () => { + const { rerender } = render(); + + expect(katex.render).toHaveBeenCalledTimes(1); + + rerender(); + + expect(katex.render).toHaveBeenCalledTimes(1); + }); + + test("passes katexOptions to katex.render", () => { + const source = "\\frac{a}{b}"; + render( + + ); + + expect(katex.render).toHaveBeenCalledWith( + source, + expect.any(HTMLElement), + expect.objectContaining({ + throwOnError: false, + displayMode: true, + fleqn: true, + }) + ); + }); + + test("re-renders when katexOptions change", () => { + const { rerender } = render( + + ); + + expect(katex.render).toHaveBeenCalledTimes(1); + + rerender(); + + expect(katex.render).toHaveBeenCalledTimes(2); + }); + + test("calls beforeRender callback if provided", () => { + const beforeRender = jest.fn(); + + render(); + + expect(beforeRender).toHaveBeenCalledTimes(1); + }); + + test("calls beforeRender before katex.render", () => { + const callOrder: string[] = []; + + const beforeRender = jest.fn(() => { + callOrder.push("beforeRender"); + }); + + (katex.render as jest.Mock).mockImplementation(() => { + callOrder.push("katex.render"); + }); + + render(); + + expect(callOrder).toEqual(["beforeRender", "katex.render"]); + }); +}); diff --git a/src/quickinsert.test.tsx b/src/quickinsert.test.tsx new file mode 100644 index 0000000..fdeb1b5 --- /dev/null +++ b/src/quickinsert.test.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import QuickInsert from "./quickinsert"; + +// Mock the Katex component +jest.mock("./katex", () => { + return function Katex(props: any) { + return {props.source}; + }; +}); + +describe("QuickInsert", () => { + const mockOnClick = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders a link", () => { + render(); + + const link = screen.getByRole("link"); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "#insert"); + }); + + test("renders Katex component with source", () => { + render(); + + const katex = screen.getByTestId("katex-mock"); + expect(katex).toBeInTheDocument(); + expect(katex).toHaveTextContent("\\alpha"); + }); + + test("has title attribute with source", () => { + const source = "\\beta"; + render(); + + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("title", source); + }); + + test("calls onClick when clicked", () => { + render(); + + const link = screen.getByRole("link"); + fireEvent.click(link); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + test("prevents default on click", () => { + render(); + + const link = screen.getByRole("link"); + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + + Object.defineProperty(event, "preventDefault", { + value: jest.fn(), + writable: true, + }); + + link.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + + test("works with complex LaTeX source", () => { + const complexSource = "\\frac{a}{b}"; + render(); + + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("title", complexSource); + + const katex = screen.getByTestId("katex-mock"); + expect(katex).toHaveTextContent(complexSource); + }); + + test("can be clicked multiple times", () => { + render(); + + const link = screen.getByRole("link"); + + fireEvent.click(link); + fireEvent.click(link); + fireEvent.click(link); + + expect(mockOnClick).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/settings.test.tsx b/src/settings.test.tsx new file mode 100644 index 0000000..2147d06 --- /dev/null +++ b/src/settings.test.tsx @@ -0,0 +1,194 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import Settings from "./settings"; + +describe("Settings", () => { + const defaultProps = { + displaySettings: false, + toggleSettings: jest.fn(), + displayMode: true, + setDisplayMode: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + document.body.className = ""; + }); + + describe("when settings are hidden", () => { + test("shows 'Show Settings' link", () => { + render(); + const link = screen.getByText(/Show Settings/i); + expect(link).toBeInTheDocument(); + }); + + test("calls toggleSettings when clicked", () => { + render(); + const link = screen.getByText(/Show Settings/i); + + fireEvent.click(link); + + expect(defaultProps.toggleSettings).toHaveBeenCalledTimes(1); + }); + + test("does not show settings options", () => { + render(); + const displayModeText = screen.queryByText(/Display mode:/i); + expect(displayModeText).not.toBeInTheDocument(); + }); + }); + + describe("when settings are shown", () => { + const visibleProps = { ...defaultProps, displaySettings: true }; + + test("shows 'Hide Settings' link", () => { + render(); + const link = screen.getByText(/Hide Settings/i); + expect(link).toBeInTheDocument(); + }); + + test("calls toggleSettings when Hide Settings is clicked", () => { + render(); + const link = screen.getByText(/Hide Settings/i); + + fireEvent.click(link); + + expect(defaultProps.toggleSettings).toHaveBeenCalledTimes(1); + }); + + test("shows display mode options", () => { + render(); + const displayModeText = screen.getByText(/Display mode:/i); + expect(displayModeText).toBeInTheDocument(); + }); + + test("shows dark mode options", () => { + render(); + const darkModeText = screen.getByText(/Dark mode:/i); + expect(darkModeText).toBeInTheDocument(); + }); + + test("shows GitHub link", () => { + render(); + const githubLink = screen.getByText(/GitHub/i); + expect(githubLink).toBeInTheDocument(); + expect(githubLink).toHaveAttribute( + "href", + "https://github.com/dashed/mathjojo" + ); + }); + + test("shows Supported TeX functions link", () => { + render(); + const texLink = screen.getByText(/Supported TeX functions/i); + expect(texLink).toBeInTheDocument(); + expect(texLink).toHaveAttribute( + "href", + "https://katex.org/docs/supported.html" + ); + }); + }); + + describe("display mode", () => { + const visibleProps = { ...defaultProps, displaySettings: true }; + + test("highlights 'Yes' when displayMode is true", () => { + render(); + + // Find the Yes link for display mode (not dark mode) + const displayModeYes = Array.from( + screen.getAllByRole("link", { name: /Yes/i }) + ).find((el) => el.getAttribute("href") === "#enable-displaymode"); + + expect(displayModeYes).toHaveStyle({ fontWeight: "bold" }); + }); + + test("highlights 'No' when displayMode is false", () => { + render(); + + const displayModeNo = Array.from( + screen.getAllByRole("link", { name: /No/i }) + ).find((el) => el.getAttribute("href") === "#disable-displaymode"); + + expect(displayModeNo).toHaveStyle({ fontWeight: "bold" }); + }); + + test("calls setDisplayMode(true) when Yes is clicked", () => { + render(); + + const displayModeYes = Array.from( + screen.getAllByRole("link", { name: /Yes/i }) + ).find((el) => el.getAttribute("href") === "#enable-displaymode"); + + fireEvent.click(displayModeYes!); + + expect(defaultProps.setDisplayMode).toHaveBeenCalledWith(true); + }); + + test("calls setDisplayMode(false) when No is clicked", () => { + render(); + + const displayModeNo = Array.from( + screen.getAllByRole("link", { name: /No/i }) + ).find((el) => el.getAttribute("href") === "#disable-displaymode"); + + fireEvent.click(displayModeNo!); + + expect(defaultProps.setDisplayMode).toHaveBeenCalledWith(false); + }); + }); + + describe("dark mode", () => { + const visibleProps = { ...defaultProps, displaySettings: true }; + + test("initializes dark mode from localStorage", () => { + localStorage.setItem("useDarkMode", "1"); + + render(); + + expect(document.body.classList.contains("latex-dark")).toBe(true); + }); + + test("adds latex-dark class when dark mode is enabled", () => { + render(); + + const darkModeYes = Array.from( + screen.getAllByRole("link", { name: /Yes/i }) + ).find((el) => el.getAttribute("href") === "#enable-darkmode"); + + fireEvent.click(darkModeYes!); + + expect(document.body.classList.contains("latex-dark")).toBe(true); + expect(localStorage.getItem("useDarkMode")).toBe("1"); + }); + + test("removes latex-dark class when dark mode is disabled", () => { + document.body.classList.add("latex-dark"); + localStorage.setItem("useDarkMode", "1"); + + render(); + + const darkModeNo = Array.from( + screen.getAllByRole("link", { name: /No/i }) + ).find((el) => el.getAttribute("href") === "#disable-darkmode"); + + fireEvent.click(darkModeNo!); + + expect(document.body.classList.contains("latex-dark")).toBe(false); + expect(localStorage.getItem("useDarkMode")).toBe("0"); + }); + + test("persists dark mode preference to localStorage", () => { + render(); + + const darkModeYes = Array.from( + screen.getAllByRole("link", { name: /Yes/i }) + ).find((el) => el.getAttribute("href") === "#enable-darkmode"); + + fireEvent.click(darkModeYes!); + + expect(localStorage.getItem("useDarkMode")).toBe("1"); + }); + }); +});