From 88860a7e875dae7554eb8e7e798dfe64c310fd21 Mon Sep 17 00:00:00 2001 From: LunaUrsa <1836049+LunaUrsa@users.noreply.github.com> Date: Sat, 14 Nov 2026 18:58:44 -0600 Subject: [PATCH] Chart only bounded mg dose ranges and cover remaining branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse factsheet dose ranges with parseDoseRangeMg: convert ng, ug/µg, mg and g to milligrams, require two explicit endpoints, and leave open-ended doses, rates (ug/hr), per-kg doses and reversed ranges out of the chart. The table still shows every dose as written, and the chart is hidden when nothing can be plotted. Closes #303. Remove branches that could never run: the null guards in addDurations and the duration tooltip (durationTiming only holds defined entries), the "unknown" ban-status fallback, the redundant status checks in canReappeal and canShowReminder (their callers already filter by status), and the "Appeal Accepted" card in the has_appeal view. Simplify the appeal token refresh to share one pending refresh promise and keep the refresh interval in the effect's scope. Add tests so statements, branches, functions and lines reach 100%. Co-Authored-By: Claude Opus 5.5 --- src/components/DrugInfo.tsx | 347 ++++++++---------- src/components/__tests__/AppealPage.test.tsx | 139 +++++++ src/components/__tests__/Document.test.tsx | 59 +++ src/components/__tests__/DrugInfo.test.tsx | 61 ++- .../__tests__/DynamicWidgets.test.tsx | 69 ++++ .../__tests__/FactsheetColumns.test.tsx | 97 +++++ src/components/__tests__/Factsheets.test.tsx | 3 + src/components/__tests__/Header.test.tsx | 19 + src/components/__tests__/HomeEffects.test.tsx | 35 ++ .../__tests__/ProfileButton.test.tsx | 57 +++ src/components/__tests__/UiAdapters.test.tsx | 117 ++++++ .../__tests__/comboDataFallback.test.ts | 12 + src/pages/appeal.tsx | 96 +---- src/utils/__tests__/discordStats.test.ts | 22 ++ src/utils/__tests__/drugs.test.ts | 6 +- src/utils/__tests__/env.config.test.ts | 4 + src/utils/__tests__/keycloak.server.test.ts | 23 ++ src/utils/__tests__/parseDoseRangeMg.test.ts | 32 ++ src/utils/parseDoseRangeMg.ts | 28 ++ 19 files changed, 942 insertions(+), 284 deletions(-) create mode 100644 src/components/__tests__/Document.test.tsx create mode 100644 src/components/__tests__/DynamicWidgets.test.tsx create mode 100644 src/components/__tests__/FactsheetColumns.test.tsx create mode 100644 src/components/__tests__/UiAdapters.test.tsx create mode 100644 src/components/comboChecker/__tests__/comboDataFallback.test.ts create mode 100644 src/utils/__tests__/keycloak.server.test.ts create mode 100644 src/utils/__tests__/parseDoseRangeMg.test.ts create mode 100644 src/utils/parseDoseRangeMg.ts diff --git a/src/components/DrugInfo.tsx b/src/components/DrugInfo.tsx index 3ab65a0..a213bc7 100644 --- a/src/components/DrugInfo.tsx +++ b/src/components/DrugInfo.tsx @@ -12,6 +12,7 @@ import type { ApexAxisChartSeries } from "apexcharts"; import dynamic from "next/dynamic"; import comboDefinitions from "../assets/comboDefinitions.json"; import addDictionaryDefs from "./addDictionaryDefs"; +import { parseDoseRangeMg } from "../utils/parseDoseRangeMg"; // We need to dynamically load this module const ReactApexChart = dynamic(() => import("react-apexcharts"), { @@ -167,32 +168,30 @@ const addDurations = (drugData: Drug) => { const durationSeries = [] as ApexAxisChartSeries; Object.keys(durationTiming).forEach((roa) => { const roaData = durationTiming[roa as keyof typeof durationTiming]; - if (roaData) { - Object.keys(roaData).forEach((timing) => { - const timingData = roaData[timing as keyof typeof roaData]; - if (timingData) { - const data = { - name: timing, - data: [ + Object.keys(roaData).forEach((timing) => { + const timingData = roaData[timing as keyof typeof roaData] as NonNullable< + (typeof roaData)[keyof typeof roaData] + >; + const data = { + name: timing, + data: [ + { + x: roa, + y: [timingData.startTime, timingData.endTime], + goals: [ + // The black line that signifies the minimum duration { - x: roa, - y: [timingData.startTime, timingData.endTime], - goals: [ - // The black line that signifies the minimum duration - { - name: "Minimum", - value: timingData.min + timingData.startTime, - strokeWidth: 2, - strokeColor: "#000000", - }, - ] as Record[], + name: "Minimum", + value: timingData.min + timingData.startTime, + strokeWidth: 2, + strokeColor: "#000000", }, - ], - } as ApexAxisChartSeries[number]; - durationSeries.push(data); - } - }); - } + ] as Record[], + }, + ], + } as ApexAxisChartSeries[number]; + durationSeries.push(data); + }); }); // Create chart options @@ -239,13 +238,12 @@ const addDurations = (drugData: Drug) => { const roaTimingData = durationTiming[s.x as keyof typeof durationTiming]; - const periodData = - roaTimingData[ - w.config.series[seriesIndex].name as keyof typeof roaTimingData - ]; + const periodData = roaTimingData[ + w.config.series[seriesIndex].name as keyof typeof roaTimingData + ] as NonNullable<(typeof roaTimingData)[keyof typeof roaTimingData]>; - const yStartValue = periodData ? periodData.min : 0; - const yEndValue = periodData ? periodData.max : 0; + const yStartValue = periodData.min; + const yEndValue = periodData.max; let yStart = `${yStartValue}` as string; let yEnd = `${yEndValue}h` as string; @@ -454,71 +452,23 @@ const addDosages = (drugData: Drug) => { [strength]: strengthData, }; - // This essentially looks for "(any digit) (optional dash) (any digit) (optional unit)" - const regex = /(\d+(?:\.\d+)?)(?:-(\d+(?:\.\d+)?))?([a-zA-Z]+)?/; - const match = RegExp(regex).exec(strengthData); - - // This is mostly for type-safety - if (match) { - // The minimum number is the first number to appear - const strengthMinNumber = parseFloat(match[1]); - - // The maximum number is either the second number, or if there is no second number, the first number * 1.5 - // Later, we have code that says "if there is no maximum, then do a fade out", the "1.5" is purely cosmetic - // The doseText above displays the "+" so it's clear to the user, they never see the (dosage * 1.5) value - const strengthMaxNumber = match[2] - ? parseFloat(match[2]) - : strengthMinNumber * 1.5; - - // Get the color associated with the strength - const strengthColor = - doseColorData[strength as keyof typeof doseColorData]; - // If it's not already in the doseColors table, add it - if (!doseColors.includes(strengthColor)) { - doseColors.push(strengthColor); - } - - // Get the gradient color associated with the strength - // If it's not already in the doseGradientToColors table, add it - const strengthGradientColor = match[2] - ? doseGradientData[strength as keyof typeof doseGradientData] - : "#ffffff"; - if (!doseGradientToColors.includes(strengthGradientColor)) { - doseGradientToColors.push(strengthGradientColor); - } - - // Check if the doseSeries already has a series with the same name - // If it does, push the new data to that series - // If it doesn't, create a new series - const existingSeries = doseSeries.find( - (series) => series.name === strength, - ); - - const roaUnit = match[3] ? ` (${match[3]})` : ``; - if (existingSeries) { - const existingSeriesData = existingSeries.data as { - x: string; - y: number[]; - }[]; - - existingSeriesData.push({ - x: `${roa}${roaUnit}`, - y: [strengthMinNumber, strengthMaxNumber], - }); - return; - } + const range = parseDoseRangeMg(strengthData); + if (!range) return; - const dosePropertySeries = { - name: strength, - data: [ - { - x: `${roa}${roaUnit}`, - y: [strengthMinNumber, strengthMaxNumber], - }, - ], - }; - doseSeries.push(dosePropertySeries); + const point = { x: `${roa} (mg)`, y: range }; + const existingSeries = doseSeries.find( + (series) => series.name === strength, + ); + if (existingSeries) { + (existingSeries.data as (typeof point)[]).push(point); + return; } + + doseColors.push(doseColorData[strength as keyof typeof doseColorData]); + doseGradientToColors.push( + doseGradientData[strength as keyof typeof doseGradientData], + ); + doseSeries.push({ name: strength, data: [point] }); }); }); @@ -594,13 +544,21 @@ const addDosages = (drugData: Drug) => { Dosages - + {doseSeries.length > 0 && ( + <> + + Chart shows bounded mass ranges in mg. See the table for all + doses. + + + + )} {/* Dosage Text */} @@ -669,39 +627,37 @@ export default function DrugInfoCard({ // This is the array that will hold all the elements that will be displayed in the expanded row const elements = [] as React.JSX.Element[]; + const properties = drugData.properties ?? {}; // If there's a warning, display it first - if (drugData.properties) { - Object.keys(drugData.properties).forEach((property) => { - const importantProperties = [ - "avoid", - "warning", - "risks", - "adverseEffects", - "contraindications", - ]; - if (importantProperties.includes(property)) { - const propertyName = property.replace(/_/g, " "); - const propertyValue = - drugData.properties[property as keyof typeof drugData.properties]; - elements.push( - - - - - {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)}{" "} - {propertyValue} - - - - , - ); - } - }); - } + Object.keys(properties).forEach((property) => { + const importantProperties = [ + "avoid", + "warning", + "risks", + "adverseEffects", + "contraindications", + ]; + if (importantProperties.includes(property)) { + const propertyName = property.replace(/_/g, " "); + const propertyValue = properties[property as keyof typeof properties]; + elements.push( + + + + + {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)}{" "} + {propertyValue} + + + + , + ); + } + }); // Always push the drug name since it's the one required field elements.push( @@ -755,7 +711,7 @@ export default function DrugInfoCard({ ); } - if (drugData.properties.summary) { + if (properties.summary) { // Take the category list, capitalize each word, and join them with commas elements.push( @@ -765,78 +721,73 @@ export default function DrugInfoCard({ Summary - - {addDictionaryDefs(drugData.properties.summary)} - + {addDictionaryDefs(properties.summary)} , ); } - if (drugData.properties) { - Object.keys(drugData.properties).forEach((property) => { - const duplicatedProperties = [ - "avoid", - "name", - "summary", - "categories", - "aliases", - "afterEffects", - "dose", - "duration", - "onset", - "effects", - "after-effects", - "pweffects", - "combos", - "sources", - "links", - "warning", - ]; - if (duplicatedProperties.includes(property)) { - return; - } - const propertyName = property.replace(/_/g, " "); - const propertyValue = - drugData.properties[property as keyof typeof drugData.properties]; - if (propertyName === "experiences") { - elements.push( - - - - - {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)} - - - - Erowid Experience Vault - - - - - , - ); - } else { - elements.push( - - - - - {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)} - - {propertyValue} - - - , - ); - } - }); - } + Object.keys(properties).forEach((property) => { + const duplicatedProperties = [ + "avoid", + "name", + "summary", + "categories", + "aliases", + "afterEffects", + "dose", + "duration", + "onset", + "effects", + "after-effects", + "pweffects", + "combos", + "sources", + "links", + "warning", + ]; + if (duplicatedProperties.includes(property)) { + return; + } + const propertyName = property.replace(/_/g, " "); + const propertyValue = properties[property as keyof typeof properties]; + if (propertyName === "experiences") { + elements.push( + + + + + {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)} + + + + Erowid Experience Vault + + + + + , + ); + } else { + elements.push( + + + + + {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)} + + {propertyValue} + + + , + ); + } + }); // Dose note comes before dosage if (drugData.dose_note) { diff --git a/src/components/__tests__/AppealPage.test.tsx b/src/components/__tests__/AppealPage.test.tsx index 81a4fd7..c3cab60 100644 --- a/src/components/__tests__/AppealPage.test.tsx +++ b/src/components/__tests__/AppealPage.test.tsx @@ -112,6 +112,50 @@ jest.mock("@/utils/keycloak", () => ({ const fetchMock = jest.fn(); const originalFetch = global.fetch; + +test("keeps a still-valid token without repeating the ban check on each interval", async () => { + jest.useFakeTimers(); + const access = validToken(); + sessionStorage.setItem("kc_token", access); + fetchMock + .mockResolvedValueOnce(reply({ banned: false })) + .mockResolvedValueOnce(reply({})); + render(); + expect( + await screen.findByRole("heading", { name: "Not Banned" }), + ).toBeVisible(); + await act(() => jest.advanceTimersByTimeAsync(90000)); + expect(sessionStorage.getItem("kc_token")).toBe(access); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + +test("avoids overlapping ban checks when the token refreshes during a slow request", async () => { + jest.useFakeTimers(); + sessionStorage.setItem("kc_token", token(Date.now() / 1000 + 75)); + sessionStorage.setItem("kc_refresh_token", "refresh"); + let completeBanCheck: (value: ReturnType) => void = () => {}; + const pendingBanCheck = new Promise>((resolve) => { + completeBanCheck = resolve; + }); + fetchMock.mockImplementation(async (url: string) => { + if (url === "/api/v2/users/banned") return pendingBanCheck; + if (url === "/api/v2/keycloak/refresh") + return reply({ access_token: validToken() }); + return reply({}); + }); + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await act(() => jest.advanceTimersByTimeAsync(30000)); + expect( + fetchMock.mock.calls.filter(([url]) => url === "/api/v2/users/banned"), + ).toHaveLength(1); + await act(async () => { + completeBanCheck(reply({ banned: false })); + }); + expect( + await screen.findByRole("heading", { name: "Not Banned" }), + ).toBeVisible(); +}); const token = (exp: number) => `header.${btoa(JSON.stringify({ exp }))}.signature`; const validToken = () => token(Date.now() / 1000 + 3600); @@ -136,12 +180,103 @@ beforeEach(() => { }); afterEach(() => { + jest.restoreAllMocks(); jest.useRealTimers(); global.fetch = originalFetch; sessionStorage.clear(); window.history.replaceState({}, "", "/"); }); +test("exchanges a code when the server supplies only an access token", async () => { + window.history.replaceState({}, "", "/appeal?code=minimal"); + fetchMock + .mockResolvedValueOnce(reply({ access_token: validToken() })) + .mockResolvedValueOnce(reply({ banned: false })) + .mockResolvedValueOnce(reply({ id: "history" })); + render(); + expect( + await screen.findByRole("heading", { name: "Appeal History" }), + ).toBeVisible(); + expect(screen.getByText("Unknown")).toBeVisible(); + expect(sessionStorage.getItem("kc_refresh_token")).toBeNull(); + expect(sessionStorage.getItem("kc_id_token")).toBeNull(); +}); + +test("keeps a denied appeal locked when no decision timestamp is available", async () => { + sessionStorage.setItem("kc_token", validToken()); + fetchMock + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce( + reply({ id: "denied", status: "DENIED", created_at: daysAgo(1) }), + ); + render(); + expect( + await screen.findByRole("heading", { name: "Your Appeal Status" }), + ).toBeVisible(); + expect(screen.queryByRole("button", { name: "Submit Appeal" })).toBeNull(); +}); + +test("shares a background token refresh with an appeal submission", async () => { + jest.useFakeTimers(); + sessionStorage.setItem("kc_token", token(Date.now() / 1000 + 75)); + sessionStorage.setItem("kc_refresh_token", "refresh"); + let completeRefresh: (value: ReturnType) => void = () => {}; + const pendingRefresh = new Promise>((resolve) => { + completeRefresh = resolve; + }); + fetchMock.mockImplementation(async (url: string) => { + if (url === "/api/v2/keycloak/refresh") return pendingRefresh; + if (url === "/api/v2/users/banned") return reply({ banned: true }); + if (url === "/api/v2/appeals/create") + return reply({ message: "Submission success" }); + return reply({}); + }); + 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" }, + }); + } + await act(() => jest.advanceTimersByTimeAsync(30000)); + fireEvent.click(submit); + expect( + fetchMock.mock.calls.filter(([url]) => url === "/api/v2/keycloak/refresh"), + ).toHaveLength(1); + await act(async () => { + completeRefresh(reply({ access_token: validToken() })); + }); + expect(await screen.findByText("Submission success")).toBeVisible(); + expect( + fetchMock.mock.calls.filter(([url]) => url === "/api/v2/appeals/create"), + ).toHaveLength(1); + expect( + fetchMock.mock.calls.filter(([url]) => url === "/api/v2/keycloak/refresh"), + ).toHaveLength(1); +}); + +test("handles credentials removed between initialization and the ban check", async () => { + const getItem = Storage.prototype.getItem; + let tokenReads = 0; + jest.spyOn(Storage.prototype, "getItem").mockImplementation(function ( + this: Storage, + key: string, + ) { + if (key === "kc_token") { + tokenReads += 1; + return tokenReads === 1 ? validToken() : null; + } + return getItem.call(this, key); + }); + render(); + await waitFor(() => expect(tokenReads).toBeGreaterThan(1)); + expect(fetchMock).not.toHaveBeenCalled(); +}); + test.each(["missing token", "network error"])( "returns to login when the code exchange fails with %s", async (failure) => { @@ -403,6 +538,7 @@ test("disables reminders for a newly submitted appeal", async () => { }); test("submits an appeal and refreshes its status", async () => { + jest.useFakeTimers(); sessionStorage.setItem("kc_token", validToken()); fetchMock .mockResolvedValueOnce(reply({ banned: true })) @@ -426,4 +562,7 @@ test("submits an appeal and refreshes its status", async () => { expect(await screen.findByText("Under Review")).toBeVisible(); expect(screen.getByText("Your appeal has been submitted.")).toBeVisible(); expect(fetchMock).toHaveBeenCalledTimes(5); + await act(() => jest.advanceTimersByTimeAsync(3000)); + expect(screen.queryByText("Your appeal has been submitted.")).toBeNull(); + expect(screen.getByText("Under Review")).toBeVisible(); }); diff --git a/src/components/__tests__/Document.test.tsx b/src/components/__tests__/Document.test.tsx new file mode 100644 index 0000000..381f655 --- /dev/null +++ b/src/components/__tests__/Document.test.tsx @@ -0,0 +1,59 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import Document from "@/pages/_document"; +import RootLayout, { metadata } from "@/app/layout"; +import BlogSuggestion from "../BlogSuggestion"; +import Github from "../Github"; +import Patreon from "../Patreon"; + +jest.mock("next/document", () => ({ + __esModule: true, + default: class {}, + Html: ({ children }: { children: ReactNode }) => {children}, + Head: ({ children }: { children: ReactNode }) =>
{children}
, + Main: () =>
Page content
, + NextScript: () => , +})); +jest.mock( + "next/script", + () => + function Script({ src }: { src: string }) { + return ; + }, +); + +test("renders the document shell, favicons and browser scripts", () => { + const html = renderToStaticMarkup(Document.prototype.render()); + expect(html).toContain('rel="icon"'); + expect(html).toContain("Page content"); + expect(html).toContain("/next-runtime.js"); + expect(html).toContain("bootstrap.bundle.min.js"); +}); + +test("renders the app layout with metadata and page content", () => { + const html = renderToStaticMarkup( + +
Hello
+
, + ); + expect(html).toContain('lang="en"'); + expect(html).toContain("
Hello
"); + expect(metadata.title).toBe("TripSit"); +}); + +test("uses the blog root during server rendering", () => { + expect( + renderToStaticMarkup( + , + ), + ).toContain('href="https://blog.tripsit.me/"'); +}); + +test("renders community support links", () => { + expect(renderToStaticMarkup()).toContain( + 'href="https://www.github.com/tripsit"', + ); + expect(renderToStaticMarkup()).toContain( + 'href="https://www.patreon.com/bePatron?u=899550"', + ); +}); diff --git a/src/components/__tests__/DrugInfo.test.tsx b/src/components/__tests__/DrugInfo.test.tsx index c1a962d..b0a308d 100644 --- a/src/components/__tests__/DrugInfo.test.tsx +++ b/src/components/__tests__/DrugInfo.test.tsx @@ -57,6 +57,21 @@ const drug = { properties: {}, } as Drug; +test("renders older database records without properties", () => { + render( + , + ); + expect(screen.getByText("Legacy")).toBeVisible(); +}); + test.each(["EXAMPLE", "example compound", "ALIAS"])( "looks up a factsheet by %s", (name) => { @@ -95,24 +110,24 @@ test.each([ ).toBeVisible(); }); -test("keeps dose text and charts the ranges for each route", () => { +test("keeps all doses in the table and charts only bounded mass ranges", () => { render( , ); expect(screen.getByText("Example dose note")).toBeVisible(); expect(screen.getByText("unknown")).toBeVisible(); - expect(screen.getByText("1-2mg")).toBeVisible(); + expect(screen.getByText("100–200 µg")).toBeVisible(); expect(screen.getByLabelText("Tooltip for common")).toHaveTextContent( - "oral (mg): 1 - 2", + "oral (mg): 0.1 - 0.2", ); expect( JSON.parse(screen.getByLabelText("Chart series").textContent ?? ""), @@ -120,21 +135,21 @@ test("keeps dose text and charts the ranges for each route", () => { { name: "common", data: [ - { x: "oral (mg)", y: [1, 2] }, - { x: "insufflated (mg)", y: [3, 4] }, + { x: "oral (mg)", y: [0.1, 0.2] }, + { x: "insufflated (mg)", y: [1, 2] }, ], }, ]); }); -test("keeps unplottable doses in the table", () => { +test("omits the dose chart when no ranges can be plotted", () => { render( , ); expect(screen.getByText("unknown")).toBeVisible(); - expect(screen.getByLabelText("Chart series")).toHaveTextContent("[]"); + expect(screen.queryByLabelText("Chart series")).toBeNull(); }); test("converts onset to hours and offsets duration and after effects", () => { @@ -227,6 +242,33 @@ test("displays after effects without onset or duration data", () => { ); }); +test("renders fractional hour durations without after effects", () => { + render( + , + ); + expect(screen.getByText("1h 30m to 2h 30m")).toBeVisible(); + expect(screen.queryByText("Aliases")).toBeNull(); +}); + +test("renders an empty factsheet result while the database is loading", () => { + jest + .mocked(useRouter) + .mockReturnValue({ query: { drug: "example" } } as unknown as ReturnType< + typeof useRouter + >); + jest.mocked(useDrugs).mockReturnValue({} as ReturnType); + render(); + expect( + screen.getByRole("heading", { name: /drug not found/i }), + ).toBeVisible(); +}); + test("renders optional references, warnings, and combination notes", () => { render( { experiences: "https://example.test/experiences", }, combos: { + absent: undefined, mdma: { status: "Caution", note: "(Sample combination note)" }, alcohol: { status: "Dangerous" }, } as Drug["combos"], diff --git a/src/components/__tests__/DynamicWidgets.test.tsx b/src/components/__tests__/DynamicWidgets.test.tsx new file mode 100644 index 0000000..47980ed --- /dev/null +++ b/src/components/__tests__/DynamicWidgets.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, act } from "@testing-library/react"; +import type { ComponentType } from "react"; +import dynamic from "next/dynamic"; + +jest.mock("next/dynamic", () => jest.fn(() => () => null)); +jest.mock("particles-bg", () => ({ __esModule: true, default: () => null })); +jest.mock("react-apexcharts", () => ({ + __esModule: true, + default: () => null, +})); +jest.mock("@widgetbot/react-embed", () => ({ + __esModule: true, + default: () => null, +})); +jest.mock("@heroui/react", () => ({})); + +test.each([ + "@/components/Particles", + "@/components/DrugInfo", + "@/components/Webchat", +])("loads the browser-only dependency in %s", async (moduleName) => { + jest.mocked(dynamic).mockClear(); + jest.isolateModules(() => { + jest.requireActual(moduleName); + }); + const [loader, options] = jest.mocked(dynamic).mock.calls[0] as unknown as [ + () => Promise<{ default: ComponentType }>, + { ssr: boolean; loading?: ComponentType }, + ]; + expect(options.ssr).toBe(false); + const dependency = await loader(); + expect(dependency.default).toEqual(expect.any(Function)); + if (options.loading) { + const Loading = options.loading; + render(); + expect(screen.getByText("Loading...")).toBeVisible(); + } +}); + +test("selects the production WidgetBot server", async () => { + jest.replaceProperty(process, "env", { + ...process.env, + NODE_ENV: "production", + }); + try { + let Webchat: ComponentType = () => null; + jest.isolateModules(() => { + Webchat = jest.requireActual("@/components/Webchat").default; + }); + const widget = jest.fn(() => null); + jest.mocked(dynamic).mockReturnValue(widget); + jest.isolateModules(() => { + Webchat = jest.requireActual("@/components/Webchat").default; + }); + await act(async () => { + render(); + }); + expect(widget).toHaveBeenCalledWith( + expect.objectContaining({ + server: "179641883222474752", + channel: "1021385869262864464", + shard: "https://emerald.widgetbot.io", + }), + undefined, + ); + } finally { + jest.restoreAllMocks(); + } +}); diff --git a/src/components/__tests__/FactsheetColumns.test.tsx b/src/components/__tests__/FactsheetColumns.test.tsx new file mode 100644 index 0000000..ee509e8 --- /dev/null +++ b/src/components/__tests__/FactsheetColumns.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import type { ReactNode } from "react"; +import type { Drug } from "tripsit_drug_db"; +import { useDrugs } from "@/utils/drugs"; +import Factsheets from "../Factsheets"; + +jest.mock("@/utils/drugs", () => ({ useDrugs: jest.fn() })); +jest.mock("@heroui/react", () => { + const Wrapper = ({ children }: { children: ReactNode }) => ( +
{children}
+ ); + return { + cn: (...classes: string[]) => classes.filter(Boolean).join(" "), + Accordion: Object.assign(Wrapper, { + Item: ({ + children, + }: { + children: (state: { isExpanded: boolean }) => ReactNode; + }) => ( + <> + {children({ isExpanded: false })} + {children({ isExpanded: true })} + + ), + Heading: Wrapper, + Trigger: Wrapper, + Panel: Wrapper, + Body: Wrapper, + }), + }; +}); +jest.mock("material-react-table", () => ({ + useMaterialReactTable: (options: unknown) => options, + MaterialReactTable: ({ + table, + }: { + table: { + data: Drug[]; + columns: { + id?: string; + accessorKey?: keyof Drug; + accessorFn?: (row: Drug) => unknown; + Cell?: (props: { cell: { getValue: () => unknown } }) => ReactNode; + }[]; + renderTopToolbarCustomActions: () => ReactNode; + }; + }) => ( + <> + {table.renderTopToolbarCustomActions()} + {table.data.map((row) => ( +
+ {table.columns.map((column) => { + const value = column.accessorFn + ? column.accessorFn(row) + : row[column.accessorKey as keyof Drug]; + return ( +
+ {column.Cell + ? column.Cell({ cell: { getValue: () => value } }) + : String(value ?? "")} +
+ ); + })} +
+ ))} + + ), +})); + +test("formats all optional factsheet columns and tolerates missing values", () => { + const refetch = jest.fn(); + jest.mocked(useDrugs).mockReturnValue({ + data: [ + { + name: "full", + pretty_name: "Full", + aliases: ["Alias"], + properties: { + "test-kits": "Reagent result", + categories: ["tentative"], + summary: "Summary", + }, + formatted_effects: ["Example effect"], + pweffects: { Nausea: "example" }, + }, + { name: "minimal", pretty_name: "Minimal", properties: {} }, + ], + refetch, + } as unknown as ReturnType); + render(); + expect(screen.getByText("Reagent result")).toBeVisible(); + expect(screen.getByText("Example effect")).toBeVisible(); + expect(screen.getByText("Nausea")).toBeVisible(); + expect(screen.getByText("Minimal")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Refresh Data" })); + expect(refetch).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/__tests__/Factsheets.test.tsx b/src/components/__tests__/Factsheets.test.tsx index 2612e5f..8e102d8 100644 --- a/src/components/__tests__/Factsheets.test.tsx +++ b/src/components/__tests__/Factsheets.test.tsx @@ -11,9 +11,12 @@ const drugs = { name: "example", pretty_name: "Example compound", aliases: ["Test alias"], + formatted_effects: ["Sample effects"], + pweffects: { Nausea: "example" }, properties: { summary: "Example summary", categories: ["Stimulant", "tentative", "supplement"], + "test-kits": "Sample reagent results", }, }, minimal: { name: "minimal", pretty_name: "Minimal compound", properties: {} }, diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index c2852b8..d742bce 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -6,6 +6,13 @@ test("opens the desktop menu and dismisses it on outside interaction or Escape", const nav = within(screen.getByRole("navigation", { name: "Main" })); const resources = nav.getByRole("button", { name: "Resources" }); fireEvent.click(resources); + fireEvent.pointerDown(resources); + expect(resources).toHaveAttribute("aria-expanded", "true"); + fireEvent.keyDown(document, { key: "Enter" }); + expect(resources).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(resources); + expect(resources).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(resources); expect(resources).toHaveAttribute("aria-expanded", "true"); expect(nav.getByRole("link", { name: "Factsheets" })).toHaveAttribute( "href", @@ -18,6 +25,18 @@ test("opens the desktop menu and dismisses it on outside interaction or Escape", expect(nav.queryByRole("link", { name: "Factsheets" })).toBeNull(); }); +test("clears a pending hover timer when navigation is unmounted", () => { + jest.useFakeTimers(); + try { + const { unmount } = render(
); + fireEvent.mouseLeave(screen.getByRole("button", { name: "Resources" })); + unmount(); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } +}); + test("gives the pointer time to cross from the menu trigger to the panel", () => { jest.useFakeTimers(); try { diff --git a/src/components/__tests__/HomeEffects.test.tsx b/src/components/__tests__/HomeEffects.test.tsx index adf5520..c1d7a83 100644 --- a/src/components/__tests__/HomeEffects.test.tsx +++ b/src/components/__tests__/HomeEffects.test.tsx @@ -25,6 +25,41 @@ test("uses animation defaults when only the ending count is supplied", () => { expect(useCountUp).toHaveBeenLastCalledWith( expect.objectContaining({ start: 0, end: 25, duration: 5, delay: 1 }), ); + const options = jest.mocked(useCountUp).mock.calls.at(-1)?.[0]; + expect(options?.easingFn?.(0, 5, 10, 2)).toBe(5); + expect(options?.easingFn?.(2, 5, 10, 2)).toBe(15); +}); + +test("tolerates missing hero elements during inactivity", () => { + jest.useFakeTimers(); + try { + render(); + act(() => jest.advanceTimersByTime(10)); + fireEvent.mouseMove(document); + act(() => jest.advanceTimersByTime(10000)); + fireEvent.keyDown(document); + expect(document.getElementById("hero")).toBeNull(); + } finally { + jest.useRealTimers(); + } +}); + +test("waits until inactivity exceeds ten seconds before fading", () => { + jest.useFakeTimers(); + try { + render( + <> + +
+ + , + ); + fireEvent.mouseMove(document); + act(() => jest.advanceTimersByTime(10000)); + expect(screen.getByRole("region", { name: "Hero" }).style.opacity).toBe(""); + } finally { + jest.useRealTimers(); + } }); test("loads the Discord member count and starts its counter on the animation event", async () => { diff --git a/src/components/__tests__/ProfileButton.test.tsx b/src/components/__tests__/ProfileButton.test.tsx index f5e7fa1..24b116d 100644 --- a/src/components/__tests__/ProfileButton.test.tsx +++ b/src/components/__tests__/ProfileButton.test.tsx @@ -1,6 +1,11 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import ProfileButton from "../ProfileButton"; +jest.mock("@/utils/keycloak", () => ({ + getLoginUrl: () => "#login", + getPostLogoutUrl: () => "#logout", +})); + const originalFetch = global.fetch; const fetchMock = jest.fn(); @@ -14,6 +19,58 @@ afterEach(() => { global.fetch = originalFetch; sessionStorage.clear(); localStorage.clear(); + window.history.replaceState({}, "", "/"); +}); + +test("starts login when a guest clicks the profile button", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Login" })); + expect(window.location.hash).toBe("#login"); +}); + +test("falls back to the guest avatar when credentials disappear before the avatar fetch", () => { + const originalGetItem = Storage.prototype.getItem; + let tokenReads = 0; + jest.spyOn(Storage.prototype, "getItem").mockImplementation(function ( + this: Storage, + key: string, + ) { + if (key === "kc_token") { + tokenReads += 1; + return tokenReads === 1 ? "temporary-token" : null; + } + return originalGetItem.call(this, key); + }); + render(); + expect( + screen.getByRole("button", { name: "Account menu" }).style.backgroundImage, + ).toContain("/assets/img/guest.png"); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +test("closes the menu on a second click and clears session tokens on logout", async () => { + sessionStorage.setItem("kc_token", "token"); + sessionStorage.setItem("kc_refresh_token", "refresh"); + sessionStorage.setItem("kc_id_token", "id"); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ avatarUrl: "/avatar.png" }), + }); + render(); + const account = screen.getByRole("button", { name: "Account menu" }); + await waitFor(() => + expect(account.style.backgroundImage).toContain("/avatar.png"), + ); + fireEvent.click(account); + fireEvent.mouseDown(account); + expect(screen.getByRole("button", { name: "Logout" })).toBeVisible(); + fireEvent.click(account); + expect(screen.queryByRole("button", { name: "Logout" })).toBeNull(); + fireEvent.click(account); + fireEvent.click(screen.getByRole("button", { name: "Logout" })); + expect(sessionStorage).toHaveLength(0); + expect(screen.getByRole("button", { name: "Login" })).toBeVisible(); + expect(window.location.hash).toBe("#logout"); }); test("reacts to session changes and dismisses the account menu outside", async () => { diff --git a/src/components/__tests__/UiAdapters.test.tsx b/src/components/__tests__/UiAdapters.test.tsx new file mode 100644 index 0000000..1013cb1 --- /dev/null +++ b/src/components/__tests__/UiAdapters.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import AccordionSection from "../AccordionSection"; +import Particles from "../Particles"; + +jest.mock("@heroui/react", () => { + const React = jest.requireActual("react"); + const Wrapper = ({ children }: { children: ReactNode }) => ( +
{children}
+ ); + return { + cn: (...classes: string[]) => classes.filter(Boolean).join(" "), + Accordion: { + Item: ({ + children, + }: { + children: (state: { isExpanded: boolean }) => ReactNode; + }) => { + const [isExpanded, setExpanded] = React.useState(false); + return ( +
+ + {children({ isExpanded })} +
+ ); + }, + Heading: Wrapper, + Trigger: ({ children, ...props }: { children: ReactNode }) => ( + + ), + Panel: Wrapper, + Body: Wrapper, + }, + Dropdown: Object.assign(Wrapper, { + Trigger: Wrapper, + Popover: Wrapper, + Menu: ({ + items, + children, + onAction, + }: { + items: { key: string; label: string }[]; + children: (item: { key: string; label: string }) => ReactNode; + onAction: (key: string) => void; + }) => ( +
+ {items.map((item) => ( +
onAction(item.key)}> + {children(item)} +
+ ))} + +
+ ), + Item: ({ children }: { children: ReactNode }) => ( + + ), + }), + }; +}); + +jest.mock( + "next/dynamic", + () => () => + function Background({ type, color }: { type: string; color?: string }) { + return ( + + {type} {color} + + ); + }, +); + +test("provides a default accordion indicator and exposes expansion state", () => { + const { container } = render( + Section content, + ); + expect( + screen.getByRole("button", { name: "Details" }).querySelector("svg"), + ).toBeInTheDocument(); + expect(container.querySelector("[data-open]")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Toggle section" })); + expect(container.querySelector('[data-open="true"]')).toBeInTheDocument(); +}); + +test("passes expanded state to a caller's accordion indicator", () => { + render( + Icon} + indicator={({ isOpen }) => (isOpen ? "Expanded" : "Collapsed")} + > + Content + , + ); + expect(screen.getByText("Icon")).toBeVisible(); + expect(screen.getByText("Collapsed")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Toggle section" })); + expect(screen.getByText("Expanded")).toBeVisible(); +}); + +test("changes the background effect and ignores unknown selections", () => { + render(); + expect(screen.getByLabelText("Background effect")).toHaveTextContent( + "cobweb #FFFFFF", + ); + fireEvent.click(screen.getByRole("button", { name: "Tiny Tracers" })); + expect(screen.getByLabelText("Background effect")).toHaveTextContent("lines"); + fireEvent.click(screen.getByRole("button", { name: "Unknown effect" })); + expect(screen.getByLabelText("Background effect")).toHaveTextContent("lines"); + fireEvent.click(screen.getByRole("button", { name: "Connections" })); + expect(screen.getByLabelText("Background effect")).toHaveTextContent( + "cobweb #FFFFFF", + ); +}); diff --git a/src/components/comboChecker/__tests__/comboDataFallback.test.ts b/src/components/comboChecker/__tests__/comboDataFallback.test.ts new file mode 100644 index 0000000..808ea7d --- /dev/null +++ b/src/components/comboChecker/__tests__/comboDataFallback.test.ts @@ -0,0 +1,12 @@ +import { getCombo } from "../comboData"; + +jest.mock("tripsit_drug_db/combos.json", () => ({ + example: { + other: { status: "Unrecognized status" }, + }, +})); + +test("uses unknown risk and an empty note for an unrecognized database status", () => { + expect(getCombo("example", "other")).toEqual({ risk: "unknown", note: "" }); + expect(getCombo("other", "example")).toEqual({ risk: "unknown", note: "" }); +}); diff --git a/src/pages/appeal.tsx b/src/pages/appeal.tsx index a6d7c50..15021ef 100644 --- a/src/pages/appeal.tsx +++ b/src/pages/appeal.tsx @@ -29,17 +29,14 @@ const AppealPage: React.FC = () => { const [reminding, setReminding] = useState(false); const [remindMessage, setRemindMessage] = useState(null); - // Use useRef to store the interval ID so we can clear it - const refreshIntervalRef = useRef(null); - // Use ref to prevent concurrent token refreshes - const isRefreshingRef = useRef(false); + // Share a pending refresh so concurrent requests use the same tokens. const refreshPromiseRef = useRef | null>(null); // Use ref to prevent concurrent checkBan calls const isCheckingBanRef = useRef(false); async function refreshTokenIfNeeded() { // If already refreshing, wait for that promise to complete - if (isRefreshingRef.current && refreshPromiseRef.current) { + if (refreshPromiseRef.current) { return refreshPromiseRef.current; } @@ -56,8 +53,7 @@ const AppealPage: React.FC = () => { // If token expires within 60 seconds, refresh it if (expiresAt - now < 60000) { if (refreshToken) { - // Set flag and create promise to prevent concurrent refreshes - isRefreshingRef.current = true; + // Share the promise to prevent concurrent refreshes. refreshPromiseRef.current = (async () => { try { const refreshRes = await fetch("/api/v2/keycloak/refresh", { @@ -89,7 +85,6 @@ const AppealPage: React.FC = () => { sessionStorage.removeItem("kc_refresh_token"); return null; } finally { - isRefreshingRef.current = false; refreshPromiseRef.current = null; } })(); @@ -156,8 +151,7 @@ const AppealPage: React.FC = () => { } function canReappeal(appeal: Appeal | null): boolean { - if (!appeal || appeal.status !== "DENIED" || !appeal.decided_at) - return false; + if (!appeal?.decided_at) return false; const decidedAt = new Date(appeal.decided_at); @@ -174,9 +168,7 @@ const AppealPage: React.FC = () => { return decidedAt <= threeMonthsAgo; } - function canShowReminder(appeal: Appeal | null): boolean { - if (!appeal || appeal.status !== "RECEIVED") return false; - + function canShowReminder(appeal: Appeal): boolean { const createdAt = new Date(appeal.created_at); // 30 seconds for development @@ -266,11 +258,9 @@ const AppealPage: React.FC = () => { // Appeal is still pending (RECEIVED status) setBanStatus("has_appeal"); } - } else if (!hasAppeal) { + } else { // No appeal found - user can create one setBanStatus("can_appeal"); - } else { - setBanStatus("unknown"); } } catch { setBanStatus("unknown"); @@ -406,36 +396,21 @@ const AppealPage: React.FC = () => { // Set up background token refresh when we have a token useEffect(() => { - if (token) { - // Clear any existing interval - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); + if (!token) return; + // Set up new interval to refresh every 30 seconds + const refreshInterval = setInterval(async () => { + const refreshedToken = await refreshTokenIfNeeded(); + // Only update token state if it actually changed to prevent unnecessary re-renders + if (refreshedToken && refreshedToken !== token) { + setToken(refreshedToken); + } else if (!refreshedToken) { + // Token is now invalid, clear it + setToken(null); + clearInterval(refreshInterval); } - - // Set up new interval to refresh every 30 seconds - refreshIntervalRef.current = setInterval(async () => { - const refreshedToken = await refreshTokenIfNeeded(); - // Only update token state if it actually changed to prevent unnecessary re-renders - if (refreshedToken && refreshedToken !== token) { - setToken(refreshedToken); - } else if (!refreshedToken && token) { - // Token is now invalid, clear it - setToken(null); - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); - } - } - // If refreshedToken === token, do nothing (no state change needed) - }, 30000); // 30 seconds - } - - // Cleanup function - return () => { - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); - refreshIntervalRef.current = null; - } - }; + // If refreshedToken === token, do nothing (no state change needed) + }, 30000); // 30 seconds + return () => clearInterval(refreshInterval); }, [token]); // Fetch ban status when we have a token @@ -850,37 +825,6 @@ const AppealPage: React.FC = () => { - {latestAppeal?.status === "ACCEPTED" && ( -
-
-
- Appeal - Accepted! -
-
-
- {latestAppeal?.response_message && ( -

- Moderator Response: -
- - {latestAppeal.response_message} - -

- )} - - - Join Discord - -
-
- )} - {latestAppeal?.status === "DENIED" && (
diff --git a/src/utils/__tests__/discordStats.test.ts b/src/utils/__tests__/discordStats.test.ts index 6ae022d..6718a3d 100644 --- a/src/utils/__tests__/discordStats.test.ts +++ b/src/utils/__tests__/discordStats.test.ts @@ -77,6 +77,28 @@ describe("getDiscordStats", () => { expect(mockedGet).not.toHaveBeenCalled(); }); + it("reads the configured bot token when called without arguments", async () => { + jest.replaceProperty(process, "env", { + ...process.env, + DISCORD_CLIENT_TOKEN: "configured", + }); + mockedGet.mockResolvedValue({ data: {} }); + try { + await expect(getDiscordStats()).resolves.toEqual({ + memberCount: 0, + presenceCount: 0, + }); + expect(mockedGet).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { Authorization: "Bot configured" }, + }), + ); + } finally { + jest.restoreAllMocks(); + } + }); + it("returns null when Discord fails", async () => { mockedGet.mockRejectedValue(new Error("timeout")); diff --git a/src/utils/__tests__/drugs.test.ts b/src/utils/__tests__/drugs.test.ts index d2a0717..087631b 100644 --- a/src/utils/__tests__/drugs.test.ts +++ b/src/utils/__tests__/drugs.test.ts @@ -1,4 +1,8 @@ -import { DRUGS_URL, fetchDrugs } from "../drugs"; +import { DRUGS_STALE_TIME, DRUGS_URL, fetchDrugs } from "../drugs"; + +test("keeps drug downloads fresh for thirty minutes", () => { + expect(DRUGS_STALE_TIME).toBe(30 * 60 * 1000); +}); describe("fetchDrugs", () => { const originalFetch = global.fetch; diff --git a/src/utils/__tests__/env.config.test.ts b/src/utils/__tests__/env.config.test.ts index df6bce6..d6abf37 100644 --- a/src/utils/__tests__/env.config.test.ts +++ b/src/utils/__tests__/env.config.test.ts @@ -17,6 +17,10 @@ test.each([ expect(env.NODE_ENV).toBe(expected); expect(env.DISCORD_GUILD_ID).toBe("test-guild"); expect(global.env).toBe(env); + expect( + jest.requireActual("../env.config") + .default, + ).toBe(env); }); } finally { global.env = previousEnv; diff --git a/src/utils/__tests__/keycloak.server.test.ts b/src/utils/__tests__/keycloak.server.test.ts new file mode 100644 index 0000000..8dde079 --- /dev/null +++ b/src/utils/__tests__/keycloak.server.test.ts @@ -0,0 +1,23 @@ +/** @jest-environment node */ +import { getLoginUrl, getPostLogoutUrl } from "../keycloak"; + +afterEach(() => jest.restoreAllMocks()); + +test("omits browser redirect origins when building authentication URLs on the server", () => { + jest.replaceProperty(process, "env", { + ...process.env, + NEXT_PUBLIC_KEYCLOAK_CLIENT_ID: "appeals", + }); + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + value: { getItem: () => null }, + }); + try { + expect(new URL(getLoginUrl()).searchParams.get("redirect_uri")).toBe(""); + expect( + new URL(getPostLogoutUrl()).searchParams.get("post_logout_redirect_uri"), + ).toBe(""); + } finally { + Reflect.deleteProperty(globalThis, "sessionStorage"); + } +}); diff --git a/src/utils/__tests__/parseDoseRangeMg.test.ts b/src/utils/__tests__/parseDoseRangeMg.test.ts new file mode 100644 index 0000000..bba5f6f --- /dev/null +++ b/src/utils/__tests__/parseDoseRangeMg.test.ts @@ -0,0 +1,32 @@ +import { parseDoseRangeMg } from "../parseDoseRangeMg"; + +test("inherits an omitted upper unit from the lower endpoint", () => { + expect(parseDoseRangeMg("1 mg - 2")).toEqual([1, 2]); +}); + +test("rejects a range whose upper endpoint overflows during conversion", () => { + expect(parseDoseRangeMg(`1-${"9".repeat(308)} g`)).toBeUndefined(); +}); + +describe("parseDoseRangeMg", () => { + it.each([ + ["800-1500ug", [0.8, 1.5]], + ["1.5-3mg", [1.5, 3]], + ["325mg-650mg", [325, 650]], + ["800 µg – 1.5 mg", [0.8, 1.5]], + ])("normalizes %s to milligrams", (source, expected) => { + expect(parseDoseRangeMg(source)).toEqual(expected); + }); + + it.each([ + "25-50ug/hr", + "25ug/hr-50ug/hr", + "3mg+", + "3mg", + "1-3", + "3mg-1mg", + "1-3mg/kg", + ])("leaves %s in the text table only", (source) => + expect(parseDoseRangeMg(source)).toBeUndefined(), + ); +}); diff --git a/src/utils/parseDoseRangeMg.ts b/src/utils/parseDoseRangeMg.ts new file mode 100644 index 0000000..53f0e25 --- /dev/null +++ b/src/utils/parseDoseRangeMg.ts @@ -0,0 +1,28 @@ +const massInMg = { ng: 0.000001, ug: 0.001, mg: 1, g: 1000 }; +type MassUnit = keyof typeof massInMg; + +export function parseDoseRangeMg(text: string): [number, number] | undefined { + // Require two explicit endpoints and at least one mass unit. + const endpoints = text + .toLowerCase() + .replace(/[\u00b5\u03bc]/gu, "u") + .split(/[-\u2013\u2014]/u); + if (endpoints.length !== 2) return; + + const quantity = /^(\d+(?:\.\d+)?|\.\d+)\s*(ng|ug|mg|g)?$/u; + const lower = quantity.exec(endpoints[0].trim()); + const upper = quantity.exec(endpoints[1].trim()); + if (!lower || !upper) return; + + const [, low, lowUnit] = lower; + const [, high, highUnit] = upper; + if (!lowUnit && !highUnit) return; + + const startUnit = (lowUnit || highUnit) as MassUnit; + const endUnit = (highUnit || lowUnit) as MassUnit; + const start = Number(low) * massInMg[startUnit]; + const end = Number(high) * massInMg[endUnit]; + if (Number.isFinite(start) && Number.isFinite(end) && start < end) { + return [start, end]; + } +}