From 23d45d63fa50b4a649558e3ff099d97f70e8d2a9 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Tue, 16 Jun 2026 11:25:08 -0400 Subject: [PATCH 1/2] test(frontend): add unit tests for remaining page/UI components Covers DataUpload, Variables, Authors, JsonViewer, PageHeader, PreviewDrawer, Sidebar, ProjectInfo, and AppShell (201 tests total). Closes #9. Co-Authored-By: Claude Sonnet 4.6 --- packages/frontend/tests/AppShell.test.tsx | 236 +++++++++ packages/frontend/tests/Authors.test.tsx | 364 ++++++++++++++ packages/frontend/tests/DataUpload.test.tsx | 453 ++++++++++++++++++ packages/frontend/tests/JsonViewer.test.tsx | 154 ++++++ packages/frontend/tests/PageHeader.test.tsx | 24 + .../frontend/tests/PreviewDrawer.test.tsx | 50 ++ packages/frontend/tests/ProjectInfo.test.tsx | 393 +++++++++++++++ packages/frontend/tests/Sidebar.test.tsx | 130 +++++ packages/frontend/tests/Variables.test.tsx | 422 ++++++++++++++++ 9 files changed, 2226 insertions(+) create mode 100644 packages/frontend/tests/AppShell.test.tsx create mode 100644 packages/frontend/tests/Authors.test.tsx create mode 100644 packages/frontend/tests/DataUpload.test.tsx create mode 100644 packages/frontend/tests/JsonViewer.test.tsx create mode 100644 packages/frontend/tests/PageHeader.test.tsx create mode 100644 packages/frontend/tests/PreviewDrawer.test.tsx create mode 100644 packages/frontend/tests/ProjectInfo.test.tsx create mode 100644 packages/frontend/tests/Sidebar.test.tsx create mode 100644 packages/frontend/tests/Variables.test.tsx diff --git a/packages/frontend/tests/AppShell.test.tsx b/packages/frontend/tests/AppShell.test.tsx new file mode 100644 index 0000000..327f9a7 --- /dev/null +++ b/packages/frontend/tests/AppShell.test.tsx @@ -0,0 +1,236 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import AppShell from "../src/components/AppShell"; + +// ── child component stubs ────────────────────────────────────────────────── +jest.mock("../src/components/Sidebar", () => ({ + __esModule: true, + default: ({ steps, canNavigateTo, onNavigate, onStartOver }: any) => ( + + ), +})); + +jest.mock("../src/components/PreviewDrawer", () => ({ + __esModule: true, + default: ({ onClose }: any) => ( +
+ +
+ ), +})); + +jest.mock("../src/pages/ProjectInfo", () => ({ + __esModule: true, + default: ({ onComplete }: any) => ( +
+ ProjectInfo page + +
+ ), + emptyProjectInfoSession: () => ({ name: "", description: "", optional: {}, optionalOpen: false }), + OPTIONAL_FIELDS: [], +})); + +jest.mock("../src/pages/DataUpload", () => ({ + __esModule: true, + default: ({ onComplete }: any) => ( +
+ DataUpload page + +
+ ), + emptyDataSession: { fileTexts: [], files: [] }, +})); + +jest.mock("../src/pages/Variables", () => ({ + __esModule: true, + default: ({ onComplete }: any) => ( +
+ Variables page + +
+ ), +})); + +jest.mock("../src/pages/Authors", () => ({ + __esModule: true, + default: ({ onComplete }: any) => ( +
+ Authors page + +
+ ), +})); + +jest.mock("../src/pages/Review", () => ({ + __esModule: true, + default: () =>
Review page
, +})); + +// ── fixtures ─────────────────────────────────────────────────────────────── + +function makeMeta() { + return { getMetadata: jest.fn().mockReturnValue({}) } as any; +} + +let meta: ReturnType; +let onStartOver: jest.Mock; + +beforeEach(() => { + meta = makeMeta(); + onStartOver = jest.fn(); +}); + +describe("AppShell", () => { + // ── initial render ──────────────────────────────────────────────────────── + + describe("initial render", () => { + test("starts on the ProjectInfo step", () => { + render(); + expect(screen.getByText("ProjectInfo page")).toBeInTheDocument(); + }); + + test("renders the Sidebar with all step labels", () => { + render(); + expect(screen.getByRole("button", { name: "Project Info" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Data" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Variables" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Authors" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Review" })).toBeInTheDocument(); + }); + + test("all steps after ProjectInfo are locked (disabled) initially", () => { + render(); + expect(screen.getByRole("button", { name: "Data" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Variables" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Authors" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Review" })).toBeDisabled(); + }); + }); + + // ── step navigation ─────────────────────────────────────────────────────── + + describe("step completion and navigation", () => { + test("completing ProjectInfo navigates to Data step", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Complete ProjectInfo" })); + + expect(screen.getByText("DataUpload page")).toBeInTheDocument(); + expect(screen.queryByText("ProjectInfo page")).not.toBeInTheDocument(); + }); + + test("completing Data step navigates to Variables", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Complete ProjectInfo" })); + await userEvent.click(screen.getByRole("button", { name: "Complete DataUpload" })); + + expect(screen.getByText("Variables page")).toBeInTheDocument(); + }); + + test("completing Variables navigates to Authors", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Complete ProjectInfo" })); + await userEvent.click(screen.getByRole("button", { name: "Complete DataUpload" })); + await userEvent.click(screen.getByRole("button", { name: "Complete Variables" })); + + expect(screen.getByText("Authors page")).toBeInTheDocument(); + }); + + test("completing Authors navigates to Review", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Complete ProjectInfo" })); + await userEvent.click(screen.getByRole("button", { name: "Complete DataUpload" })); + await userEvent.click(screen.getByRole("button", { name: "Complete Variables" })); + await userEvent.click(screen.getByRole("button", { name: "Complete Authors" })); + + expect(screen.getByText("Review page")).toBeInTheDocument(); + }); + + test("clicking an unlocked Sidebar step navigates to it", async () => { + render(); + // Complete ProjectInfo to unlock Data + await userEvent.click(screen.getByRole("button", { name: "Complete ProjectInfo" })); + // Go back to ProjectInfo via sidebar + await userEvent.click(screen.getByRole("button", { name: "Project Info" })); + expect(screen.getByText("ProjectInfo page")).toBeInTheDocument(); + }); + + test("clicking a disabled Sidebar step does not navigate", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Data" })); + // Still on ProjectInfo + expect(screen.getByText("ProjectInfo page")).toBeInTheDocument(); + }); + }); + + // ── existing project (skip Data) ────────────────────────────────────────── + + describe("existing project", () => { + test("completing ProjectInfo skips Data and navigates to Variables", async () => { + const existingFile = new File(['{"name":"Study"}'], "dataset_description.json"); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Complete ProjectInfo" })); + + expect(screen.getByText("Variables page")).toBeInTheDocument(); + expect(screen.queryByText("DataUpload page")).not.toBeInTheDocument(); + }); + }); + + // ── preview pill ────────────────────────────────────────────────────────── + + describe("preview pill", () => { + test("preview pill is visible on non-review steps", () => { + render(); + expect(screen.getByRole("button", { name: "Open JSON preview" })).toBeInTheDocument(); + }); + + test("preview pill is hidden on the Review step", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Complete ProjectInfo" })); + await userEvent.click(screen.getByRole("button", { name: "Complete DataUpload" })); + await userEvent.click(screen.getByRole("button", { name: "Complete Variables" })); + await userEvent.click(screen.getByRole("button", { name: "Complete Authors" })); + + expect(screen.queryByRole("button", { name: "Open JSON preview" })).not.toBeInTheDocument(); + }); + + test("clicking the preview pill opens PreviewDrawer", async () => { + render(); + expect(screen.queryByTestId("preview-drawer")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Open JSON preview" })); + expect(screen.getByTestId("preview-drawer")).toBeInTheDocument(); + }); + + test("closing the PreviewDrawer hides it", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Open JSON preview" })); + await userEvent.click(screen.getByRole("button", { name: "Close preview" })); + + expect(screen.queryByTestId("preview-drawer")).not.toBeInTheDocument(); + }); + }); + + // ── start over passthrough ──────────────────────────────────────────────── + + describe("start over", () => { + test("Sidebar 'Start over' calls the onStartOver prop", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Start over" })); + expect(onStartOver).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/frontend/tests/Authors.test.tsx b/packages/frontend/tests/Authors.test.tsx new file mode 100644 index 0000000..6c1de43 --- /dev/null +++ b/packages/frontend/tests/Authors.test.tsx @@ -0,0 +1,364 @@ +import { render, screen, fireEvent, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Authors from "../src/pages/Authors"; + +type AuthorFields = { + name: string; + givenName?: string; + familyName?: string; + "@type"?: string; + identifier?: string; +}; + +function makeMeta(authors: (AuthorFields | string)[] = []) { + return { + getAuthorList: jest.fn().mockReturnValue(authors), + setAuthor: jest.fn(), + deleteAuthor: jest.fn(), + } as any; +} + +/** Returns the card div for the nth author (1-based). */ +function authorCard(n: number): HTMLElement { + return screen.getByText(`Author ${n}`).closest(".card") as HTMLElement; +} + +/** Finds the Name input within a card and types into it, then blurs via Tab. */ +async function commitName(card: HTMLElement, name: string) { + const input = within(card).getByLabelText(/Name/); + await userEvent.clear(input); + await userEvent.type(input, name); + await userEvent.tab(); // moves DOM focus away, properly firing blur +} + +/** Opens the optional-fields section on a card. */ +async function openOptional(card: HTMLElement) { + await userEvent.click(within(card).getByRole("button", { name: /Optional fields/ })); +} + +let meta: ReturnType; +let onComplete: jest.Mock; + +beforeEach(() => { + meta = makeMeta(); + onComplete = jest.fn(); +}); + +describe("Authors", () => { + // ── initial render ──────────────────────────────────────────────────────── + + describe("initial render", () => { + test("starts with one empty card when no existing authors", () => { + render(); + expect(screen.getByText("Author 1")).toBeInTheDocument(); + expect(screen.queryByText("Author 2")).not.toBeInTheDocument(); + }); + + test("Remove button is hidden on a single empty card", () => { + render(); + expect(within(authorCard(1)).queryByRole("button", { name: "Remove" })).not.toBeInTheDocument(); + }); + + test("loads existing authors from getAuthorList", () => { + meta = makeMeta([{ name: "Jane Smith" }, { name: "John Doe" }]); + render(); + expect(screen.getByText("Author 1")).toBeInTheDocument(); + expect(screen.getByText("Author 2")).toBeInTheDocument(); + expect(within(authorCard(1)).getByDisplayValue("Jane Smith")).toBeInTheDocument(); + expect(within(authorCard(2)).getByDisplayValue("John Doe")).toBeInTheDocument(); + }); + + test("existing authors with optional fields start with those fields expanded", () => { + meta = makeMeta([{ name: "Jane Smith", givenName: "Jane", identifier: "https://orcid.org/0000-0001-2345-6789" }]); + render(); + expect(within(authorCard(1)).getByLabelText("ORCID")).toBeInTheDocument(); + }); + + test("strips the ORCID prefix when loading an existing author", () => { + meta = makeMeta([{ name: "Jane Smith", identifier: "https://orcid.org/0000-0001-2345-6789" }]); + render(); + expect(within(authorCard(1)).getByDisplayValue("0000-0001-2345-6789")).toBeInTheDocument(); + }); + }); + + // ── name commit on blur ─────────────────────────────────────────────────── + + describe("name commit on blur", () => { + test("blurring a non-empty name calls setAuthor and increments saved count", async () => { + render(); + await commitName(authorCard(1), "Jane Smith"); + + expect(meta.setAuthor).toHaveBeenCalledWith(expect.objectContaining({ name: "Jane Smith" })); + expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("1"); + }); + + test("blurring with an empty name on an uncommitted card does not call setAuthor", async () => { + render(); + fireEvent.blur(within(authorCard(1)).getByLabelText(/Name/)); + expect(meta.setAuthor).not.toHaveBeenCalled(); + }); + + test("blurring with an empty name on a committed card calls deleteAuthor", async () => { + render(); + await commitName(authorCard(1), "Jane Smith"); + meta.setAuthor.mockClear(); + + const input = within(authorCard(1)).getByLabelText(/Name/); + await userEvent.clear(input); + await userEvent.tab(); + + expect(meta.deleteAuthor).toHaveBeenCalledWith("Jane Smith"); + expect(meta.setAuthor).not.toHaveBeenCalled(); + }); + + test("changing a committed name deletes the old entry and sets the new one", async () => { + render(); + await commitName(authorCard(1), "Jane Smith"); + meta.setAuthor.mockClear(); + + const input = within(authorCard(1)).getByLabelText(/Name/); + await userEvent.clear(input); + await userEvent.type(input, "Jane Doe"); + fireEvent.blur(input); + + expect(meta.deleteAuthor).toHaveBeenCalledWith("Jane Smith"); + expect(meta.setAuthor).toHaveBeenCalledWith(expect.objectContaining({ name: "Jane Doe" })); + }); + }); + + // ── remove author ───────────────────────────────────────────────────────── + + describe("remove author", () => { + test("Remove button appears on a committed card", async () => { + render(); + await commitName(authorCard(1), "Jane Smith"); + expect(within(authorCard(1)).getByRole("button", { name: "Remove" })).toBeInTheDocument(); + }); + + test("removing a committed card calls deleteAuthor and removes the card", async () => { + // Start with 2 authors so removing one actually removes it (removing the + // only card replaces it with a fresh empty card instead). + meta = makeMeta([{ name: "Jane Smith" }, { name: "John Doe" }]); + render(); + + await userEvent.click(within(authorCard(1)).getByRole("button", { name: "Remove" })); + + expect(meta.deleteAuthor).toHaveBeenCalledWith("Jane Smith"); + expect(screen.queryByDisplayValue("Jane Smith")).not.toBeInTheDocument(); + expect(screen.queryByText("Author 2")).not.toBeInTheDocument(); + }); + + test("removing the last card replaces it with a fresh empty card", async () => { + meta = makeMeta([{ name: "Jane Smith" }]); + render(); + + await userEvent.click(within(authorCard(1)).getByRole("button", { name: "Remove" })); + + // A new empty card appears in its place + expect(screen.getByText("Author 1")).toBeInTheDocument(); + expect(within(authorCard(1)).getByLabelText(/Name/)).toHaveValue(""); + }); + + test("Remove buttons appear on all cards when there are multiple", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "+ Add another author" })); + + expect(within(authorCard(1)).getByRole("button", { name: "Remove" })).toBeInTheDocument(); + expect(within(authorCard(2)).getByRole("button", { name: "Remove" })).toBeInTheDocument(); + }); + }); + + // ── add author ──────────────────────────────────────────────────────────── + + describe("add author", () => { + test("'+ Add another author' appends a new empty card", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "+ Add another author" })); + expect(screen.getByText("Author 2")).toBeInTheDocument(); + }); + + test("adding a second card makes Remove visible on the first card", async () => { + render(); + expect(within(authorCard(1)).queryByRole("button", { name: "Remove" })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "+ Add another author" })); + expect(within(authorCard(1)).getByRole("button", { name: "Remove" })).toBeInTheDocument(); + }); + }); + + // ── optional fields ─────────────────────────────────────────────────────── + + describe("optional fields", () => { + test("optional fields are hidden by default on a new card", () => { + render(); + expect(within(authorCard(1)).queryByLabelText("ORCID")).not.toBeInTheDocument(); + }); + + test("toggle button expands and collapses optional fields", async () => { + render(); + const toggle = within(authorCard(1)).getByRole("button", { name: /Optional fields/ }); + + await userEvent.click(toggle); + expect(within(authorCard(1)).getByLabelText("ORCID")).toBeInTheDocument(); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + + await userEvent.click(toggle); + expect(within(authorCard(1)).queryByLabelText("ORCID")).not.toBeInTheDocument(); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + }); + + test("given name and family name fields call setAuthor on a committed author", async () => { + render(); + const card = authorCard(1); + await commitName(card, "Jane Smith"); + await openOptional(card); + meta.setAuthor.mockClear(); + + await userEvent.type(within(card).getByLabelText("Given name"), "Jane"); + expect(meta.setAuthor).toHaveBeenCalledWith( + expect.objectContaining({ name: "Jane Smith", givenName: "Jane" }), + ); + }); + + test("@type field calls setAuthor with the typed value", async () => { + render(); + const card = authorCard(1); + await commitName(card, "Jane Smith"); + await openOptional(card); + meta.setAuthor.mockClear(); + + await userEvent.type(within(card).getByLabelText("@type"), "Person"); + expect(meta.setAuthor).toHaveBeenCalledWith( + expect.objectContaining({ "@type": "Person" }), + ); + }); + }); + + // ── ORCID validation ────────────────────────────────────────────────────── + + describe("ORCID validation", () => { + test("a valid ORCID is committed to metadata with the full URL prefix", async () => { + render(); + const card = authorCard(1); + await commitName(card, "Jane Smith"); + await openOptional(card); + meta.setAuthor.mockClear(); + + await userEvent.type(within(card).getByLabelText("ORCID"), "0000-0001-2345-6789"); + expect(meta.setAuthor).toHaveBeenCalledWith( + expect.objectContaining({ identifier: "https://orcid.org/0000-0001-2345-6789" }), + ); + }); + + test("a partial or invalid ORCID is NOT written to metadata as an identifier", async () => { + render(); + const card = authorCard(1); + await commitName(card, "Jane Smith"); + await openOptional(card); + meta.setAuthor.mockClear(); + + await userEvent.type(within(card).getByLabelText("ORCID"), "0000-0001"); + const calls = meta.setAuthor.mock.calls as { identifier?: string }[][]; + expect(calls.every(([fields]) => !fields.identifier)).toBe(true); + }); + }); + + // ── bulk import ─────────────────────────────────────────────────────────── + + describe("bulk import", () => { + async function openBulk() { + await userEvent.click(screen.getByRole("button", { name: "+ Add multiple authors at once" })); + } + + async function importLines(lines: string) { + await openBulk(); + await userEvent.type(screen.getByPlaceholderText(/Paste author names/), lines); + await userEvent.click(screen.getByRole("button", { name: "Import" })); + } + + test("opens and closes the bulk import panel", async () => { + render(); + await openBulk(); + expect(screen.getByPlaceholderText(/Paste author names/)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByPlaceholderText(/Paste author names/)).not.toBeInTheDocument(); + }); + + test("imports names and replaces the single empty placeholder card", async () => { + render(); + await importLines("Jane Smith\nJohn Doe"); + + expect(screen.getByText("Author 1")).toBeInTheDocument(); + expect(screen.getByText("Author 2")).toBeInTheDocument(); + expect(meta.setAuthor).toHaveBeenCalledWith(expect.objectContaining({ name: "Jane Smith" })); + expect(meta.setAuthor).toHaveBeenCalledWith(expect.objectContaining({ name: "John Doe" })); + }); + + test("appends to existing committed authors instead of replacing them", async () => { + render(); + await commitName(authorCard(1), "Existing Author"); + await importLines("Jane Smith"); + + expect(screen.getByText("Author 1")).toBeInTheDocument(); + expect(screen.getByText("Author 2")).toBeInTheDocument(); + }); + + test("skips duplicate names (case-insensitive)", async () => { + render(); + await commitName(authorCard(1), "Jane Smith"); + meta.setAuthor.mockClear(); + + await importLines("jane smith\nJohn Doe"); + + expect(screen.queryByText("Author 3")).not.toBeInTheDocument(); + expect(meta.setAuthor).toHaveBeenCalledTimes(1); + expect(meta.setAuthor).toHaveBeenCalledWith(expect.objectContaining({ name: "John Doe" })); + }); + + test("imports a valid ORCID and normalises a pasted full URL", async () => { + render(); + await importLines("Jane Smith, https://orcid.org/0000-0001-2345-6789"); + + expect(meta.setAuthor).toHaveBeenCalledWith( + expect.objectContaining({ identifier: "https://orcid.org/0000-0001-2345-6789" }), + ); + }); + + test("imports the name but shows a warning when the ORCID is invalid", async () => { + render(); + await importLines("Jane Smith, not-an-orcid"); + + expect(meta.setAuthor).toHaveBeenCalledWith( + expect.objectContaining({ name: "Jane Smith" }), + ); + const call = meta.setAuthor.mock.calls[0][0] as AuthorFields; + expect(call.identifier).toBeUndefined(); + expect(screen.getByText(/ORCID not saved for: Jane Smith/)).toBeInTheDocument(); + }); + + test("warning can be dismissed", async () => { + render(); + await importLines("Jane Smith, bad-orcid"); + + await userEvent.click(screen.getByRole("button", { name: "✕" })); + expect(screen.queryByText(/ORCID not saved for/)).not.toBeInTheDocument(); + }); + }); + + // ── continue ────────────────────────────────────────────────────────────── + + describe("continue", () => { + test("Continue flushes pending name edits and calls onComplete", async () => { + render(); + // Type a name but do NOT blur (so it is not yet committed) + await userEvent.type(within(authorCard(1)).getByLabelText(/Name/), "Jane Smith"); + + await userEvent.click(screen.getByRole("button", { name: "Continue →" })); + + // handleNameBlur is called for all rows before onComplete + expect(meta.setAuthor).toHaveBeenCalledWith(expect.objectContaining({ name: "Jane Smith" })); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/frontend/tests/DataUpload.test.tsx b/packages/frontend/tests/DataUpload.test.tsx new file mode 100644 index 0000000..92d1c65 --- /dev/null +++ b/packages/frontend/tests/DataUpload.test.tsx @@ -0,0 +1,453 @@ +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import JSZip from "jszip"; +import { analyzeJoinKeys } from "@jspsych/metadata"; +import DataUpload, { emptyDataSession } from "../src/pages/DataUpload"; +import type { DataSession } from "../src/pages/DataUpload"; + +jest.mock("@jspsych/metadata", () => ({ + __esModule: true, + default: jest.fn(), + analyzeJoinKeys: jest.fn(), +})); + +jest.mock("jszip", () => ({ + __esModule: true, + default: { loadAsync: jest.fn() }, +})); + +const mockAnalyzeJoinKeys = analyzeJoinKeys as jest.Mock; +const mockLoadAsync = (JSZip as { loadAsync: jest.Mock }).loadAsync; + +function makeFile(name: string, content = "col,val\n1,2", relativePath = "") { + const file = new File([content], name); + Object.defineProperty(file, "webkitRelativePath", { value: relativePath, configurable: true }); + return file; +} + +function makeMeta(varNames: string[] = []) { + return { + generate: jest.fn().mockResolvedValue(undefined), + getVariableNames: jest.fn().mockReturnValue(varNames), + } as any; +} + +let meta: ReturnType; +let onComplete: jest.Mock; +let onSessionChange: jest.Mock; + +function props(overrides: Record = {}) { + return { + jsPsychMetadata: meta, + dataProcessed: false, + onComplete, + session: emptyDataSession, + onSessionChange, + ...overrides, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockAnalyzeJoinKeys.mockReturnValue({ isUnique: true, candidates: [] }); + meta = makeMeta(); + onComplete = jest.fn(); + onSessionChange = jest.fn(); +}); + +describe("DataUpload", () => { + // ── idle ──────────────────────────────────────────────────────────────── + + describe("idle phase", () => { + test("renders description and picker buttons", () => { + render(); + expect(screen.getByText(/Select your data folder/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Choose folder" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Upload .zip" })).toBeInTheDocument(); + }); + + test("Process button is absent until files are picked", () => { + render(); + expect(screen.queryByRole("button", { name: "Process files" })).not.toBeInTheDocument(); + }); + }); + + // ── fromExisting ───────────────────────────────────────────────────────── + + describe("fromExisting phase", () => { + test("shows variables-loaded banner, optional-upload note, and Continue", () => { + render(); + expect(screen.getByText(/Variables loaded from existing metadata/)).toBeInTheDocument(); + expect(screen.getByText(/No data upload is needed/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Continue →" })).toBeInTheDocument(); + }); + + test("Continue calls onComplete", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Continue →" })); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + test("picking files from fromExisting transitions to ready with Process button", () => { + const { container } = render(); + const input = container.querySelector("input[multiple]") as HTMLInputElement; + fireEvent.change(input, { target: { files: [makeFile("sub01.csv")] } }); + + expect(screen.getByRole("button", { name: "Process files" })).toBeInTheDocument(); + expect(screen.queryByText(/Variables loaded from existing metadata/)).not.toBeInTheDocument(); + }); + }); + + // ── hasData ────────────────────────────────────────────────────────────── + + describe("hasData phase (navigating back)", () => { + test("shows variable count and Continue button", () => { + render(); + expect(screen.getByText(/Data already processed/)).toBeInTheDocument(); + expect(screen.getByText(/2 variables generated/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Continue →" })).toBeInTheDocument(); + }); + + test("uses singular form for 1 variable", () => { + render(); + expect(screen.getByText(/1 variable generated/)).toBeInTheDocument(); + }); + + test("shows file status list from session", () => { + const session: DataSession = { + ...emptyDataSession, + fileStatuses: [ + { name: "sub01.csv", status: "success" }, + { name: "sub02.csv", status: "error", detail: "parse failed" }, + ], + }; + render(); + expect(screen.getByText("sub01.csv")).toBeInTheDocument(); + expect(screen.getByText("parse failed")).toBeInTheDocument(); + }); + + test("shows Re-configure join keys when candidates and fileTexts exist", () => { + const session: DataSession = { + ...emptyDataSession, + joinKeyCandidates: [{ column: "subject", makesUnique: true }], + fileTexts: new Map([["data.json", { content: "[]", type: "json" }]]), + }; + render(); + expect(screen.getByRole("button", { name: "Re-configure join keys" })).toBeInTheDocument(); + }); + }); + + // ── folder picking ─────────────────────────────────────────────────────── + + describe("folder picking", () => { + test("picked files appear in file list with Process button", () => { + const { container } = render(); + const input = container.querySelector("input[multiple]") as HTMLInputElement; + fireEvent.change(input, { target: { files: [makeFile("sub01.csv")] } }); + + expect(screen.getByText("sub01.csv")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Process files" })).toBeInTheDocument(); + }); + + test("hidden files (dot-prefixed names) are filtered out", () => { + const { container } = render(); + const input = container.querySelector("input[multiple]") as HTMLInputElement; + fireEvent.change(input, { + target: { files: [makeFile("sub01.csv"), makeFile(".DS_Store")] }, + }); + + expect(screen.getByText("sub01.csv")).toBeInTheDocument(); + expect(screen.queryByText(".DS_Store")).not.toBeInTheDocument(); + }); + + test("shows folder name and file count after pick", () => { + const { container } = render(); + const input = container.querySelector("input[multiple]") as HTMLInputElement; + const file = makeFile("sub01.csv", "col,val\n1,2", "experiment/sub01.csv"); + fireEvent.change(input, { target: { files: [file] } }); + + expect(screen.getByText(/experiment \(1 file\)/)).toBeInTheDocument(); + }); + }); + + // ── zip upload ─────────────────────────────────────────────────────────── + + describe("zip upload", () => { + function mockZip(entries: Record) { + const files: Record = {}; + for (const [name, { dir, content = "" }] of Object.entries(entries)) { + files[name] = { dir, name, async: jest.fn().mockResolvedValue(content) }; + } + mockLoadAsync.mockResolvedValue({ files }); + } + + test("extracts files and moves to ready", async () => { + mockZip({ "data/sub01.csv": { dir: false, content: "rt\n100" } }); + const { container } = render(); + fireEvent.change(container.querySelector("input[accept='.zip']")!, { + target: { files: [makeFile("exp.zip")] }, + }); + + expect(await screen.findByRole("button", { name: "Process files" })).toBeInTheDocument(); + expect(screen.getByText("data/sub01.csv")).toBeInTheDocument(); + }); + + test("strips .zip extension from source name", async () => { + mockZip({ "sub01.csv": { dir: false } }); + const { container } = render(); + fireEvent.change(container.querySelector("input[accept='.zip']")!, { + target: { files: [makeFile("my-experiment.zip")] }, + }); + + await screen.findByRole("button", { name: "Process files" }); + expect(screen.getByText(/my-experiment/)).toBeInTheDocument(); + }); + + test("filters __MACOSX and hidden files", async () => { + mockZip({ + "__MACOSX/._sub01.csv": { dir: false }, + "data/.hidden": { dir: false }, + "data/sub01.csv": { dir: false, content: "rt\n100" }, + }); + const { container } = render(); + fireEvent.change(container.querySelector("input[accept='.zip']")!, { + target: { files: [makeFile("exp.zip")] }, + }); + + await screen.findByText("data/sub01.csv"); + expect(screen.queryByText("__MACOSX/._sub01.csv")).not.toBeInTheDocument(); + expect(screen.queryByText("data/.hidden")).not.toBeInTheDocument(); + }); + + test("shows error when zip yields no readable files", async () => { + mockZip({ "__MACOSX/": { dir: true } }); + const { container } = render(); + fireEvent.change(container.querySelector("input[accept='.zip']")!, { + target: { files: [makeFile("empty.zip")] }, + }); + + expect(await screen.findByText(/No readable files found/)).toBeInTheDocument(); + }); + + test("shows error when zip cannot be parsed", async () => { + mockLoadAsync.mockRejectedValue(new Error("bad zip")); + const { container } = render(); + fireEvent.change(container.querySelector("input[accept='.zip']")!, { + target: { files: [makeFile("bad.zip")] }, + }); + + expect(await screen.findByText(/Could not read the zip file/)).toBeInTheDocument(); + }); + }); + + // ── processing ─────────────────────────────────────────────────────────── + + describe("processing", () => { + async function pickAndProcess(file: File, container: HTMLElement) { + const input = container.querySelector("input[multiple]") as HTMLInputElement; + fireEvent.change(input, { target: { files: [file] } }); + await userEvent.click(screen.getByRole("button", { name: "Process files" })); + } + + test("calls generate once per CSV file with correct args", async () => { + const { container } = render(); + await pickAndProcess(makeFile("sub01.csv", "rt,stimulus\n100,hello"), container); + + await waitFor(() => expect(meta.generate).toHaveBeenCalledTimes(1)); + expect(meta.generate).toHaveBeenCalledWith( + "rt,stimulus\n100,hello", + {}, + "csv", + expect.any(Object), + ); + }); + + test("skips dataset_description.json with 'existing metadata file' detail", async () => { + const { container } = render(); + await pickAndProcess(makeFile("dataset_description.json", '{"name":"x"}'), container); + + await screen.findByRole("button", { name: "Continue →" }); + expect(meta.generate).not.toHaveBeenCalled(); + expect(screen.getByText(/existing metadata file/)).toBeInTheDocument(); + }); + + test("skips unsupported file types with 'unsupported file type' detail", async () => { + const { container } = render(); + await pickAndProcess(makeFile("notes.txt", "some text"), container); + + await screen.findByRole("button", { name: "Continue →" }); + expect(meta.generate).not.toHaveBeenCalled(); + expect(screen.getByText(/unsupported file type/)).toBeInTheDocument(); + }); + + test("shows error status when generate throws", async () => { + meta.generate.mockRejectedValue(new Error("parse failed")); + const { container } = render(); + await pickAndProcess(makeFile("bad.csv", "malformed"), container); + + expect(await screen.findByText(/parse failed/)).toBeInTheDocument(); + }); + + test("calls generate for a JSON file when trial_index is unique", async () => { + const content = JSON.stringify([{ trial_index: 0, rt: 100 }]); + // mockAnalyzeJoinKeys returns isUnique: true from beforeEach — no join key UI + const { container } = render(); + await pickAndProcess(makeFile("data.json", content), container); + + await waitFor(() => expect(meta.generate).toHaveBeenCalledTimes(1)); + expect(meta.generate).toHaveBeenCalledWith(content, {}, "json", expect.any(Object)); + expect(screen.queryByText(/Rows need a unique identifier/)).not.toBeInTheDocument(); + }); + + test("Continue button appears after done and calls onComplete", async () => { + const { container } = render(); + await pickAndProcess(makeFile("sub01.csv", "rt\n100"), container); + + const continueBtn = await screen.findByRole("button", { name: "Continue →" }); + await userEvent.click(continueBtn); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + }); + + // ── join key chooser ───────────────────────────────────────────────────── + + describe("join key chooser", () => { + async function renderToJoinKeys( + candidates = [{ column: "subject", makesUnique: true as boolean }], + ) { + mockAnalyzeJoinKeys.mockReturnValue({ isUnique: false, candidates }); + const { container } = render(); + const input = container.querySelector("input[multiple]") as HTMLInputElement; + const content = JSON.stringify([{ trial_index: 0, subject: "p1" }]); + fireEvent.change(input, { target: { files: [makeFile("data.json", content)] } }); + await userEvent.click(screen.getByRole("button", { name: "Process files" })); + await screen.findByText(/Rows need a unique identifier/); + return container; + } + + test("shows warning and candidate columns when trial_index is non-unique", async () => { + await renderToJoinKeys(); + expect(screen.getByText(/Rows need a unique identifier/)).toBeInTheDocument(); + expect(screen.getByText("subject")).toBeInTheDocument(); + expect(screen.getByText("sufficient alone")).toBeInTheDocument(); + }); + + test("help text expands and collapses", async () => { + await renderToJoinKeys(); + const toggle = screen.getByRole("button", { name: /What is a join key/ }); + expect(screen.queryByText(/nested data/)).not.toBeInTheDocument(); + + await userEvent.click(toggle); + expect(screen.getByText(/nested data/)).toBeInTheDocument(); + + await userEvent.click(toggle); + expect(screen.queryByText(/nested data/)).not.toBeInTheDocument(); + }); + + test("selecting a column and applying calls generate with that key", async () => { + await renderToJoinKeys([{ column: "subject", makesUnique: true }]); + const subjectItem = screen.getByText("subject").closest("li")!; + await userEvent.click(within(subjectItem).getByRole("checkbox")); + await userEvent.click(screen.getByRole("button", { name: "Apply and process files" })); + + await waitFor(() => expect(meta.generate).toHaveBeenCalled()); + expect(meta.generate).toHaveBeenCalledWith( + expect.any(String), + {}, + "json", + expect.objectContaining({ + arrayJoinKeys: expect.arrayContaining(["trial_index", "subject"]), + }), + ); + }); + + test('"Proceed anyway" disables candidates and suppresses the warning', async () => { + await renderToJoinKeys([{ column: "subject", makesUnique: true }]); + const proceedItem = screen.getByText(/Proceed anyway/).closest("li")!; + await userEvent.click(within(proceedItem).getByRole("checkbox")); + + expect(within(screen.getByText("subject").closest("li")!).getByRole("checkbox")).toBeDisabled(); + + await userEvent.click(screen.getByRole("button", { name: "Apply and process files" })); + await waitFor(() => expect(meta.generate).toHaveBeenCalled()); + expect(meta.generate).toHaveBeenCalledWith( + expect.any(String), + {}, + "json", + expect.objectContaining({ arrayJoinKeys: ["trial_index"], suppressJoinKeyWarning: true }), + ); + }); + + test("Cancel returns to the ready phase without processing", async () => { + await renderToJoinKeys(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.getByRole("button", { name: "Process files" })).toBeInTheDocument(); + expect(screen.queryByText(/Rows need a unique identifier/)).not.toBeInTheDocument(); + expect(meta.generate).not.toHaveBeenCalled(); + }); + + test("Re-configure join keys button appears in the done phase after processing", async () => { + await renderToJoinKeys([{ column: "subject", makesUnique: true }]); + await userEvent.click(screen.getByRole("button", { name: "Apply and process files" })); + + // After applying, processing completes and phase becomes 'done'. + // joinKeyCandidates is non-empty, so the Re-configure button should appear. + expect( + await screen.findByRole("button", { name: "Re-configure join keys" }), + ).toBeInTheDocument(); + }); + }); + + // ── session sync ───────────────────────────────────────────────────────── + + describe("session sync", () => { + test("calls onSessionChange with updated files after folder pick", async () => { + const { container } = render(); + const input = container.querySelector("input[multiple]") as HTMLInputElement; + fireEvent.change(input, { target: { files: [makeFile("sub01.csv")] } }); + + await waitFor(() => { + const lastCall = onSessionChange.mock.calls.at(-1)?.[0] as DataSession; + expect(lastCall?.files).toHaveLength(1); + expect(lastCall.files[0].name).toBe("sub01.csv"); + }); + }); + + test("calls onSessionChange with extracted files after zip upload", async () => { + const mockZipFiles = { + "data/sub01.csv": { + dir: false, + name: "data/sub01.csv", + async: jest.fn().mockResolvedValue("rt\n100"), + }, + }; + mockLoadAsync.mockResolvedValue({ files: mockZipFiles }); + + const { container } = render(); + fireEvent.change(container.querySelector("input[accept='.zip']")!, { + target: { files: [makeFile("exp.zip")] }, + }); + + await waitFor(() => { + const lastCall = onSessionChange.mock.calls.at(-1)?.[0] as DataSession; + expect(lastCall?.files).toHaveLength(1); + expect(lastCall.files[0].name).toBe("data/sub01.csv"); + }); + }); + + test("calls onSessionChange with fileStatuses after processing completes", async () => { + const { container } = render(); + const input = container.querySelector("input[multiple]") as HTMLInputElement; + fireEvent.change(input, { target: { files: [makeFile("sub01.csv", "rt\n100")] } }); + await userEvent.click(screen.getByRole("button", { name: "Process files" })); + + await screen.findByRole("button", { name: "Continue →" }); + await waitFor(() => { + const lastCall = onSessionChange.mock.calls.at(-1)?.[0] as DataSession; + expect(lastCall?.fileStatuses).toHaveLength(1); + expect(lastCall.fileStatuses[0]).toMatchObject({ name: "sub01.csv", status: "success" }); + }); + }); + }); +}); diff --git a/packages/frontend/tests/JsonViewer.test.tsx b/packages/frontend/tests/JsonViewer.test.tsx new file mode 100644 index 0000000..fa7f72f --- /dev/null +++ b/packages/frontend/tests/JsonViewer.test.tsx @@ -0,0 +1,154 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import JsonViewer from "../src/components/JsonViewer"; + +describe("JsonViewer", () => { + // ── primitives ──────────────────────────────────────────────────────────── + + describe("primitives", () => { + test("renders null", () => { + render(); + expect(screen.getByText("null")).toBeInTheDocument(); + }); + + test("renders true and false", () => { + const { rerender } = render(); + expect(screen.getByText("true")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("false")).toBeInTheDocument(); + }); + + test("renders numbers", () => { + render(); + expect(screen.getByText("42")).toBeInTheDocument(); + }); + + test("renders strings wrapped in quotes", () => { + render(); + expect(screen.getByText('"hello"')).toBeInTheDocument(); + }); + + test("escapes double-quotes inside strings", () => { + render(); + expect(screen.getByText('"say \\"hi\\""')).toBeInTheDocument(); + }); + + test("escapes backslashes inside strings", () => { + render(); + expect(screen.getByText('"path\\\\file"')).toBeInTheDocument(); + }); + }); + + // ── empty containers ────────────────────────────────────────────────────── + + describe("empty containers", () => { + test("renders empty object as {}", () => { + render(); + expect(screen.getByText("{}")).toBeInTheDocument(); + }); + + test("renders empty array as []", () => { + render(); + expect(screen.getByText("[]")).toBeInTheDocument(); + }); + }); + + // ── object and array structure ──────────────────────────────────────────── + + describe("objects and arrays", () => { + test("renders object keys and string values", () => { + render(); + expect(screen.getByText('"name"')).toBeInTheDocument(); + expect(screen.getByText('"test"')).toBeInTheDocument(); + expect(screen.getByText('"license"')).toBeInTheDocument(); + expect(screen.getByText('"CC0"')).toBeInTheDocument(); + }); + + test("renders array items without keys", () => { + render(); + expect(screen.getByText('"alpha"')).toBeInTheDocument(); + expect(screen.getByText('"beta"')).toBeInTheDocument(); + }); + + test("renders nested objects recursively", () => { + render(); + expect(screen.getByText('"author"')).toBeInTheDocument(); + expect(screen.getByText('"name"')).toBeInTheDocument(); + expect(screen.getByText('"Jane"')).toBeInTheDocument(); + }); + + test("adds a comma after non-last items", () => { + const { container } = render(); + // Only one comma expected (after "a": 1, not after "b": 2) + const commas = container.querySelectorAll(".punct"); + expect(commas.length).toBe(1); + }); + + test("shows correct field count in object summary", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Collapse" })); + expect(screen.getByText("3 fields")).toBeInTheDocument(); + }); + + test("shows correct item count in array summary", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Collapse" })); + expect(screen.getByText("2 items")).toBeInTheDocument(); + }); + + test("uses singular for 1 field / 1 item", async () => { + const { rerender } = render(); + await userEvent.click(screen.getByRole("button", { name: "Collapse" })); + expect(screen.getByText("1 field")).toBeInTheDocument(); + + rerender(); + // toggle is now "Expand" — click to re-open then collapse again + await userEvent.click(screen.getAllByRole("button", { name: "Expand" })[0]); + await userEvent.click(screen.getAllByRole("button", { name: "Collapse" })[0]); + expect(screen.getByText("1 item")).toBeInTheDocument(); + }); + }); + + // ── collapse / expand ───────────────────────────────────────────────────── + + describe("collapse / expand", () => { + test("toggle button starts with aria-label 'Collapse' (expanded by default)", () => { + render(); + expect(screen.getByRole("button", { name: "Collapse" })).toBeInTheDocument(); + }); + + test("clicking Collapse hides children and shows the summary", async () => { + const { container } = render(); + await userEvent.click(screen.getByRole("button", { name: "Collapse" })); + + // Children wrapper gets the "hidden" class + expect( + container.querySelector(".children")?.parentElement, + ).toHaveClass("hidden"); + + // Summary span no longer inside a "hidden" element + const summary = screen.getByText("1 field"); + expect(summary.closest('[class="hidden"]')).toBeNull(); + }); + + test("clicking Expand again restores the children", async () => { + const { container } = render(); + await userEvent.click(screen.getByRole("button", { name: "Collapse" })); + await userEvent.click(screen.getByRole("button", { name: "Expand" })); + + expect( + container.querySelector(".children")?.parentElement, + ).not.toHaveClass("hidden"); + }); + + test("each nested object has its own independent toggle", async () => { + render(); + const toggles = screen.getAllByRole("button", { name: "Collapse" }); + expect(toggles.length).toBe(2); // outer + inner + // Collapse only the inner object + await userEvent.click(toggles[1]); + expect(screen.getAllByRole("button", { name: "Collapse" }).length).toBe(1); + expect(screen.getAllByRole("button", { name: "Expand" }).length).toBe(1); + }); + }); +}); diff --git a/packages/frontend/tests/PageHeader.test.tsx b/packages/frontend/tests/PageHeader.test.tsx new file mode 100644 index 0000000..af44673 --- /dev/null +++ b/packages/frontend/tests/PageHeader.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@testing-library/react"; +import PageHeader from "../src/components/PageHeader"; + +describe("PageHeader", () => { + test("renders a string title as an h2", () => { + render(); + expect(screen.getByRole("heading", { level: 2, name: "Project Info" })).toBeInTheDocument(); + }); + + test("renders a JSX title", () => { + render(Variables 3} />); + expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Variables 3"); + }); + + test("renders right-slot content when provided", () => { + render(Toggle} />); + expect(screen.getByRole("button", { name: "Toggle" })).toBeInTheDocument(); + }); + + test("renders nothing in the right slot when the prop is absent", () => { + const { container } = render(); + expect(container.querySelector(".right")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/tests/PreviewDrawer.test.tsx b/packages/frontend/tests/PreviewDrawer.test.tsx new file mode 100644 index 0000000..ca870ac --- /dev/null +++ b/packages/frontend/tests/PreviewDrawer.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import PreviewDrawer from "../src/components/PreviewDrawer"; + +function makeMeta(data: Record = {}) { + return { getMetadata: jest.fn().mockReturnValue(data) } as any; +} + +let onClose: jest.Mock; + +beforeEach(() => { + onClose = jest.fn(); +}); + +describe("PreviewDrawer", () => { + test("renders the dialog with a JSON preview of the current metadata", () => { + const meta = makeMeta({ name: "my-study" }); + render(); + + expect(screen.getByRole("dialog", { name: "JSON preview" })).toBeInTheDocument(); + expect(screen.getByText('"name"')).toBeInTheDocument(); + expect(screen.getByText('"my-study"')).toBeInTheDocument(); + }); + + test("close button calls onClose", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Close preview" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test("clicking the backdrop calls onClose", async () => { + const { container } = render(); + await userEvent.click(container.querySelector(".backdrop")!); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test("locks body scroll on mount and restores it on unmount", () => { + const { unmount } = render(); + expect(document.body.style.overflow).toBe("hidden"); + + unmount(); + expect(document.body.style.overflow).toBe(""); + }); + + test("snapshots metadata at mount time — getMetadata called once", () => { + const meta = makeMeta({ name: "study" }); + render(); + expect(meta.getMetadata).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/frontend/tests/ProjectInfo.test.tsx b/packages/frontend/tests/ProjectInfo.test.tsx new file mode 100644 index 0000000..ffe603a --- /dev/null +++ b/packages/frontend/tests/ProjectInfo.test.tsx @@ -0,0 +1,393 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ProjectInfo, { emptyProjectInfoSession } from "../src/pages/ProjectInfo"; +import type { ProjectInfoSession } from "../src/pages/ProjectInfo"; + +function makeMeta(fields: Record = {}) { + return { + loadMetadata: jest.fn(), + getMetadataField: jest.fn().mockImplementation((key: string) => fields[key] ?? ""), + setMetadataField: jest.fn(), + deleteMetadataField: jest.fn(), + } as any; +} + +function session(overrides: Partial = {}): ProjectInfoSession { + return { ...emptyProjectInfoSession(), ...overrides }; +} + +let meta: ReturnType; +let onComplete: jest.Mock; +let onSessionChange: jest.Mock; + +beforeEach(() => { + meta = makeMeta(); + onComplete = jest.fn(); + onSessionChange = jest.fn(); +}); + +/** Simulates uploading a JSON file via the pre-fill button. */ +function uploadJson(container: HTMLElement, json: Record) { + const input = container.querySelector("input[accept='.json']") as HTMLInputElement; + const file = new File([JSON.stringify(json)], "meta.json", { type: "application/json" }); + fireEvent.change(input, { target: { files: [file] } }); +} + +describe("ProjectInfo", () => { + // ── basic render ────────────────────────────────────────────────────────── + + describe("basic render", () => { + test("renders name and description fields", () => { + render( + , + ); + expect(screen.getByRole("textbox", { name: /Project name/ })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /^Description/ })).toBeInTheDocument(); + }); + + test("pre-fills inputs from the session prop", () => { + render( + , + ); + expect(screen.getByRole("textbox", { name: /Project name/ })).toHaveValue("My Study"); + expect(screen.getByRole("textbox", { name: /^Description/ })).toHaveValue("A description"); + }); + }); + + // ── Continue validation ─────────────────────────────────────────────────── + + describe("Continue validation", () => { + test("shows error and does not proceed when name is empty", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Continue →" })); + + expect(screen.getByText("Project name is required.")).toBeInTheDocument(); + expect(onComplete).not.toHaveBeenCalled(); + }); + + test("calls setMetadataField with name and calls onComplete", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Continue →" })); + + expect(meta.setMetadataField).toHaveBeenCalledWith("name", "My Study"); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + test("empty description writes 'No description provided.'", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Continue →" })); + expect(meta.setMetadataField).toHaveBeenCalledWith( + "description", + "No description provided.", + ); + }); + + test("non-empty optional fields are saved; empty ones are deleted", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Continue →" })); + + expect(meta.setMetadataField).toHaveBeenCalledWith("license", "CC0"); + expect(meta.deleteMetadataField).toHaveBeenCalledWith("keywords"); + }); + }); + + // ── optional fields ─────────────────────────────────────────────────────── + + describe("optional fields", () => { + test("optional fields section is collapsed by default", () => { + render( + , + ); + expect(screen.queryByLabelText("License")).not.toBeInTheDocument(); + }); + + test("toggle button calls onSessionChange with optionalOpen: true", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /Optional fields/ })); + expect(onSessionChange).toHaveBeenCalledWith( + expect.objectContaining({ optionalOpen: true }), + ); + }); + + test("privacy policy renders as a select, not an input", async () => { + render( + , + ); + expect(screen.getByLabelText("Privacy policy")).toBeInstanceOf(HTMLSelectElement); + }); + + test("changing an optional field calls onSessionChange", async () => { + render( + , + ); + fireEvent.change(screen.getByLabelText("License"), { target: { value: "CC0" } }); + expect(onSessionChange).toHaveBeenCalledWith( + expect.objectContaining({ optional: expect.objectContaining({ license: "CC0" }) }), + ); + }); + }); + + // ── help popovers ───────────────────────────────────────────────────────── + + describe("help popovers", () => { + test("ⓘ next to Description toggles help text", async () => { + render( + , + ); + const helpBtn = screen.getByRole("button", { name: "Help for Description" }); + expect(screen.queryByText(/helps others understand/i)).not.toBeInTheDocument(); + + await userEvent.click(helpBtn); + expect(screen.getByText(/helps others understand/i)).toBeInTheDocument(); + + await userEvent.click(helpBtn); + expect(screen.queryByText(/helps others understand/i)).not.toBeInTheDocument(); + }); + + test("ⓘ next to License shows license help text when optional fields are open", async () => { + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Help for License" })); + expect(screen.getByText(/CC0/)).toBeInTheDocument(); + }); + + test("pre-fill help button toggles the upload explainer", async () => { + render( + , + ); + const btn = screen.getByRole("button", { name: "Help for pre-fill from JSON" }); + await userEvent.click(btn); + expect(screen.getByText(/Array values/)).toBeInTheDocument(); + + await userEvent.click(btn); + expect(screen.queryByText(/Array values/)).not.toBeInTheDocument(); + }); + }); + + // ── JSON pre-fill ───────────────────────────────────────────────────────── + + describe("JSON pre-fill", () => { + test("no conflict: applies name and description immediately", async () => { + const { container } = render( + , + ); + uploadJson(container, { name: "New Study", description: "New desc" }); + + await waitFor(() => + expect(onSessionChange).toHaveBeenCalledWith( + expect.objectContaining({ name: "New Study", description: "New desc" }), + ), + ); + }); + + test("array keyword values are joined as comma-separated text", async () => { + const { container } = render( + , + ); + uploadJson(container, { keywords: ["stroop", "reaction time"] }); + + await waitFor(() => + expect(onSessionChange).toHaveBeenCalledWith( + expect.objectContaining({ + optional: expect.objectContaining({ keywords: "stroop, reaction time" }), + }), + ), + ); + }); + + test("conflict: shows conflict callout when uploaded name differs from current", async () => { + const { container } = render( + , + ); + uploadJson(container, { name: "Uploaded" }); + + expect(await screen.findByText(/different "name"/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Yes, overwrite" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "No, keep mine" })).toBeInTheDocument(); + }); + + test("conflict: 'See details' expands current vs uploaded comparison", async () => { + const { container } = render( + , + ); + uploadJson(container, { name: "Different Name" }); + + await userEvent.click(await screen.findByRole("button", { name: /See details/ })); + expect(screen.getByText("Existing Name")).toBeInTheDocument(); + expect(screen.getByText("Different Name")).toBeInTheDocument(); + }); + + test("conflict: 'Yes, overwrite' calls onSessionChange with uploaded values", async () => { + const { container } = render( + , + ); + uploadJson(container, { name: "Uploaded" }); + await userEvent.click(await screen.findByRole("button", { name: "Yes, overwrite" })); + + expect(onSessionChange).toHaveBeenCalledWith( + expect.objectContaining({ name: "Uploaded" }), + ); + expect(screen.queryByRole("button", { name: "Yes, overwrite" })).not.toBeInTheDocument(); + }); + + test("conflict: 'No, keep mine' applies optional fields but keeps current name", async () => { + const { container } = render( + , + ); + uploadJson(container, { name: "Uploaded", license: "CC0" }); + await userEvent.click(await screen.findByRole("button", { name: "No, keep mine" })); + + const call = onSessionChange.mock.calls.at(-1)?.[0] as ProjectInfoSession; + expect(call.name).toBe("Existing"); + expect(call.optional.license).toBe("CC0"); + }); + }); + + // ── existing metadata file ──────────────────────────────────────────────── + + describe("existing metadata file (open existing project)", () => { + test("loads metadata from the file and calls onSessionChange", async () => { + const fields: Record = { name: "Loaded Study", description: "Loaded desc" }; + meta = makeMeta(fields); + const file = new File(['{"name":"Loaded Study"}'], "dataset_description.json"); + + render( + , + ); + + await waitFor(() => expect(meta.loadMetadata).toHaveBeenCalledTimes(1)); + expect(onSessionChange).toHaveBeenCalledWith( + expect.objectContaining({ name: "Loaded Study" }), + ); + }); + + test("shows a loaded banner after successful file load", async () => { + meta = makeMeta({ name: "Study" }); + const file = new File(['{"name":"Study"}'], "dataset_description.json"); + + render( + , + ); + + expect(await screen.findByText(/Loaded from/)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/frontend/tests/Sidebar.test.tsx b/packages/frontend/tests/Sidebar.test.tsx new file mode 100644 index 0000000..00a8511 --- /dev/null +++ b/packages/frontend/tests/Sidebar.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Sidebar from "../src/components/Sidebar"; +import { STEPS } from "../src/components/AppShell"; + +// jsdom does not implement showModal — stub it so the dialog renders. +beforeAll(() => { + HTMLDialogElement.prototype.showModal = jest.fn().mockImplementation( + function (this: HTMLDialogElement) { this.setAttribute("open", ""); }, + ); + HTMLDialogElement.prototype.close = jest.fn().mockImplementation( + function (this: HTMLDialogElement) { + this.removeAttribute("open"); + this.dispatchEvent(new Event("close")); + }, + ); +}); + +const defaultProps = { + steps: STEPS, + currentStep: "projectInfo" as const, + completedSteps: new Set<"projectInfo" | "data" | "variables" | "authors" | "review">(), + canNavigateTo: (id: string) => id === "projectInfo", + onNavigate: jest.fn(), + onStartOver: jest.fn(), +}; + +let onNavigate: jest.Mock; +let onStartOver: jest.Mock; + +function props(overrides: Partial = {}) { + return { ...defaultProps, onNavigate, onStartOver, ...overrides }; +} + +beforeEach(() => { + onNavigate = jest.fn(); + onStartOver = jest.fn(); +}); + +describe("Sidebar", () => { + // ── step list ───────────────────────────────────────────────────────────── + + describe("step list", () => { + test("renders a button for every step", () => { + render(); + for (const { label } of STEPS) { + expect(screen.getByRole("button", { name: new RegExp(label) })).toBeInTheDocument(); + } + }); + + test("the active step button is not disabled", () => { + render(); + expect(screen.getByRole("button", { name: /Project Info/ })).not.toBeDisabled(); + }); + + test("locked steps (not navigable, not active) are disabled", () => { + render(); + // Only projectInfo is navigable, all others are locked + const dataBtn = screen.getByRole("button", { name: /^Data/ }); + expect(dataBtn).toBeDisabled(); + }); + + test("unlocked, non-active steps are enabled", () => { + render( + true, + })} + />, + ); + expect(screen.getByRole("button", { name: /^Data/ })).not.toBeDisabled(); + }); + + test("completed steps show a checkmark", () => { + render( + , + ); + const projectInfoBtn = screen.getByRole("button", { name: /Project Info/ }); + expect(within(projectInfoBtn).getByText("✓")).toBeInTheDocument(); + }); + + test("clicking an enabled step calls onNavigate with its id", async () => { + render( + true })} + />, + ); + await userEvent.click(screen.getByRole("button", { name: /^Data/ })); + expect(onNavigate).toHaveBeenCalledWith("data"); + }); + + test("clicking a disabled step does not call onNavigate", async () => { + render(); + // "Data" is locked — its button is disabled, click should not fire + await userEvent.click(screen.getByRole("button", { name: /^Data/ })); + expect(onNavigate).not.toHaveBeenCalled(); + }); + }); + + // ── start over dialog ───────────────────────────────────────────────────── + + describe("start over dialog", () => { + test("'← Start over' button opens the confirmation dialog", async () => { + render(); + expect(screen.queryByText("Start over?")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "← Start over" })); + expect(screen.getByText("Start over?")).toBeInTheDocument(); + }); + + test("'Yes, start over' calls onStartOver", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "← Start over" })); + await userEvent.click(screen.getByRole("button", { name: "Yes, start over" })); + expect(onStartOver).toHaveBeenCalledTimes(1); + }); + + test("'Cancel' closes the dialog without calling onStartOver", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "← Start over" })); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onStartOver).not.toHaveBeenCalled(); + expect(screen.queryByText("Start over?")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/frontend/tests/Variables.test.tsx b/packages/frontend/tests/Variables.test.tsx new file mode 100644 index 0000000..40eb4c0 --- /dev/null +++ b/packages/frontend/tests/Variables.test.tsx @@ -0,0 +1,422 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Variables from "../src/pages/Variables"; + +// Mirrors VariableFields from @jspsych/metadata — defined locally so we don't +// need to import from the real package (the component only uses it as a type). +type VarFields = { + name: string; + value?: string; + description?: string | Record; + levels?: string[]; + minValue?: number; + maxValue?: number; +}; + +function makeVar(name: string, overrides: Partial = {}): VarFields { + return { name, ...overrides }; +} + +// Unknown var: missing or 'unknown' type / description (lands in "Missing descriptions"). +function unknownVar(name: string, overrides: Partial = {}) { + return makeVar(name, { value: "unknown", ...overrides }); +} + +// Known var: has a concrete type and description (lands in "Other variables"). +function knownVar(name: string, overrides: Partial = {}) { + return makeVar(name, { + value: "string", + description: "A description", + ...overrides, + }); +} + +function makeMeta(vars: VarFields[] = []) { + const map = new Map(vars.map((v) => [v.name, v])); + return { + getVariableNames: jest.fn().mockReturnValue(vars.map((v) => v.name)), + getVariable: jest.fn().mockImplementation((name: string) => map.get(name)), + updateVariable: jest.fn(), + } as any; +} + +/** Returns the
  • element for a variable by its name. */ +function varRow(name: string) { + return screen.getByText(name).closest("li")!; +} + +let onComplete: jest.Mock; + +beforeEach(() => { + onComplete = jest.fn(); +}); + +describe("Variables", () => { + // ── sections ───────────────────────────────────────────────────────────── + + describe("sections", () => { + test("puts unknowns in 'Missing descriptions' and knowns in 'Other variables'", () => { + const meta = makeMeta([unknownVar("rt"), knownVar("stimulus")]); + render(); + + const missing = screen.getByText("Missing descriptions").closest("section")!; + const other = screen.getByText("Other variables").closest("section")!; + expect(within(missing).getByText("rt")).toBeInTheDocument(); + expect(within(other).getByText("stimulus")).toBeInTheDocument(); + }); + + test("shows no 'Missing descriptions' section when all vars are known", () => { + const meta = makeMeta([knownVar("stimulus"), knownVar("rt")]); + render(); + + expect(screen.queryByText("Missing descriptions")).not.toBeInTheDocument(); + expect(screen.getByText("Other variables")).toBeInTheDocument(); + }); + + test("shows no 'Other variables' section when all vars are unknown", () => { + const meta = makeMeta([unknownVar("rt"), unknownVar("trial_type")]); + render(); + + expect(screen.getByText("Missing descriptions")).toBeInTheDocument(); + expect(screen.queryByText("Other variables")).not.toBeInTheDocument(); + }); + + test("sorts vars alphabetically within each section", () => { + const meta = makeMeta([unknownVar("z_var"), unknownVar("a_var")]); + render(); + + const items = screen.getAllByRole("listitem"); + const names = items.map((li) => li.querySelector("span")?.textContent); + expect(names.indexOf("a_var")).toBeLessThan(names.indexOf("z_var")); + }); + + test("renders the total variable count in the header", () => { + const meta = makeMeta([unknownVar("rt"), knownVar("stimulus")]); + render(); + expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("2"); + }); + + test("renders cleanly with zero variables", () => { + render(); + expect(screen.queryByText("Missing descriptions")).not.toBeInTheDocument(); + expect(screen.queryByText("Other variables")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Continue →" })).toBeInTheDocument(); + }); + }); + + // ── expand / collapse ──────────────────────────────────────────────────── + + describe("expand / collapse", () => { + test("unknown vars start expanded, known vars start collapsed", () => { + const meta = makeMeta([unknownVar("rt"), knownVar("stimulus")]); + render(); + + expect(within(varRow("rt")).getByRole("button")).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(within(varRow("stimulus")).getByRole("button")).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + + test("clicking a row header toggles it open and closed", async () => { + const meta = makeMeta([knownVar("stimulus")]); + render(); + + const header = within(varRow("stimulus")).getByRole("button"); + expect(header).toHaveAttribute("aria-expanded", "false"); + + await userEvent.click(header); + expect(header).toHaveAttribute("aria-expanded", "true"); + + await userEvent.click(header); + expect(header).toHaveAttribute("aria-expanded", "false"); + }); + + test("expanded row shows textarea, type select, and chevron ▲", async () => { + const meta = makeMeta([unknownVar("rt")]); + render(); + + // rt starts expanded + const row = varRow("rt"); + expect(within(row).getByRole("textbox")).toBeInTheDocument(); + expect(within(row).getByRole("combobox")).toBeInTheDocument(); + expect(within(row).getByText("▲")).toBeInTheDocument(); + }); + + test("collapsed row hides textarea and type select", () => { + const meta = makeMeta([knownVar("stimulus")]); + render(); + + const row = varRow("stimulus"); + expect(within(row).queryByRole("textbox")).not.toBeInTheDocument(); + expect(within(row).queryByRole("combobox")).not.toBeInTheDocument(); + }); + }); + + // ── expand all ─────────────────────────────────────────────────────────── + + describe("expand all toggle", () => { + test("checking 'Expand all' expands every row", async () => { + const meta = makeMeta([unknownVar("rt"), knownVar("stimulus")]); + render(); + + await userEvent.click(screen.getByRole("checkbox", { name: /Expand all/ })); + + expect(within(varRow("rt")).getByRole("button")).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(within(varRow("stimulus")).getByRole("button")).toHaveAttribute( + "aria-expanded", + "true", + ); + }); + + test("unchecking 'Expand all' collapses every row", async () => { + const meta = makeMeta([unknownVar("rt"), knownVar("stimulus")]); + render(); + + const toggle = screen.getByRole("checkbox", { name: /Expand all/ }); + await userEvent.click(toggle); // expand all + await userEvent.click(toggle); // collapse all + + expect(within(varRow("rt")).getByRole("button")).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(within(varRow("stimulus")).getByRole("button")).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + + test("toggle is checked only when all rows are expanded", async () => { + const meta = makeMeta([unknownVar("rt"), knownVar("stimulus")]); + render(); + + const toggle = screen.getByRole("checkbox", { name: /Expand all/ }); + expect(toggle).not.toBeChecked(); + + await userEvent.click(toggle); + expect(toggle).toBeChecked(); + }); + }); + + // ── badges ─────────────────────────────────────────────────────────────── + + describe("badges", () => { + test("shows '⚠ no description' badge on unknown vars", () => { + const meta = makeMeta([unknownVar("rt")]); + render(); + expect(within(varRow("rt")).getByText(/⚠ no description/)).toBeInTheDocument(); + }); + + test("shows type badge with the variable's type", () => { + const meta = makeMeta([knownVar("rt", { value: "numeric", description: "RT" })]); + render(); + expect(within(varRow("rt")).getByText("numeric")).toBeInTheDocument(); + }); + + test("shows 'unknown' type badge when value is absent", () => { + const meta = makeMeta([makeVar("rt")]); + render(); + // Scope to the header button to avoid matching the 'unknown' option inside the select. + const header = within(varRow("rt")).getByRole("button"); + expect(within(header).getByText("unknown")).toBeInTheDocument(); + }); + }); + + // ── description editing ─────────────────────────────────────────────────── + + describe("description editing", () => { + test("typing in textarea calls updateVariable with the new description", async () => { + const meta = makeMeta([unknownVar("rt")]); + render(); + + const textarea = within(varRow("rt")).getByRole("textbox"); + await userEvent.clear(textarea); + await userEvent.type(textarea, "Reaction time"); + + expect(meta.updateVariable).toHaveBeenCalledWith( + "rt", + "description", + expect.objectContaining({ default: expect.stringContaining("Reaction time") }), + ); + }); + + test("badge changes to '✓ filled in' after a description is provided", async () => { + // Use a var with a concrete type but no description — once a description is + // typed, isUnknown() returns false and the badge switches to "✓ filled in". + const meta = makeMeta([makeVar("rt", { value: "numeric" })]); + render(); + + const textarea = within(varRow("rt")).getByRole("textbox"); + await userEvent.type(textarea, "Reaction time"); + + expect(within(varRow("rt")).getByText(/✓ filled in/)).toBeInTheDocument(); + expect(within(varRow("rt")).queryByText(/⚠ no description/)).not.toBeInTheDocument(); + }); + + test("progress counter increments as descriptions are filled in", async () => { + // Concrete types, no descriptions → both start as unknown; filling in one + // moves the counter from 0/2 to 1/2. + const meta = makeMeta([makeVar("rt", { value: "numeric" }), makeVar("stimulus", { value: "string" })]); + render(); + + expect(screen.getByText(/0 \/ 2 filled in/)).toBeInTheDocument(); + + const textarea = within(varRow("rt")).getByRole("textbox"); + await userEvent.type(textarea, "Reaction time"); + + expect(screen.getByText(/1 \/ 2 filled in/)).toBeInTheDocument(); + }); + + test("shows plugin-description caption when description comes from plugin docs", async () => { + // No 'default' key → description is from plugin; caption appears when expanded. + // isUnknown() returns false here (non-unknown type + non-empty description), + // so the row starts in "Other variables" and is collapsed by default. + const meta = makeMeta([ + makeVar("rt", { + value: "numeric", + description: { somePlugin: "Response time in ms" }, + }), + ]); + render(); + + await userEvent.click(within(varRow("rt")).getByRole("button")); // expand + expect( + within(varRow("rt")).getByText(/From plugin documentation/), + ).toBeInTheDocument(); + }); + }); + + // ── type editing ────────────────────────────────────────────────────────── + + describe("type editing", () => { + test("changing the type select calls updateVariable", async () => { + const meta = makeMeta([unknownVar("rt")]); + render(); + + const select = within(varRow("rt")).getByRole("combobox"); + await userEvent.selectOptions(select, "numeric"); + + expect(meta.updateVariable).toHaveBeenCalledWith("rt", "value", "numeric"); + }); + + test("all five type options are present", () => { + const meta = makeMeta([unknownVar("rt")]); + render(); + + const select = within(varRow("rt")).getByRole("combobox"); + const options = within(select).getAllByRole("option").map((o) => o.textContent); + expect(options).toEqual( + expect.arrayContaining(["string", "numeric", "boolean", "array", "unknown"]), + ); + }); + }); + + // ── levels ──────────────────────────────────────────────────────────────── + + describe("levels", () => { + test("shows detected levels when expanded", async () => { + const meta = makeMeta([ + knownVar("condition", { levels: ["A", "B", "C"] }), + ]); + render(); + + await userEvent.click(within(varRow("condition")).getByRole("button")); + expect(within(varRow("condition")).getByText("A")).toBeInTheDocument(); + expect(within(varRow("condition")).getByText("B")).toBeInTheDocument(); + }); + + test("truncates at 5 and shows 'Show all N levels' button", async () => { + const levels = ["A", "B", "C", "D", "E", "F", "G"]; + const meta = makeMeta([knownVar("condition", { levels })]); + render(); + + await userEvent.click(within(varRow("condition")).getByRole("button")); + + expect(within(varRow("condition")).queryByText("F")).not.toBeInTheDocument(); + expect( + within(varRow("condition")).getByText("Show all 7 levels ▼"), + ).toBeInTheDocument(); + }); + + test("'Show all' expands levels and 'Collapse' hides them again", async () => { + const levels = ["A", "B", "C", "D", "E", "F", "G"]; + const meta = makeMeta([knownVar("condition", { levels })]); + render(); + + await userEvent.click(within(varRow("condition")).getByRole("button")); // expand row + + await userEvent.click(within(varRow("condition")).getByText("Show all 7 levels ▼")); + expect(within(varRow("condition")).getByText("F")).toBeInTheDocument(); + expect(within(varRow("condition")).getByText("Collapse ▲")).toBeInTheDocument(); + + await userEvent.click(within(varRow("condition")).getByText("Collapse ▲")); + expect(within(varRow("condition")).queryByText("F")).not.toBeInTheDocument(); + }); + + test("no 'Show all' button when levels count is ≤ 5", async () => { + const meta = makeMeta([knownVar("condition", { levels: ["A", "B", "C"] })]); + render(); + + await userEvent.click(within(varRow("condition")).getByRole("button")); + expect(within(varRow("condition")).queryByText(/Show all/)).not.toBeInTheDocument(); + }); + }); + + // ── range ───────────────────────────────────────────────────────────────── + + describe("range display", () => { + test("shows min – max when both are present", async () => { + const meta = makeMeta([ + knownVar("rt", { value: "numeric", minValue: 100, maxValue: 2500 }), + ]); + render(); + + await userEvent.click(within(varRow("rt")).getByRole("button")); + expect(within(varRow("rt")).getByText(/100/)).toBeInTheDocument(); + expect(within(varRow("rt")).getByText(/2500/)).toBeInTheDocument(); + }); + + test("hides range section when neither min nor max is defined", async () => { + const meta = makeMeta([knownVar("condition")]); + render(); + + await userEvent.click(within(varRow("condition")).getByRole("button")); + expect(within(varRow("condition")).queryByText("Range")).not.toBeInTheDocument(); + }); + + test("shows em-dash placeholder for missing half of range", async () => { + const meta = makeMeta([ + knownVar("rt_min", { value: "numeric", minValue: 50 }), + knownVar("rt_max", { value: "numeric", maxValue: 2000 }), + ]); + render(); + + await userEvent.click(within(varRow("rt_min")).getByRole("button")); + expect(within(varRow("rt_min")).getByText(/50/)).toBeInTheDocument(); + expect(within(varRow("rt_min")).getByText(/—/)).toBeInTheDocument(); + + await userEvent.click(within(varRow("rt_max")).getByRole("button")); + expect(within(varRow("rt_max")).getByText(/2000/)).toBeInTheDocument(); + expect(within(varRow("rt_max")).getByText(/—/)).toBeInTheDocument(); + }); + }); + + // ── continue ────────────────────────────────────────────────────────────── + + describe("continue", () => { + test("Continue button calls onComplete", async () => { + const meta = makeMeta([knownVar("rt")]); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Continue →" })); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + }); +}); From 74475d5ad72ea10df1debcdb874dff811c037740 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Thu, 18 Jun 2026 18:42:19 -0400 Subject: [PATCH 2/2] test(frontend): update DataUpload tests for merged preflight/builder changes The DataUpload component gained a parseJsonData-based join-key preflight (#115) and a buildPsychDSDataFiles conversion step (#103/#114) since these tests were written. Expand the @jspsych/metadata mock (parseJsonData, parseCSV, buildPsychDSDataFiles, deriveFallbackBase, isValidPsychDSDataFilename, PSYCHDS_IGNORE_*) and the metadata stub (getExtractedArrays/Objects, getArrayJoinKeys) so the join-key chooser and session-sync paths exercise correctly. Co-Authored-By: Claude Opus 4.8 --- packages/frontend/tests/DataUpload.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/frontend/tests/DataUpload.test.tsx b/packages/frontend/tests/DataUpload.test.tsx index 92d1c65..d94aab3 100644 --- a/packages/frontend/tests/DataUpload.test.tsx +++ b/packages/frontend/tests/DataUpload.test.tsx @@ -9,6 +9,16 @@ jest.mock("@jspsych/metadata", () => ({ __esModule: true, default: jest.fn(), analyzeJoinKeys: jest.fn(), + // Preflight parses JSON via parseJsonData (tagSourceRecordId is a no-op for a single array); + // return the parsed array so the join-key preflight runs. runGenerate then builds Psych-DS + // files, which the component only iterates over — an empty list is sufficient for these tests. + parseJsonData: jest.fn((content: string) => JSON.parse(content)), + parseCSV: jest.fn(() => []), + buildPsychDSDataFiles: jest.fn(() => []), + deriveFallbackBase: jest.fn((stem: string) => `subject-${stem}`), + isValidPsychDSDataFilename: jest.fn(() => false), + PSYCHDS_IGNORE_FILENAME: ".psychds-ignore", + PSYCHDS_IGNORE_CONTENT: "", })); jest.mock("jszip", () => ({ @@ -29,6 +39,10 @@ function makeMeta(varNames: string[] = []) { return { generate: jest.fn().mockResolvedValue(undefined), getVariableNames: jest.fn().mockReturnValue(varNames), + // runGenerate feeds these into buildPsychDSDataFiles when converting each file. + getExtractedArrays: jest.fn().mockReturnValue(new Map()), + getExtractedObjects: jest.fn().mockReturnValue(new Map()), + getArrayJoinKeys: jest.fn().mockReturnValue(["trial_index"]), } as any; }