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..d94aab3
--- /dev/null
+++ b/packages/frontend/tests/DataUpload.test.tsx
@@ -0,0 +1,467 @@
+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(),
+ // 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", () => ({
+ __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),
+ // 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;
+}
+
+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);
+ });
+ });
+});