From 82376562c6ba7b175280c2c91bcfb266a02c9848 Mon Sep 17 00:00:00 2001 From: LunaUrsa <1836049+LunaUrsa@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:44:17 -0600 Subject: [PATCH 1/2] Expand test coverage and enforce branch coverage in CI --- .github/workflows/PullRequestOpenAll.yml | 2 +- jest.config.ts | 8 + src/components/ProfileButton.tsx | 8 +- src/components/__tests__/App.test.tsx | 44 ++ src/components/__tests__/AppealForm.test.tsx | 57 +++ src/components/__tests__/AppealPage.test.tsx | 429 ++++++++++++++++++ src/components/__tests__/DrugInfo.test.tsx | 270 +++++++++++ src/components/__tests__/Factsheets.test.tsx | 66 +++ src/components/__tests__/Header.test.tsx | 51 +++ src/components/__tests__/HomeEffects.test.tsx | 93 ++++ src/components/__tests__/HomePage.test.tsx | 65 +++ .../__tests__/ProfileButton.test.tsx | 68 +++ src/components/__tests__/WebchatPage.test.tsx | 26 ++ .../__tests__/ComboChecker.test.tsx | 70 +++ src/utils/__tests__/env.config.test.ts | 26 ++ src/utils/__tests__/keycloak.test.ts | 16 +- 16 files changed, 1291 insertions(+), 8 deletions(-) create mode 100644 src/components/__tests__/App.test.tsx create mode 100644 src/components/__tests__/AppealForm.test.tsx create mode 100644 src/components/__tests__/AppealPage.test.tsx create mode 100644 src/components/__tests__/DrugInfo.test.tsx create mode 100644 src/components/__tests__/Factsheets.test.tsx create mode 100644 src/components/__tests__/Header.test.tsx create mode 100644 src/components/__tests__/HomeEffects.test.tsx create mode 100644 src/components/__tests__/HomePage.test.tsx create mode 100644 src/components/__tests__/ProfileButton.test.tsx create mode 100644 src/components/__tests__/WebchatPage.test.tsx create mode 100644 src/components/comboChecker/__tests__/ComboChecker.test.tsx create mode 100644 src/utils/__tests__/env.config.test.ts diff --git a/.github/workflows/PullRequestOpenAll.yml b/.github/workflows/PullRequestOpenAll.yml index 1b83238..635b3a2 100644 --- a/.github/workflows/PullRequestOpenAll.yml +++ b/.github/workflows/PullRequestOpenAll.yml @@ -64,7 +64,7 @@ jobs: - name: Copy env run: cp .env.example .env - name: Test - run: npm test + run: npm run test:coverage codeql: name: CodeQL runs-on: ubuntu-latest diff --git a/jest.config.ts b/jest.config.ts index fbd080b..ce5f600 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -9,6 +9,14 @@ const createJestConfig = nextJest({ const config: Config = { testEnvironment: "jsdom", + collectCoverageFrom: [ + "src/**/*.{ts,tsx}", + "!src/**/*.d.ts", + "!src/**/__tests__/**", + ], + coverageThreshold: { + global: { branches: 90 }, + }, setupFilesAfterEnv: ["/jest.setup.ts"], testPathIgnorePatterns: ["/.next/", "/node_modules/"], modulePathIgnorePatterns: ["/.next/"], diff --git a/src/components/ProfileButton.tsx b/src/components/ProfileButton.tsx index bdaabcc..328529c 100644 --- a/src/components/ProfileButton.tsx +++ b/src/components/ProfileButton.tsx @@ -72,11 +72,7 @@ const ProfileButton: React.FC = () => { } }; - if (isLoggedIn) { - fetchAvatar(); - } else { - setAvatarUrl(GUEST_AVATAR_PATH); - } + if (isLoggedIn) fetchAvatar(); }, [isLoggedIn]); // Close dropdown when clicking outside @@ -138,7 +134,7 @@ const ProfileButton: React.FC = () => { aria-label={isLoggedIn ? "Account menu" : "Login"} style={{ border: "1px solid var(--color-line)", - background: "var(--color-surface)", + backgroundColor: "var(--color-surface)", ...(showAvatar && { backgroundImage: `url(${avatarUrl})` }), }} > diff --git a/src/components/__tests__/App.test.tsx b/src/components/__tests__/App.test.tsx new file mode 100644 index 0000000..7bba13a --- /dev/null +++ b/src/components/__tests__/App.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import type { AppProps } from "next/app"; +import App from "@/pages/_app"; +import { useDrugs } from "@/utils/drugs"; + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; +}); + +function FactsheetNames() { + const { data } = useDrugs(); + return

{data?.map((drug) => drug.pretty_name).join(", ")}

; +} + +test("reuses the downloaded drug database across page changes", async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + example: { name: "example", pretty_name: "Example compound" }, + }), + }); + global.fetch = fetchMock; + const first = { + Component: FactsheetNames, + pageProps: {}, + router: {} as AppProps["router"], + }; + const second = { + ...first, + Component: () => ( +
+ +
+ ), + }; + const { rerender } = render(); + expect(await screen.findByText("Example compound")).toBeVisible(); + + rerender(); + expect(screen.getByRole("main")).toHaveTextContent("Example compound"); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/__tests__/AppealForm.test.tsx b/src/components/__tests__/AppealForm.test.tsx new file mode 100644 index 0000000..3569376 --- /dev/null +++ b/src/components/__tests__/AppealForm.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import AppealForm from "../AppealForm"; + +test("rejects blank answers and submits all completed fields", () => { + const onSubmit = jest.fn(); + render(); + const submit = screen.getByRole("button", { name: "Submit Appeal" }); + const reason = screen.getByRole("textbox", { name: /why you were banned/i }); + fireEvent.change(reason, { target: { value: " " } }); + const form = submit.closest("form"); + if (!form) throw new Error("Expected an appeal form"); + fireEvent.submit(form); + expect(onSubmit).not.toHaveBeenCalled(); + + fireEvent.change(reason, { target: { value: "I broke a rule." } }); + fireEvent.change(screen.getByRole("textbox", { name: /make amends/i }), { + target: { value: "I apologized." }, + }); + fireEvent.change(screen.getByRole("textbox", { name: /avoid repeating/i }), { + target: { value: "I will review the rules." }, + }); + fireEvent.change(screen.getByRole("textbox", { name: /anything else/i }), { + target: { value: "Thank you." }, + }); + fireEvent.click(submit); + expect(onSubmit).toHaveBeenCalledWith({ + reason: "I broke a rule.", + solution: "I apologized.", + future: "I will review the rules.", + extra: "Thank you.", + }); +}); + +test("warns near the shared limit and blocks submissions over it", () => { + const onSubmit = jest.fn(); + const { rerender } = render( + , + ); + const [reason, solution, future, extra] = screen.getAllByRole("textbox"); + for (const field of [reason, solution, future]) { + fireEvent.change(field, { target: { value: "x" } }); + } + fireEvent.change(extra, { target: { value: "x".repeat(3597) } }); + expect(screen.getByText(/3600\/3600.*getting close/i)).toBeVisible(); + const submit = screen.getByRole("button", { name: "Submit Appeal" }); + expect(submit).toBeEnabled(); + fireEvent.change(extra, { target: { value: "x".repeat(3598) } }); + expect(screen.getByText(/3601\/3600.*too long/i)).toBeVisible(); + expect(submit).toBeDisabled(); + const form = submit.closest("form"); + if (!form) throw new Error("Expected the submit button to be inside a form"); + fireEvent.submit(form); + expect(onSubmit).not.toHaveBeenCalled(); + + rerender(); + expect(screen.getByRole("button", { name: /submitting/i })).toBeDisabled(); +}); diff --git a/src/components/__tests__/AppealPage.test.tsx b/src/components/__tests__/AppealPage.test.tsx new file mode 100644 index 0000000..81a4fd7 --- /dev/null +++ b/src/components/__tests__/AppealPage.test.tsx @@ -0,0 +1,429 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import AppealPage from "@/pages/appeal"; + +test.each([ + ["server", "An error occurred while submitting your appeal"], + ["network", "Error submitting appeal."], + ["expired", "Session expired."], + ["empty", "Failed to submit appeal"], +])("shows a dismissible submission error for %s", async (failure, message) => { + sessionStorage.setItem("kc_token", validToken()); + fetchMock + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce(reply({}, false)); + render(); + const submit = await screen.findByRole("button", { name: "Submit Appeal" }); + for (const label of [ + /why you were banned/i, + /make amends/i, + /avoid repeating/i, + ]) { + fireEvent.change(screen.getByRole("textbox", { name: label }), { + target: { value: "Answer" }, + }); + } + if (failure === "expired") sessionStorage.clear(); + else if (failure === "network") + fetchMock.mockRejectedValueOnce(new Error("Offline")); + else if (failure === "server") + fetchMock.mockResolvedValueOnce( + reply({ error: "Failed to create appeal due to a server error" }, false), + ); + else fetchMock.mockResolvedValueOnce(reply({}, false)); + fireEvent.click(submit); + expect(await screen.findByText(message, { exact: false })).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByText(message, { exact: false })).toBeNull(); +}); + +test("renders appeal status when optional details are absent", async () => { + sessionStorage.setItem("kc_token", validToken()); + fetchMock + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce(reply({ id: "pending" })); + render(); + expect(await screen.findByText("Under Review")).toBeVisible(); + expect(screen.getByText(/unknown/i)).toBeVisible(); + expect( + screen.queryByRole("button", { name: "View Your Appeal Content" }), + ).toBeNull(); +}); + +test("keeps the session when checking ban status fails", async () => { + const access = validToken(); + sessionStorage.setItem("kc_token", access); + window.history.replaceState({}, "", "/appeal?code=stale"); + fetchMock.mockResolvedValueOnce(reply({}, false)); + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(window.location.search).toBe(""); + expect(sessionStorage.getItem("kc_token")).toBe(access); +}); + +test.each(["DENIED", "RECEIVED"])( + "applies the shorter development waiting period to %s", + async (status) => { + const environment = process.env as Record; + const original = environment.NODE_ENV; + environment.NODE_ENV = "development"; + try { + sessionStorage.setItem("kc_token", validToken()); + fetchMock + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce( + reply({ + id: "appeal", + status, + decided_at: daysAgo(1), + created_at: + status === "RECEIVED" + ? new Date(Date.now() - 60000).toISOString() + : undefined, + }), + ); + render(); + if (status === "DENIED") { + expect( + await screen.findByRole("heading", { name: "Submit New Ban Appeal" }), + ).toBeVisible(); + expect(screen.getByText("Unknown")).toBeVisible(); + } else { + expect( + await screen.findByRole("button", { name: "Send Reminder" }), + ).toBeEnabled(); + } + } finally { + environment.NODE_ENV = original; + } + }, +); + +jest.mock("@/components/Header", () => () => null); +jest.mock("@/components/Footer", () => () => null); +jest.mock("@/utils/keycloak", () => ({ + getLoginUrl: () => "https://auth.example.test/login", +})); + +const fetchMock = jest.fn(); +const originalFetch = global.fetch; +const token = (exp: number) => + `header.${btoa(JSON.stringify({ exp }))}.signature`; +const validToken = () => token(Date.now() / 1000 + 3600); +const daysAgo = (days: number) => + new Date(Date.now() - days * 86400000).toISOString(); +const reply = (body: object, ok = true) => ({ ok, json: async () => body }); +const appeal = (status: string, age: number) => ({ + id: "appeal-1", + status, + created_at: daysAgo(age + 1), + decided_at: daysAgo(age), + response_message: "Moderator response", + reason: "My reason", + solution: "My solution", + future: "My plan", + extra: "Extra context", +}); + +beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock; +}); + +afterEach(() => { + jest.useRealTimers(); + global.fetch = originalFetch; + sessionStorage.clear(); + window.history.replaceState({}, "", "/"); +}); + +test.each(["missing token", "network error"])( + "returns to login when the code exchange fails with %s", + async (failure) => { + window.history.replaceState({}, "", "/appeal?code=invalid"); + if (failure === "network error") + fetchMock.mockRejectedValueOnce(new Error("Offline")); + else fetchMock.mockResolvedValueOnce(reply({}, false)); + render(); + expect( + await screen.findByRole("link", { name: "Login with Discord" }), + ).toBeVisible(); + expect(sessionStorage.getItem("kc_token")).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }, +); + +test("refreshes the session in the background, then returns to login if refresh fails", async () => { + jest.useFakeTimers(); + const access = token(Date.now() / 1000 + 75); + const refreshed = token(Date.now() / 1000 + 100); + sessionStorage.setItem("kc_token", access); + sessionStorage.setItem("kc_refresh_token", "refresh"); + fetchMock + .mockResolvedValueOnce(reply({ banned: false })) + .mockResolvedValueOnce(reply({}, false)) + .mockResolvedValueOnce(reply({ access_token: refreshed })) + .mockResolvedValueOnce(reply({ banned: false })) + .mockResolvedValueOnce(reply({}, false)) + .mockResolvedValueOnce(reply({}, false)); + render(); + expect( + await screen.findByRole("heading", { name: "Not Banned" }), + ).toBeVisible(); + await act(() => jest.advanceTimersByTimeAsync(30000)); + expect(sessionStorage.getItem("kc_token")).toBe(refreshed); + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + "/api/v2/users/banned", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Bearer ${refreshed}`, + }), + }), + ); + await act(() => jest.advanceTimersByTimeAsync(30000)); + expect( + screen.getByRole("link", { name: "Login with Discord" }), + ).toBeVisible(); + expect(sessionStorage.getItem("kc_refresh_token")).toBeNull(); +}); + +test.each(["HTTP error", "network error"])( + "reports a reminder %s", + async (failure) => { + sessionStorage.setItem("kc_token", validToken()); + fetchMock + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce(reply(appeal("RECEIVED", 4))); + if (failure === "HTTP error") + fetchMock.mockResolvedValueOnce( + reply({ error: "Reminder unavailable" }, false), + ); + else fetchMock.mockRejectedValueOnce(new Error("Offline")); + render(); + const remind = await screen.findByRole("button", { name: "Send Reminder" }); + fireEvent.click(remind); + expect( + await screen.findByText(/reminder unavailable|failed to send reminder/i), + ).toBeVisible(); + expect(remind).toBeEnabled(); + expect(fetchMock).toHaveBeenCalledTimes(3); + }, +); + +test("offers Discord login to a visitor without credentials", async () => { + render(); + expect( + await screen.findByRole("link", { name: "Login with Discord" }), + ).toHaveAttribute("href", "https://auth.example.test/login"); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +test("exchanges an authorization code and uses the resulting session", async () => { + window.history.replaceState({}, "", "/appeal?code=example"); + const access = validToken(); + fetchMock + .mockResolvedValueOnce( + reply({ access_token: access, refresh_token: "refresh", id_token: "id" }), + ) + .mockResolvedValueOnce(reply({ banned: false })) + .mockResolvedValueOnce(reply({}, false)); + render(); + expect( + await screen.findByRole("heading", { name: "Not Banned" }), + ).toBeVisible(); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "/api/v2/keycloak/token", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ code: "example" }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/api/v2/users/banned", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: `Bearer ${access}` }), + }), + ); + expect(sessionStorage.getItem("kc_refresh_token")).toBe("refresh"); + expect(sessionStorage.getItem("kc_id_token")).toBe("id"); + expect(window.location.search).toBe(""); +}); + +test("refreshes an expired token before requesting ban status", async () => { + sessionStorage.setItem("kc_token", token(0)); + sessionStorage.setItem("kc_refresh_token", "old-refresh"); + const access = validToken(); + fetchMock + .mockResolvedValueOnce( + reply({ + access_token: access, + refresh_token: "new-refresh", + id_token: "new-id", + }), + ) + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce(reply({})); + render(); + expect( + await screen.findByRole("heading", { name: "Submit Ban Appeal" }), + ).toBeVisible(); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "/api/v2/keycloak/refresh", + expect.objectContaining({ + body: JSON.stringify({ refresh_token: "old-refresh" }), + }), + ); + expect(sessionStorage.getItem("kc_token")).toBe(access); + expect(sessionStorage.getItem("kc_refresh_token")).toBe("new-refresh"); +}); + +test.each([ + ["malformed", false], + [token(0), false], + [token(0), true], +])( + "returns to login when the stored session is unusable (%#)", + async (stored, refresh) => { + sessionStorage.setItem("kc_token", stored); + if (refresh) sessionStorage.setItem("kc_refresh_token", "expired-refresh"); + fetchMock.mockResolvedValue(reply({}, false)); + render(); + expect( + await screen.findByRole("link", { name: "Login with Discord" }), + ).toBeVisible(); + expect(sessionStorage.getItem("kc_token")).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(refresh ? 1 : 0); + }, +); + +test.each([ + [true, "ACCEPTED", 1, "Submit Ban Appeal"], + [true, "DENIED", 1, "Your Appeal Status"], + [true, "DENIED", 100, "Submit New Ban Appeal"], + [false, "ACCEPTED", 1, "Appeal History"], + [false, "DENIED", 1, "Appeal History"], + [false, "RECEIVED", 1, "Appeal History"], +])( + "renders the appropriate view for ban=%s, status=%s, age=%s", + async (banned, status, age, heading) => { + sessionStorage.setItem("kc_token", validToken()); + fetchMock + .mockResolvedValueOnce(reply({ banned })) + .mockResolvedValueOnce(reply(appeal(status, age))); + render(); + expect(await screen.findByRole("heading", { name: heading })).toBeVisible(); + if (!banned) { + expect( + screen.getByText( + /appeal accepted!|appeal was denied|appeal still pending/i, + ), + ).toBeVisible(); + } + }, +); + +test("sends a reminder for an older pending appeal and reloads its status", async () => { + sessionStorage.setItem("kc_token", validToken()); + const pending = appeal("RECEIVED", 4); + fetchMock + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce(reply(pending)) + .mockResolvedValueOnce(reply({ message: "Reminder sent" })) + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce(reply({ ...pending, reminded_at: daysAgo(0) })); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Send Reminder" })); + expect(await screen.findByText(/last reminder:/i)).toBeVisible(); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "/api/v2/appeals/remind", + expect.objectContaining({ method: "POST" }), + ); +}); + +test("shows a submission error and allows retrying", async () => { + sessionStorage.setItem("kc_token", validToken()); + fetchMock + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce(reply({}, false)) + .mockResolvedValueOnce( + reply({ error: "User not found in database" }, false), + ); + render(); + const submit = await screen.findByRole("button", { name: "Submit Appeal" }); + const data = { + reason: "Reason", + solution: "Solution", + future: "Plan", + extra: "", + }; + fireEvent.change( + screen.getByRole("textbox", { name: /why you were banned/i }), + { target: { value: data.reason } }, + ); + fireEvent.change(screen.getByRole("textbox", { name: /make amends/i }), { + target: { value: data.solution }, + }); + fireEvent.change(screen.getByRole("textbox", { name: /avoid repeating/i }), { + target: { value: data.future }, + }); + fireEvent.click(submit); + expect( + await screen.findByText(/your account could not be found/i), + ).toBeVisible(); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "/api/v2/appeals/create", + expect.objectContaining({ method: "POST", body: JSON.stringify(data) }), + ); + await waitFor(() => expect(submit).toBeEnabled()); +}); + +test("disables reminders for a newly submitted appeal", async () => { + sessionStorage.setItem("kc_token", validToken()); + fetchMock + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce( + reply({ ...appeal("RECEIVED", 0), created_at: daysAgo(0) }), + ); + render(); + expect( + await screen.findByRole("button", { name: "Come back in 48 hours" }), + ).toBeDisabled(); + expect(screen.getByText(/you can send a reminder 48 hours/i)).toBeVisible(); +}); + +test("submits an appeal and refreshes its status", async () => { + sessionStorage.setItem("kc_token", validToken()); + fetchMock + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce(reply({}, false)) + .mockResolvedValueOnce(reply({})) + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce(reply(appeal("RECEIVED", 0))); + render(); + const submit = await screen.findByRole("button", { name: "Submit Appeal" }); + fireEvent.change( + screen.getByRole("textbox", { name: /why you were banned/i }), + { target: { value: "Reason" } }, + ); + fireEvent.change(screen.getByRole("textbox", { name: /make amends/i }), { + target: { value: "Solution" }, + }); + fireEvent.change(screen.getByRole("textbox", { name: /avoid repeating/i }), { + target: { value: "Plan" }, + }); + fireEvent.click(submit); + expect(await screen.findByText("Under Review")).toBeVisible(); + expect(screen.getByText("Your appeal has been submitted.")).toBeVisible(); + expect(fetchMock).toHaveBeenCalledTimes(5); +}); diff --git a/src/components/__tests__/DrugInfo.test.tsx b/src/components/__tests__/DrugInfo.test.tsx new file mode 100644 index 0000000..ea7df9e --- /dev/null +++ b/src/components/__tests__/DrugInfo.test.tsx @@ -0,0 +1,270 @@ +import { render, screen } from "@testing-library/react"; +import type { Category, Drug, Unit } from "tripsit_drug_db"; +import DrugPage from "@/pages/factsheets/[drug]"; +import { useRouter } from "next/router"; +import { useDrugs } from "@/utils/drugs"; +import DrugInfo from "../DrugInfo"; + +jest.mock("next/router", () => ({ useRouter: jest.fn() })); +jest.mock("@/utils/drugs", () => ({ useDrugs: jest.fn() })); +jest.mock("../Header", () => () => null); +jest.mock( + "next/dynamic", + () => () => + function Chart({ + series, + options, + }: { + series: unknown; + options: { + tooltip: { + custom: (input: { + seriesIndex: number; + dataPointIndex: number; + w: { config: { series: unknown } }; + }) => string; + }; + }; + }) { + return ( + <> + {JSON.stringify(series)} + {Array.isArray(series) && + series.map(({ name }, seriesIndex) => { + const html = options.tooltip.custom({ + seriesIndex, + dataPointIndex: 0, + w: { config: { series } }, + }); + return ( + + { + new DOMParser().parseFromString(html, "text/html").body + .textContent + } + + ); + })} + + ); + }, +); + +const drug = { + name: "example", + pretty_name: "Example compound", + aliases: ["alias"], + properties: {}, +} as Drug; + +test.each(["EXAMPLE", "example compound", "ALIAS"])( + "looks up a factsheet by %s", + (name) => { + jest + .mocked(useRouter) + .mockReturnValue({ query: { drug: name } } as unknown as ReturnType< + typeof useRouter + >); + jest + .mocked(useDrugs) + .mockReturnValue({ data: [drug] } as ReturnType); + render(); + expect(screen.getByText("Example compound")).toBeVisible(); + }, +); + +test.each([ + { name: undefined }, + { name: ["example", "alias"] }, + { name: "missing" }, +])("shows the missing factsheet view for $name", ({ name }) => { + jest + .mocked(useRouter) + .mockReturnValue({ query: { drug: name } } as unknown as ReturnType< + typeof useRouter + >); + jest + .mocked(useDrugs) + .mockReturnValue({ data: [drug] } as ReturnType); + render(); + expect( + screen.getByRole("heading", { name: /drug not found/i }), + ).toBeVisible(); + expect( + screen.getByRole("link", { name: /contact us on discord/i }), + ).toBeVisible(); +}); + +test("keeps dose text and charts the ranges for each route", () => { + render( + , + ); + expect(screen.getByText("Example dose note")).toBeVisible(); + expect(screen.getByText("unknown")).toBeVisible(); + expect(screen.getByText("1-2mg")).toBeVisible(); + expect(screen.getByLabelText("Tooltip for common")).toHaveTextContent( + /oral .*:.*1 - 2/, + ); + expect( + JSON.parse(screen.getByLabelText("Chart series").textContent ?? ""), + ).toEqual([ + { + name: "common", + data: [ + { x: "oral (mg)", y: [1, 2] }, + { x: "insufflated (mg)", y: [3, 4] }, + ], + }, + ]); +}); + +test("keeps unplottable doses in the table", () => { + render( + , + ); + expect(screen.getByText("unknown")).toBeVisible(); + expect(screen.getByLabelText("Chart series")).toHaveTextContent("[]"); +}); + +test("converts onset to hours and offsets duration and after effects", () => { + render( + , + ); + expect(screen.getByText("15m to 30m")).toBeVisible(); + expect(screen.getByText("1h 30m to 2h 30m")).toBeVisible(); + expect(screen.getByLabelText("Tooltip for Onset")).toHaveTextContent( + /30m after dosing/, + ); + expect(screen.getAllByLabelText("Tooltip for Duration")[0]).toHaveTextContent( + /after Onset ends/, + ); + const series = JSON.parse( + screen.getByLabelText("Chart series").textContent ?? "", + ); + expect(series).toEqual([ + expect.objectContaining({ + name: "Onset", + data: [expect.objectContaining({ x: "oral", y: [0, 0.5] })], + }), + expect.objectContaining({ + name: "Duration", + data: [expect.objectContaining({ x: "oral", y: [0.25, 3] })], + }), + expect.objectContaining({ + name: "After Effects", + data: [expect.objectContaining({ x: "oral", y: [1.75, 5.75] })], + }), + expect.objectContaining({ + name: "Duration", + data: [expect.objectContaining({ x: "insufflated", y: [0, 2] })], + }), + ]); +}); + +test("supports legacy oral onset and routes with after effects only", () => { + render( + , + ); + expect(screen.getByText("0 to 30m")).toBeVisible(); + expect( + JSON.parse(screen.getByLabelText("Chart series").textContent ?? ""), + ).toEqual([ + expect.objectContaining({ + name: "Onset", + data: [expect.objectContaining({ x: "Oral", y: [0, 0.5] })], + }), + expect.objectContaining({ + name: "After Effects", + data: [expect.objectContaining({ x: "insufflated", y: [1, 2] })], + }), + ]); +}); + +test("displays after effects without onset or duration data", () => { + render( + , + ); + expect(screen.getByText("2 to 3")).toBeVisible(); + expect(screen.getByLabelText("Tooltip for After Effects")).toHaveTextContent( + /3h after dosing/, + ); +}); + +test("renders optional references, warnings, and combination notes", () => { + render( + , + ); + expect(screen.getByText(/sample warning/i)).toBeVisible(); + expect(screen.getByText("Sample summary")).toBeVisible(); + expect(screen.getByText("MDMA")).toBeVisible(); + expect(screen.getByText("Sample combination note")).toBeVisible(); + expect(screen.getByText("Sample effect")).toBeVisible(); + expect(screen.getByText("Sample source")).toBeVisible(); + expect( + screen.getByRole("link", { name: "Erowid Experience Vault" }), + ).toHaveAttribute("href", "https://example.test/experiences"); + expect(screen.getByRole("link", { name: "Nausea" })).toHaveAttribute( + "href", + "https://psychonautwiki.org/wiki/Nausea", + ); +}); diff --git a/src/components/__tests__/Factsheets.test.tsx b/src/components/__tests__/Factsheets.test.tsx new file mode 100644 index 0000000..2612e5f --- /dev/null +++ b/src/components/__tests__/Factsheets.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import FactsheetsPage from "@/pages/factsheets"; + +jest.mock("@heroui/react", () => ({ Accordion: () => null })); + +const originalFetch = global.fetch; +const fetchMock = jest.fn(); +const drugs = { + example: { + name: "example", + pretty_name: "Example compound", + aliases: ["Test alias"], + properties: { + summary: "Example summary", + categories: ["Stimulant", "tentative", "supplement"], + }, + }, + minimal: { name: "minimal", pretty_name: "Minimal compound", properties: {} }, +}; + +beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +function renderFactsheets() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +test("loads factsheet rows from the drug database and expands their details", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => drugs }); + renderFactsheets(); + expect(await screen.findByText("Example compound")).toBeVisible(); + expect(screen.getByText("Test alias")).toBeVisible(); + expect(screen.getByText("Minimal compound")).toBeVisible(); + expect(screen.getByText("Stimulant,")).toHaveStyle({ + color: "rgb(255, 255, 255)", + }); + expect(screen.getByText("tentative,")).toHaveStyle({ color: "#001713" }); + expect(screen.getByText(/supplement/)).toBeVisible(); + fireEvent.click(screen.getAllByRole("button", { name: "Expand" })[0]); + expect(await screen.findByText("Chemical Name")).toBeVisible(); +}); + +test("shows a loading error and recovers when refreshed", async () => { + fetchMock + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: true, json: async () => drugs }); + renderFactsheets(); + expect(await screen.findByText("Error loading data")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Refresh Data" })); + expect(await screen.findByText("Example compound")).toBeVisible(); + expect(screen.queryByText("Error loading data")).toBeNull(); +}); diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx new file mode 100644 index 0000000..c2852b8 --- /dev/null +++ b/src/components/__tests__/Header.test.tsx @@ -0,0 +1,51 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import Header from "../Header"; + +test("opens the desktop menu and dismisses it on outside interaction or Escape", () => { + render(
); + const nav = within(screen.getByRole("navigation", { name: "Main" })); + const resources = nav.getByRole("button", { name: "Resources" }); + fireEvent.click(resources); + expect(resources).toHaveAttribute("aria-expanded", "true"); + expect(nav.getByRole("link", { name: "Factsheets" })).toHaveAttribute( + "href", + "/factsheets", + ); + fireEvent.pointerDown(document.body); + expect(resources).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(resources); + fireEvent.keyDown(document, { key: "Escape" }); + expect(nav.queryByRole("link", { name: "Factsheets" })).toBeNull(); +}); + +test("gives the pointer time to cross from the menu trigger to the panel", () => { + jest.useFakeTimers(); + try { + render(
); + const resources = screen.getByRole("button", { name: "Resources" }); + fireEvent.mouseEnter(resources); + fireEvent.mouseLeave(resources); + act(() => jest.advanceTimersByTime(100)); + expect(resources).toHaveAttribute("aria-expanded", "true"); + fireEvent.mouseEnter(resources); + act(() => jest.advanceTimersByTime(250)); + expect(resources).toHaveAttribute("aria-expanded", "true"); + fireEvent.mouseLeave(resources); + act(() => jest.advanceTimersByTime(250)); + expect(resources).toHaveAttribute("aria-expanded", "false"); + } finally { + jest.useRealTimers(); + } +}); + +test("reveals mobile navigation and closes it after following a link", () => { + render(
); + fireEvent.click(screen.getByRole("button", { name: "Open menu" })); + const mobile = screen.getByRole("navigation", { name: "Mobile" }); + const factsheets = within(mobile).getByRole("link", { name: "Factsheets" }); + factsheets.addEventListener("click", (event) => event.preventDefault(), { + once: true, + }); + fireEvent.click(factsheets); + expect(screen.queryByRole("navigation", { name: "Mobile" })).toBeNull(); +}); diff --git a/src/components/__tests__/HomeEffects.test.tsx b/src/components/__tests__/HomeEffects.test.tsx new file mode 100644 index 0000000..adf5520 --- /dev/null +++ b/src/components/__tests__/HomeEffects.test.tsx @@ -0,0 +1,93 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import axios from "axios"; +import { useCountUp } from "react-countup"; +import DiscordMembers from "../DiscordMembers"; +import Counter from "../Counter"; +import Ghost from "../Ghost"; + +jest.mock("axios"); +jest.mock("react-countup", () => ({ useCountUp: jest.fn() })); + +test("uses animation defaults when only the ending count is supplied", () => { + jest.mocked(useCountUp).mockReturnValue({ + start: jest.fn(), + pauseResume: jest.fn(), + reset: jest.fn(), + update: jest.fn(), + getCountUp: jest.fn(), + }); + render( + ["data"]} + />, + ); + expect(screen.getByText("0")).toBeVisible(); + expect(useCountUp).toHaveBeenLastCalledWith( + expect.objectContaining({ start: 0, end: 25, duration: 5, delay: 1 }), + ); +}); + +test("loads the Discord member count and starts its counter on the animation event", async () => { + jest.mocked(axios.get).mockResolvedValue({ + data: { approximate_member_count: 12345 }, + }); + const start = jest.fn(); + jest.mocked(useCountUp).mockReturnValue({ + start, + pauseResume: jest.fn(), + reset: jest.fn(), + update: jest.fn(), + getCountUp: jest.fn(), + }); + render( +
+ +
, + ); + expect(screen.getByText("0")).toBeVisible(); + + await act(async () => {}); + expect(axios.get).toHaveBeenCalledWith( + "https://discord.com/api/v10/guilds/179641883222474752?with_counts=true", + expect.any(Object), + ); + expect(useCountUp).toHaveBeenCalledWith( + expect.objectContaining({ + start: 0, + end: 12345, + duration: 4, + delay: 1, + separator: ",", + }), + ); + fireEvent( + screen.getByRole("region", { name: "Discord count" }), + new Event("aos:in"), + ); + expect(start).toHaveBeenCalledTimes(1); +}); + +test("fades the hero after inactivity and restores it on interaction", () => { + jest.useFakeTimers(); + try { + render( + <> + +
+ + , + ); + const hero = screen.getByRole("region", { name: "Hero" }); + const animations = screen.getByRole("button", { name: "Animations" }); + act(() => jest.advanceTimersByTime(10)); + fireEvent.mouseMove(document); + act(() => jest.advanceTimersByTime(10000)); + expect(hero).toHaveStyle({ opacity: "0" }); + expect(animations).toHaveStyle({ opacity: "0" }); + fireEvent.keyDown(document); + expect(Number(hero.style.opacity)).toBeGreaterThan(0); + expect(Number(animations.style.opacity)).toBeGreaterThan(0); + } finally { + jest.useRealTimers(); + } +}); diff --git a/src/components/__tests__/HomePage.test.tsx b/src/components/__tests__/HomePage.test.tsx new file mode 100644 index 0000000..81a7cff --- /dev/null +++ b/src/components/__tests__/HomePage.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import Home, { getStaticProps } from "@/pages"; +import { getDiscordStats } from "@/utils/discordStats"; + +jest.mock("@/utils/discordStats"); +jest.mock("@heroui/react", () => { + const Wrapper = ({ children }: { children: ReactNode }) => ( +
{children}
+ ); + return { + Accordion: Object.assign(Wrapper, { + Item: ({ + children, + }: { + children: (state: { isExpanded: boolean }) => ReactNode; + }) => children({ isExpanded: false }), + Heading: Wrapper, + Trigger: Wrapper, + Panel: Wrapper, + Body: Wrapper, + }), + Dropdown: Object.assign(Wrapper, { Trigger: Wrapper, Popover: () => null }), + cn: (...classes: string[]) => classes.filter(Boolean).join(" "), + }; +}); +jest.mock("swiper/modules", () => ({})); +jest.mock("swiper/css", () => ({})); +jest.mock("swiper/css/navigation", () => ({})); +jest.mock("swiper/css/pagination", () => ({})); +jest.mock("swiper/react", () => ({ + Swiper: ({ children }: { children: ReactNode }) =>
{children}
, + SwiperSlide: ({ children }: { children: ReactNode }) =>
{children}
, +})); +jest.mock("next/dynamic", () => () => () => null); +jest.mock("react-countup", () => ({ + useCountUp: () => ({ start: jest.fn() }), +})); + +const discord = { memberCount: 4000, presenceCount: 1234 }; + +test.each([ + { stats: discord, seconds: 300 }, + { stats: null, seconds: 30 }, +])( + "uses a $seconds second revalidation interval", + async ({ stats, seconds }) => { + jest.mocked(getDiscordStats).mockResolvedValue(stats); + await expect(getStaticProps()).resolves.toEqual({ + props: { discord: stats }, + revalidate: seconds, + }); + }, +); + +test("links to trip sitting and shows online counts when available", () => { + const { rerender } = render(); + expect( + screen.getByRole("link", { name: /i want to talk to a trip sitter/i }), + ).toHaveAttribute("href", "/webchat"); + expect(screen.getByText("1,234")).toBeVisible(); + expect(screen.getByRole("heading", { name: "About Us" })).toBeVisible(); + rerender(); + expect(screen.queryByText(/people are here right now/i)).toBeNull(); +}); diff --git a/src/components/__tests__/ProfileButton.test.tsx b/src/components/__tests__/ProfileButton.test.tsx new file mode 100644 index 0000000..f5e7fa1 --- /dev/null +++ b/src/components/__tests__/ProfileButton.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ProfileButton from "../ProfileButton"; + +const originalFetch = global.fetch; +const fetchMock = jest.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock; +}); + +afterEach(() => { + jest.restoreAllMocks(); + global.fetch = originalFetch; + sessionStorage.clear(); + localStorage.clear(); +}); + +test("reacts to session changes and dismisses the account menu outside", async () => { + const consoleError = jest.spyOn(console, "error"); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ avatarUrl: "/avatar.png" }), + }); + render(); + expect(screen.getByRole("button", { name: "Login" })).toBeVisible(); + expect(fetchMock).not.toHaveBeenCalled(); + + sessionStorage.setItem("kc_token", "session-token"); + fireEvent(window, new Event("tokensUpdated")); + const account = screen.getByRole("button", { name: "Account menu" }); + await waitFor(() => + expect(account.style.backgroundImage).toContain("/avatar.png"), + ); + expect(fetchMock).toHaveBeenCalledWith("/api/v2/users/avatar", { + headers: { + Authorization: "Bearer session-token", + "Content-Type": "application/json", + }, + }); + + fireEvent.click(account); + expect(screen.getByRole("button", { name: "Logout" })).toBeVisible(); + fireEvent.mouseDown(document.body); + expect(screen.queryByRole("button", { name: "Logout" })).toBeNull(); + + sessionStorage.clear(); + fireEvent.focus(window); + const login = screen.getByRole("button", { name: "Login" }); + expect(login).toBeVisible(); + expect(login.style.backgroundImage).toBe(""); + expect(login.style.backgroundColor).toBe("var(--color-surface)"); + expect(consoleError).not.toHaveBeenCalled(); +}); + +test.each(["HTTP error", "network error"])( + "uses the guest avatar after an avatar %s", + async (error) => { + if (error === "HTTP error") fetchMock.mockResolvedValue({ ok: false }); + else fetchMock.mockRejectedValue(new Error("Offline")); + localStorage.setItem("kc_token", "stored-token"); + render(); + const account = screen.getByRole("button", { name: "Account menu" }); + await waitFor(() => + expect(account.style.backgroundImage).toContain("/assets/img/guest.png"), + ); + }, +); diff --git a/src/components/__tests__/WebchatPage.test.tsx b/src/components/__tests__/WebchatPage.test.tsx new file mode 100644 index 0000000..d28734a --- /dev/null +++ b/src/components/__tests__/WebchatPage.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from "@testing-library/react"; +import WebchatPage from "@/pages/webchat"; + +jest.mock( + "next/dynamic", + () => () => + function Widget({ server, channel }: { server: string; channel: string }) { + return ( +
+ ); + }, +); + +test("mounts the configured chat widget alongside navigation and footer links", () => { + render(); + const widget = screen.getByRole("region", { name: "Chat widget" }); + expect(widget).toHaveAttribute("data-server", "960606557622657026"); + expect(widget).toHaveAttribute("data-channel", "1052634176161054782"); + expect(screen.getByRole("navigation", { name: "Main" })).toBeVisible(); + expect(screen.getByRole("link", { name: "Privacy policy" })).toBeVisible(); +}); diff --git a/src/components/comboChecker/__tests__/ComboChecker.test.tsx b/src/components/comboChecker/__tests__/ComboChecker.test.tsx new file mode 100644 index 0000000..91dcd52 --- /dev/null +++ b/src/components/comboChecker/__tests__/ComboChecker.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import ComboPage from "@/pages/combo"; +import ComboResults from "../ComboResults"; +import { getCombo, RANK } from "../comboData"; + +test("searches, compares, removes and clears selected substances", () => { + render(); + const search = screen.getByRole("textbox", { name: "Search substances" }); + const button = (name: string) => screen.getByRole("button", { name }); + + expect(screen.getByText(/select two or more substances/i)).toBeVisible(); + fireEvent.click(button("Alcohol")); + expect(screen.getByText(/compare with Alcohol/)).toBeVisible(); + fireEvent.change(search, { target: { value: " kEtAmInE " } }); + expect(button("Alcohol")).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "Cocaine" })).toBeNull(); + + fireEvent.click(button("Ketamine")); + expect(screen.getByText("Alcohol + Ketamine")).toBeVisible(); + fireEvent.click(button("Alcohol")); + expect(screen.queryByText("Alcohol + Ketamine")).toBeNull(); + expect(screen.getByText(/compare with Ketamine/)).toBeVisible(); + fireEvent.click(button("Clear (1)")); + expect(button("Ketamine")).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByText(/select two or more substances/i)).toBeVisible(); +}); + +test("renders every pair in selection order or severity order", () => { + const selected = ["Alcohol", "Cannabis", "Ketamine"]; + const pairs = [ + ["Alcohol", "Cannabis"], + ["Alcohol", "Ketamine"], + ["Cannabis", "Ketamine"], + ]; + const titles = () => + screen + .getAllByText(/^(Alcohol|Cannabis) \+ /) + .map((node) => node.textContent); + const { rerender } = render( + , + ); + expect(titles()).toEqual(pairs.map((pair) => pair.join(" + "))); + const note = getCombo("Alcohol", "Ketamine").note; + expect(note).toBeTruthy(); + expect(screen.queryByText(note)).toBeNull(); + + rerender( + , + ); + expect(titles()).toEqual( + pairs + .map(([a, b]) => ({ + title: `${a} + ${b}`, + rank: RANK[getCombo(a, b).risk], + })) + .sort((a, b) => b.rank - a.rank) + .map(({ title }) => title), + ); + expect(screen.getByText(note)).toBeVisible(); +}); diff --git a/src/utils/__tests__/env.config.test.ts b/src/utils/__tests__/env.config.test.ts new file mode 100644 index 0000000..df6bce6 --- /dev/null +++ b/src/utils/__tests__/env.config.test.ts @@ -0,0 +1,26 @@ +test.each([ + ["production", "production"], + ["test", "development"], +] as const)( + "exports the configured environment for NODE_ENV=%s", + (nodeEnv, expected) => { + const previousEnv = global.env; + jest.replaceProperty(process, "env", { + ...process.env, + NODE_ENV: nodeEnv, + DISCORD_GUILD_ID: "test-guild", + }); + try { + jest.isolateModules(() => { + const { env } = + jest.requireActual("../env.config"); + expect(env.NODE_ENV).toBe(expected); + expect(env.DISCORD_GUILD_ID).toBe("test-guild"); + expect(global.env).toBe(env); + }); + } finally { + global.env = previousEnv; + jest.restoreAllMocks(); + } + }, +); diff --git a/src/utils/__tests__/keycloak.test.ts b/src/utils/__tests__/keycloak.test.ts index 60976a6..45df027 100644 --- a/src/utils/__tests__/keycloak.test.ts +++ b/src/utils/__tests__/keycloak.test.ts @@ -1,4 +1,4 @@ -import { getLoginUrl } from "../keycloak"; +import { getLoginUrl, getPostLogoutUrl } from "../keycloak"; // NODE_ENV is typed read-only; tests need to switch it. const env = process.env as Record; @@ -23,8 +23,22 @@ afterEach(() => { restore("NEXT_PUBLIC_KEYCLOAK_URL"); restore("NEXT_PUBLIC_KEYCLOAK_CLIENT_ID"); jest.restoreAllMocks(); + sessionStorage.clear(); }); +test.each(["", "test-id-token"])( + "builds a logout URL with token hint %p", + (hint) => { + if (hint) sessionStorage.setItem("kc_id_token", hint); + const url = new URL(getPostLogoutUrl()); + expect(url.pathname).toBe("/realms/TripSit/protocol/openid-connect/logout"); + expect(url.searchParams.get("post_logout_redirect_uri")).toBe( + window.location.origin, + ); + expect(url.searchParams.get("id_token_hint")).toBe(hint); + }, +); + describe("getLoginUrl", () => { it("asks Keycloak for a code as the configured client", () => { env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID = "ban-appeals"; From d391a7c27e98595d17c7861b0b49120edb794733 Mon Sep 17 00:00:00 2001 From: LunaUrsa <1836049+LunaUrsa@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:47:01 -0600 Subject: [PATCH 2/2] Use a literal assertion for the dose tooltip --- src/components/__tests__/DrugInfo.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/__tests__/DrugInfo.test.tsx b/src/components/__tests__/DrugInfo.test.tsx index ea7df9e..c1a962d 100644 --- a/src/components/__tests__/DrugInfo.test.tsx +++ b/src/components/__tests__/DrugInfo.test.tsx @@ -112,7 +112,7 @@ test("keeps dose text and charts the ranges for each route", () => { expect(screen.getByText("unknown")).toBeVisible(); expect(screen.getByText("1-2mg")).toBeVisible(); expect(screen.getByLabelText("Tooltip for common")).toHaveTextContent( - /oral .*:.*1 - 2/, + "oral (mg): 1 - 2", ); expect( JSON.parse(screen.getByLabelText("Chart series").textContent ?? ""),