From b23ca65d35fe8c895b7df04584146897a0e3c6d6 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 01:50:29 +0000 Subject: [PATCH 1/8] test(canvas): add component tests for OnboardingWizard and PurchaseSuccessModal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnboardingWizard: visibility gates, 4-step flow, skip/dismiss, localStorage persistence, progress bar, aria-live announcements, auto-advance from welcome→api-key on nodes change. PurchaseSuccessModal: URL param gating, portal rendering, item name display, 5s auto-dismiss (fake timers), backdrop/Escape close, replaceState URL stripping, aria-modal/focus management. Co-Authored-By: Claude Opus 4.7 --- .../__tests__/OnboardingWizard.test.tsx | 174 ++++++++++++ .../__tests__/PurchaseSuccessModal.test.tsx | 255 ++++++++++++++++++ 2 files changed, 429 insertions(+) create mode 100644 canvas/src/components/__tests__/OnboardingWizard.test.tsx create mode 100644 canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx diff --git a/canvas/src/components/__tests__/OnboardingWizard.test.tsx b/canvas/src/components/__tests__/OnboardingWizard.test.tsx new file mode 100644 index 00000000..54368950 --- /dev/null +++ b/canvas/src/components/__tests__/OnboardingWizard.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +/** + * Tests for OnboardingWizard component. + * + * Covers: renders only when not dismissed, renders 4 steps, dismiss + * button, localStorage persistence, progress bar width, step navigation, + * auto-advance from welcome→api-key on nodes change, aria-live region. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OnboardingWizard } from "../OnboardingWizard"; +import { useCanvasStore } from "@/store/canvas"; + +const mockStoreState = { + nodes: [] as Array<{ id: string; data: Record }>, + selectedNodeId: null as string | null, + panelTab: "chat" as string, + agentMessages: {} as Record, + setPanelTab: vi.fn(), +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + (sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState), + { getState: () => mockStoreState }, + ), +})); + +const STORAGE_KEY = "molecule-onboarding-complete"; + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string): string | null => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { store[key] = value; }), + removeItem: vi.fn((key: string) => { delete store[key]; }), + clear: () => { store = {}; }, + getStore: () => store, + }; +})(); +Object.defineProperty(window, "localStorage", { value: localStorageMock }); + +afterEach(() => { + cleanup(); + localStorageMock.clear(); + vi.clearAllMocks(); + // Reset mutable store properties (mockStoreState is const, so mutate fields) + mockStoreState.nodes = []; + mockStoreState.selectedNodeId = null; + mockStoreState.panelTab = "chat"; + mockStoreState.agentMessages = {}; + mockStoreState.setPanelTab = vi.fn(); +}); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("OnboardingWizard — visibility", () => { + it("renders nothing when localStorage has the complete flag", () => { + localStorageMock.getItem.mockReturnValueOnce("true"); + render(); + expect(screen.queryByRole("complementary")).toBeNull(); + }); + + it("renders the wizard for first-time users (no localStorage flag)", () => { + localStorageMock.getItem.mockReturnValueOnce(null); + render(); + expect(screen.getByRole("complementary", { name: "Onboarding guide" })).toBeTruthy(); + }); +}); + +describe("OnboardingWizard — steps", () => { + beforeEach(() => { + localStorageMock.getItem.mockReturnValue(null); + }); + + it("renders step 1 'Welcome to Molecule AI' on first paint", () => { + render(); + expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy(); + expect(screen.getByText("Step 1 of 4")).toBeTruthy(); + }); + + it("renders the 'Skip guide' button", () => { + render(); + expect(screen.getByRole("button", { name: "Skip onboarding guide" })).toBeTruthy(); + }); + + it("renders the progress bar", () => { + render(); + // Progress bar is inside a div + const bar = document.body.querySelector(".h-full.bg-gradient-to-r"); + expect(bar).toBeTruthy(); + // Step 1 should be 25% wide + expect(bar?.getAttribute("style")).toContain("25%"); + }); + + it("advances to step 2 'Set your API key' when Next is clicked", () => { + render(); + expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(screen.getByText("Set your API key")).toBeTruthy(); + expect(screen.getByText("Step 2 of 4")).toBeTruthy(); + }); + + it("advances to step 3 'Send your first message' when Next is clicked twice", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(screen.getByText("Send your first message")).toBeTruthy(); + expect(screen.getByText("Step 3 of 4")).toBeTruthy(); + }); + + it("shows 'Get Started' button on the last step", () => { + render(); + // Navigate to done step + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(screen.getByText("You're all set!")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Get Started" })).toBeTruthy(); + }); + + it("dismisses the wizard when 'Skip guide' is clicked", () => { + render(); + expect(screen.getByRole("complementary")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Skip onboarding guide" })); + expect(screen.queryByRole("complementary")).toBeNull(); + }); + + it("persists the dismissed state to localStorage when dismissed", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Skip onboarding guide" })); + expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, "true"); + }); +}); + +describe("OnboardingWizard — auto-advance", () => { + beforeEach(() => { + localStorageMock.getItem.mockReturnValue(null); + }); + + it("auto-advances from welcome to api-key when nodes appear", async () => { + const { unmount } = render(); + expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy(); + + // Simulate a node being added to the store and re-render + mockStoreState.nodes = [{ id: "ws-1", data: {} }]; + render(); + + await waitFor(() => { + expect(screen.queryByText("Welcome to Molecule AI")).toBeNull(); + }); + expect(screen.getByText("Set your API key")).toBeTruthy(); + unmount(); + }); +}); + +describe("OnboardingWizard — accessibility", () => { + beforeEach(() => { + localStorageMock.getItem.mockReturnValue(null); + }); + + it("has aria-live='polite' region for step announcements", () => { + render(); + const liveRegion = document.body.querySelector('[aria-live="polite"]'); + expect(liveRegion).toBeTruthy(); + expect(liveRegion?.textContent).toMatch(/onboarding step 1/i); + }); + + it("has role=complementary with aria-label", () => { + render(); + expect(screen.getByRole("complementary", { name: "Onboarding guide" })).toBeTruthy(); + }); +}); diff --git a/canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx b/canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx new file mode 100644 index 00000000..75f7dd3c --- /dev/null +++ b/canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx @@ -0,0 +1,255 @@ +// @vitest-environment jsdom +/** + * Tests for PurchaseSuccessModal component. + * + * Covers: no render when no URL params, renders with ?purchase_success=1, + * portal rendering, item name from &item=, auto-dismiss after 5s, + * manual dismiss, backdrop click close, Escape key close, URL stripping, + * focus management. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PurchaseSuccessModal } from "../PurchaseSuccessModal"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function pushUrl(url: string) { + window.history.pushState({}, "", url); +} +function replaceUrl(url: string) { + window.history.replaceState({}, "", url); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("PurchaseSuccessModal — render conditions", () => { + beforeEach(() => { + replaceUrl("http://localhost/"); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("renders nothing when URL has no purchase_success param", () => { + replaceUrl("http://localhost/"); + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders nothing on a plain URL", () => { + replaceUrl("http://localhost/dashboard?foo=bar"); + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders the dialog when ?purchase_success=1 is present", async () => { + replaceUrl("http://localhost/?purchase_success=1"); + render(); + // useEffect fires after mount + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + expect(screen.queryByRole("dialog")).toBeTruthy(); + }); + + it("renders the dialog when ?purchase_success=true is present", async () => { + replaceUrl("http://localhost/?purchase_success=true"); + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + expect(screen.queryByRole("dialog")).toBeTruthy(); + }); + + it("renders a portal attached to document.body", async () => { + replaceUrl("http://localhost/?purchase_success=1"); + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog).toBeTruthy(); + }); + + it("shows the item name when &item= is present", async () => { + replaceUrl("http://localhost/?purchase_success=1&item=MyAgent"); + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + expect(screen.getByText("MyAgent")).toBeTruthy(); + expect(screen.getByText("Purchase successful")).toBeTruthy(); + }); + + it("shows 'Your new agent' when no item param is present", async () => { + replaceUrl("http://localhost/?purchase_success=1"); + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + expect(screen.getByText("Your new agent")).toBeTruthy(); + }); + + it("decodes URI-encoded item names", async () => { + replaceUrl("http://localhost/?purchase_success=1&item=Claude%20Code%20Agent"); + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + expect(screen.getByText("Claude Code Agent")).toBeTruthy(); + }); +}); + +describe("PurchaseSuccessModal — dismiss", () => { + beforeEach(() => { + replaceUrl("http://localhost/?purchase_success=1&item=TestItem"); + vi.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("closes the dialog when the close button is clicked", async () => { + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + expect(screen.getByRole("dialog")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + await act(async () => { + vi.advanceTimersByTime(10); + }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("closes the dialog when the backdrop is clicked", async () => { + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + expect(screen.getByRole("dialog")).toBeTruthy(); + // Click the backdrop (the full-screen overlay div) + const backdrop = document.body.querySelector('[aria-hidden="true"]'); + if (backdrop) fireEvent.click(backdrop); + await act(async () => { + vi.advanceTimersByTime(10); + }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("closes on Escape key", async () => { + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + expect(screen.getByRole("dialog")).toBeTruthy(); + fireEvent.keyDown(window, { key: "Escape" }); + await act(async () => { + vi.advanceTimersByTime(10); + }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("auto-dismisses after 5 seconds", async () => { + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + expect(screen.getByRole("dialog")).toBeTruthy(); + + // Advance 5 seconds + act(() => { vi.advanceTimersByTime(5000); }); + await act(async () => { /* flush */ }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("does not auto-dismiss before 5 seconds", async () => { + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + expect(screen.getByRole("dialog")).toBeTruthy(); + + act(() => { vi.advanceTimersByTime(4900); }); + await act(async () => { /* flush */ }); + expect(screen.queryByRole("dialog")).toBeTruthy(); + }); +}); + +describe("PurchaseSuccessModal — URL stripping", () => { + beforeEach(() => { + replaceUrl("http://localhost/?purchase_success=1&item=TestItem"); + vi.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("strips purchase_success and item params from the URL on mount", async () => { + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + const url = new URL(window.location.href); + expect(url.searchParams.get("purchase_success")).toBeNull(); + expect(url.searchParams.get("item")).toBeNull(); + }); + + it("uses replaceState (not pushState) so back-button does not re-trigger", async () => { + const replaceSpy = vi.spyOn(window.history, "replaceState"); + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + expect(replaceSpy).toHaveBeenCalled(); + }); +}); + +describe("PurchaseSuccessModal — accessibility", () => { + beforeEach(() => { + replaceUrl("http://localhost/?purchase_success=1&item=TestItem"); + vi.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("has aria-modal=true on the dialog", async () => { + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + const dialog = screen.getByRole("dialog"); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + }); + + it("has aria-labelledby pointing to the title", async () => { + render(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + const dialog = screen.getByRole("dialog"); + const labelledby = dialog.getAttribute("aria-labelledby"); + expect(labelledby).toBeTruthy(); + expect(document.getElementById(labelledby!)).toBeTruthy(); + expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i); + }); + + it("moves focus to the close button on open", async () => { + render(); + await act(async () => { + // Two rAFs for focus: one from the effect, one from the RAF wrapper + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + }); + expect(document.activeElement?.textContent).toMatch(/close/i); + }); +}); From ee5648b3d1b3d292d21ddb9a3ab8c893c0ff5f80 Mon Sep 17 00:00:00 2001 From: Molecule AI Core Platform Lead Date: Sun, 10 May 2026 01:53:43 +0000 Subject: [PATCH 2/8] trigger From 2549c4cbcc7faf31dfea415f33d9f1ae39ac3fbe Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 02:06:44 +0000 Subject: [PATCH 3/8] test(canvas): add component tests for SearchDialog and ContextMenu SearchDialog: Cmd+K/Ctrl+K shortcut, Escape close, input focus via rAF, text filtering by name/role/status, arrow-key navigation, Enter select, aria-combobox/listbox/option attributes, footer workspace count. ContextMenu: null guard, node header, outside-click/Escape/Tab close, conditional items (online vs offline vs paused), team items, dividers, danger Delete styling, keyboard navigation, Pause/Resume API calls. Co-Authored-By: Claude Opus 4.7 --- .../components/__tests__/ContextMenu.test.tsx | 376 ++++++++++++++++++ .../__tests__/SearchDialog.test.tsx | 351 ++++++++++++++++ 2 files changed, 727 insertions(+) create mode 100644 canvas/src/components/__tests__/ContextMenu.test.tsx create mode 100644 canvas/src/components/__tests__/SearchDialog.test.tsx diff --git a/canvas/src/components/__tests__/ContextMenu.test.tsx b/canvas/src/components/__tests__/ContextMenu.test.tsx new file mode 100644 index 00000000..9e8cb693 --- /dev/null +++ b/canvas/src/components/__tests__/ContextMenu.test.tsx @@ -0,0 +1,376 @@ +// @vitest-environment jsdom +/** + * Tests for ContextMenu component. + * + * Covers: null guard, node header (name + status), outside-click close, + * Escape close, arrow-key navigation, conditional menu items by status, + * danger items, dividers, rAF position clamping. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ContextMenu } from "../ContextMenu"; +import { useCanvasStore } from "@/store/canvas"; +import { showToast } from "../Toaster"; + +// ─── Mock Toaster ───────────────────────────────────────────────────────────── + +vi.mock("../Toaster", () => ({ + showToast: vi.fn(), +})); + +// ─── Mock API ──────────────────────────────────────────────────────────────── + +const apiPost = vi.fn().mockResolvedValue(undefined as void); +const apiPatch = vi.fn().mockResolvedValue(undefined as void); +vi.mock("@/lib/api", () => ({ + api: { + post: apiPost, + patch: apiPatch, + get: vi.fn(), + }, +})); + +// ─── Mock store ────────────────────────────────────────────────────────────── + +const mockStoreState = { + contextMenu: null as { + x: number; + y: number; + nodeId: string; + nodeData: { + name: string; + status: string; + tier: number; + role: string; + parentId?: string | null; + collapsed?: boolean; + }; + } | null, + closeContextMenu: vi.fn(), + updateNodeData: vi.fn(), + selectNode: vi.fn(), + setPanelTab: vi.fn(), + nestNode: vi.fn().mockResolvedValue(undefined as void), + setPendingDelete: vi.fn(), + setCollapsed: vi.fn(), + arrangeChildren: vi.fn(), + nodes: [] as Array<{ + id: string; + data: { parentId?: string | null }; + }>, +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + (sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState), + { getState: () => mockStoreState }, + ), +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function openMenu(overrides?: Partial>) { + mockStoreState.contextMenu = { + x: 100, + y: 200, + nodeId: "n1", + nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" }, + ...overrides, + }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("ContextMenu — visibility", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.contextMenu = null; + mockStoreState.closeContextMenu.mockClear(); + mockStoreState.updateNodeData.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + mockStoreState.nestNode.mockClear(); + mockStoreState.setPendingDelete.mockClear(); + mockStoreState.setCollapsed.mockClear(); + mockStoreState.arrangeChildren.mockClear(); + mockStoreState.nodes = []; + apiPost.mockReset(); + apiPatch.mockReset(); + vi.mocked(showToast).mockClear(); + }); + + it("renders nothing when contextMenu is null", () => { + mockStoreState.contextMenu = null; + render(); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("renders the menu when contextMenu is set", () => { + openMenu(); + render(); + expect(screen.getByRole("menu")).toBeTruthy(); + }); + + it("has aria-label describing the node name", () => { + openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } }); + render(); + expect(screen.getByRole("menu").getAttribute("aria-label")).toBe("Actions for Alice"); + }); + + it("shows the node name in the header", () => { + openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } }); + render(); + expect(screen.getByText("Bob")).toBeTruthy(); + }); + + it("shows the node status in the header", () => { + openMenu({ nodeData: { name: "Alice", status: "failed", tier: 4, role: "assistant" } }); + render(); + expect(screen.getByText("failed")).toBeTruthy(); + }); +}); + +describe("ContextMenu — close", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.contextMenu = null; + mockStoreState.closeContextMenu.mockClear(); + mockStoreState.updateNodeData.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + mockStoreState.nestNode.mockClear(); + mockStoreState.setPendingDelete.mockClear(); + mockStoreState.setCollapsed.mockClear(); + mockStoreState.arrangeChildren.mockClear(); + mockStoreState.nodes = []; + apiPost.mockReset(); + apiPatch.mockReset(); + vi.mocked(showToast).mockClear(); + }); + + it("closes when clicking outside the menu", () => { + openMenu(); + render(); + fireEvent.mouseDown(document.body); + expect(mockStoreState.closeContextMenu).toHaveBeenCalled(); + }); + + it("closes when Escape is pressed", () => { + openMenu(); + render(); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(mockStoreState.closeContextMenu).toHaveBeenCalled(); + }); + + it("closes when Tab is pressed", () => { + openMenu(); + render(); + fireEvent.keyDown(document.body, { key: "Tab" }); + expect(mockStoreState.closeContextMenu).toHaveBeenCalled(); + }); +}); + +describe("ContextMenu — menu items", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.contextMenu = null; + mockStoreState.closeContextMenu.mockClear(); + mockStoreState.updateNodeData.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + mockStoreState.nestNode.mockClear(); + mockStoreState.setPendingDelete.mockClear(); + mockStoreState.setCollapsed.mockClear(); + mockStoreState.arrangeChildren.mockClear(); + mockStoreState.nodes = []; + apiPost.mockReset(); + apiPatch.mockReset(); + vi.mocked(showToast).mockClear(); + }); + + it("shows Chat and Terminal only for online nodes", () => { + openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } }); + render(); + expect(screen.getByRole("menuitem", { name: /chat/i })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: /terminal/i })).toBeTruthy(); + }); + + it("hides Chat and Terminal for offline nodes", () => { + openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } }); + render(); + expect(screen.queryByRole("menuitem", { name: /chat/i })).toBeNull(); + expect(screen.queryByRole("menuitem", { name: /terminal/i })).toBeNull(); + }); + + it("shows Pause for online nodes (not paused)", () => { + openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } }); + render(); + expect(screen.getByRole("menuitem", { name: /pause/i })).toBeTruthy(); + }); + + it("shows Resume for paused nodes (not Pause)", () => { + openMenu({ nodeData: { name: "Carol", status: "paused", tier: 3, role: "writer" } }); + render(); + expect(screen.queryByRole("menuitem", { name: /pause/i })).toBeNull(); + expect(screen.getByRole("menuitem", { name: /resume/i })).toBeTruthy(); + }); + + it("shows Extract from Team only for child nodes", () => { + openMenu({ nodeData: { name: "Child", status: "online", tier: 4, role: "", parentId: "parent1" } }); + render(); + expect(screen.getByRole("menuitem", { name: /extract/i })).toBeTruthy(); + }); + + it("hides Extract from Team for root nodes", () => { + openMenu({ nodeData: { name: "Root", status: "online", tier: 4, role: "", parentId: null } }); + render(); + expect(screen.queryByRole("menuitem", { name: /extract/i })).toBeNull(); + }); + + it("shows team items only when node has children", () => { + openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "" } }); + mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }]; + render(); + expect(screen.getByRole("menuitem", { name: /arrange/i })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: /collapse/i })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: /zoom/i })).toBeTruthy(); + }); + + it("hides team items when node has no children", () => { + openMenu({ nodeData: { name: "Leaf", status: "online", tier: 4, role: "" } }); + mockStoreState.nodes = []; + render(); + expect(screen.queryByRole("menuitem", { name: /arrange/i })).toBeNull(); + expect(screen.queryByRole("menuitem", { name: /collapse/i })).toBeNull(); + expect(screen.queryByRole("menuitem", { name: /zoom/i })).toBeNull(); + }); + + it("shows Collapse Team when collapsed, Expand Team when expanded", () => { + openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "", collapsed: true } }); + mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }]; + render(); + expect(screen.getByRole("menuitem", { name: /expand/i })).toBeTruthy(); + }); + + it("Delete item has danger styling class", () => { + openMenu(); + render(); + const deleteItem = screen.getByRole("menuitem", { name: /delete/i }); + expect(deleteItem.getAttribute("class")).toMatch(/text-bad|bad/); + }); + + it("renders role=separator for dividers", () => { + openMenu(); + render(); + expect(document.body.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0); + }); +}); + +describe("ContextMenu — keyboard navigation", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.contextMenu = null; + mockStoreState.closeContextMenu.mockClear(); + mockStoreState.updateNodeData.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + mockStoreState.nestNode.mockClear(); + mockStoreState.setPendingDelete.mockClear(); + mockStoreState.setCollapsed.mockClear(); + mockStoreState.arrangeChildren.mockClear(); + mockStoreState.nodes = []; + apiPost.mockReset(); + apiPatch.mockReset(); + vi.mocked(showToast).mockClear(); + }); + + it("ArrowDown moves focus to next enabled menuitem", () => { + openMenu(); + render(); + const menu = screen.getByRole("menu"); + // First tab goes to Details (first non-disabled item) + fireEvent.keyDown(menu, { key: "ArrowDown" }); + const buttons = screen.getAllByRole("menuitem"); + const focusedIdx = buttons.findIndex((b) => document.activeElement === b); + expect(focusedIdx).toBeGreaterThanOrEqual(0); + }); + + it("ArrowUp moves focus to previous enabled menuitem", () => { + openMenu(); + render(); + const menu = screen.getByRole("menu"); + fireEvent.keyDown(menu, { key: "ArrowDown" }); + const beforeFocused = document.activeElement; + fireEvent.keyDown(menu, { key: "ArrowUp" }); + // Focus should have moved + expect(document.activeElement).toBeTruthy(); + }); +}); + +describe("ContextMenu — item actions", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.contextMenu = null; + mockStoreState.closeContextMenu.mockClear(); + mockStoreState.updateNodeData.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + mockStoreState.nestNode.mockClear(); + mockStoreState.setPendingDelete.mockClear(); + mockStoreState.setCollapsed.mockClear(); + mockStoreState.arrangeChildren.mockClear(); + mockStoreState.nodes = []; + apiPost.mockReset(); + apiPatch.mockReset(); + vi.mocked(showToast).mockClear(); + }); + + it("Details selects node and opens details tab", () => { + openMenu(); + render(); + fireEvent.click(screen.getByRole("menuitem", { name: /details/i })); + expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); + expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details"); + }); + + it("Chat selects node and opens chat tab", () => { + openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } }); + render(); + fireEvent.click(screen.getByRole("menuitem", { name: /chat/i })); + expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); + expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("chat"); + }); + + it("Delete calls setPendingDelete without closing immediately", () => { + openMenu(); + render(); + fireEvent.click(screen.getByRole("menuitem", { name: /delete/i })); + expect(mockStoreState.setPendingDelete).toHaveBeenCalled(); + expect(mockStoreState.closeContextMenu).toHaveBeenCalled(); + }); + + it("Pause calls the pause API and updates node status optimistically", async () => { + openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } }); + apiPost.mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByRole("menuitem", { name: /pause/i })); + await act(async () => { /* flush */ }); + expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/pause", {}); + expect(mockStoreState.updateNodeData).toHaveBeenCalledWith("n1", { status: "paused" }); + }); + + it("Resume calls the resume API", async () => { + openMenu({ nodeData: { name: "Alice", status: "paused", tier: 4, role: "assistant" } }); + apiPost.mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByRole("menuitem", { name: /resume/i })); + await act(async () => { /* flush */ }); + expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/resume", {}); + }); +}); diff --git a/canvas/src/components/__tests__/SearchDialog.test.tsx b/canvas/src/components/__tests__/SearchDialog.test.tsx new file mode 100644 index 00000000..2e017707 --- /dev/null +++ b/canvas/src/components/__tests__/SearchDialog.test.tsx @@ -0,0 +1,351 @@ +// @vitest-environment jsdom +/** + * Tests for SearchDialog component. + * + * Covers: renders only when open, Cmd+K/Ctrl+K shortcut, Escape close, + * focus management, text filtering (name/role/status), arrow-key + * navigation, Enter to select, footer count, aria attributes. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SearchDialog } from "../SearchDialog"; +import { useCanvasStore } from "@/store/canvas"; + +// ─── Mock store ────────────────────────────────────────────────────────────── + +const mockStoreState = { + searchOpen: false, + setSearchOpen: vi.fn((open: boolean) => { + mockStoreState.searchOpen = open; + }), + nodes: [] as Array<{ + id: string; + data: { + name: string; + status: string; + tier: number; + role: string; + parentId?: string | null; + }; + }>, + selectNode: vi.fn(), + setPanelTab: vi.fn(), +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + (sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState), + { getState: () => mockStoreState }, + ), +})); + +const STORAGE_KEY = "molecule-onboarding-complete"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function dispatchKeydown(key: string, meta = false, ctrl = false) { + fireEvent.keyDown(window, { + key, + metaKey: meta, + ctrlKey: ctrl, + }); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("SearchDialog — visibility", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("does not render when searchOpen is false", () => { + mockStoreState.searchOpen = false; + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders the dialog when searchOpen is true", () => { + mockStoreState.searchOpen = true; + render(); + expect(screen.getByRole("dialog", { name: "Search workspaces" })).toBeTruthy(); + }); +}); + +describe("SearchDialog — keyboard shortcuts", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("opens the dialog when Cmd+K is pressed", () => { + render(); + dispatchKeydown("k", true, false); + expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true); + }); + + it("opens the dialog when Ctrl+K is pressed", () => { + render(); + dispatchKeydown("k", false, true); + expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true); + }); + + it("clears the query when Cmd+K opens the dialog", () => { + render(); + dispatchKeydown("k", true, false); + const input = screen.getByRole("combobox"); + expect(input.getAttribute("value") ?? "").toBe(""); + }); + + it("closes the dialog when Escape is pressed while open", () => { + mockStoreState.searchOpen = true; + render(); + dispatchKeydown("Escape"); + expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false); + }); +}); + +describe("SearchDialog — focus", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("focuses the input when the dialog opens", async () => { + mockStoreState.searchOpen = true; + render(); + await act(async () => { + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + }); + expect(document.activeElement?.getAttribute("role")).toBe("combobox"); + }); + + it("input has the combobox role", () => { + mockStoreState.searchOpen = true; + render(); + expect(screen.getByRole("combobox")).toBeTruthy(); + }); +}); + +describe("SearchDialog — filtering", () => { + beforeEach(() => { + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + { id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } }, + { id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } }, + ]; + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("shows all workspaces when query is empty", () => { + mockStoreState.searchOpen = true; + render(); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.getByText("Bob")).toBeTruthy(); + expect(screen.getByText("Carol")).toBeTruthy(); + }); + + it("filters workspaces by name (case-insensitive)", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "alice" } }); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.queryByText("Bob")).toBeNull(); + expect(screen.queryByText("Carol")).toBeNull(); + }); + + it("filters workspaces by role (case-insensitive)", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "writer" } }); + expect(screen.queryByText("Alice")).toBeNull(); + expect(screen.queryByText("Bob")).toBeNull(); + expect(screen.getByText("Carol")).toBeTruthy(); + }); + + it("filters workspaces by status", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "online" } }); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.queryByText("Bob")).toBeNull(); + expect(screen.getByText("Carol")).toBeTruthy(); + }); + + it("shows 'No workspaces match' when filtering returns nothing", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "xyz123" } }); + expect(screen.getByText("No workspaces match")).toBeTruthy(); + }); + + it("shows 'No workspaces yet' when canvas is empty", () => { + mockStoreState.searchOpen = true; + mockStoreState.nodes = []; + render(); + expect(screen.getByText("No workspaces yet")).toBeTruthy(); + }); +}); + +describe("SearchDialog — listbox navigation", () => { + beforeEach(() => { + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + { id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } }, + { id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } }, + ]; + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("highlights the first result when query is typed", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "a" } }); + // First result (Alice) should be highlighted + const options = screen.getAllByRole("option"); + expect(options[0].getAttribute("aria-selected")).toBe("true"); + }); + + it("ArrowDown moves highlight to the next item", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "a" } }); // All 3 match + fireEvent.keyDown(input, { key: "ArrowDown" }); + const options = screen.getAllByRole("option"); + expect(options[0].getAttribute("aria-selected")).toBe("false"); + expect(options[1].getAttribute("aria-selected")).toBe("true"); + }); + + it("ArrowUp moves highlight to the previous item", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "a" } }); // All 3 match + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "ArrowUp" }); + const options = screen.getAllByRole("option"); + expect(options[0].getAttribute("aria-selected")).toBe("true"); + expect(options[1].getAttribute("aria-selected")).toBe("false"); + }); + + it("Enter selects the highlighted workspace", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "a" } }); // All 3 match + fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob + fireEvent.keyDown(input, { key: "Enter" }); + expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); // Alice + expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details"); + expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false); + }); +}); + +describe("SearchDialog — aria attributes", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("dialog has role=dialog and aria-modal=true", () => { + mockStoreState.searchOpen = true; + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + expect(dialog.getAttribute("aria-label")).toBe("Search workspaces"); + }); + + it("results container has role=listbox", () => { + mockStoreState.searchOpen = true; + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + ]; + render(); + expect(screen.getByRole("listbox")).toBeTruthy(); + }); + + it("each result has role=option", () => { + mockStoreState.searchOpen = true; + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + ]; + render(); + expect(screen.getAllByRole("option").length).toBeGreaterThan(0); + }); +}); + +describe("SearchDialog — footer", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("footer shows singular 'workspace' when count is 1", () => { + mockStoreState.searchOpen = true; + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + ]; + render(); + expect(screen.getByText("1 workspace")).toBeTruthy(); + }); + + it("footer shows plural 'workspaces' when count > 1", () => { + mockStoreState.searchOpen = true; + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + { id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } }, + ]; + render(); + expect(screen.getByText("2 workspaces")).toBeTruthy(); + }); +}); From 67310828e7423f5aaec8e25f536d38fefc067bcf Mon Sep 17 00:00:00 2001 From: Molecule AI Core Platform Lead Date: Sun, 10 May 2026 02:12:21 +0000 Subject: [PATCH 4/8] trigger From 0ee6317c0c06f86764050c05fc17584eaac1c847 Mon Sep 17 00:00:00 2001 From: Molecule AI Core Platform Lead Date: Sun, 10 May 2026 02:13:02 +0000 Subject: [PATCH 5/8] trigger From 6b3ab63bc00891c1fea6d2ead61a7326d3d82f16 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 02:15:02 +0000 Subject: [PATCH 6/8] test(canvas): add tests for StatusBadge, ValidationHint, Spinner StatusBadge: all 3 status variants, aria-label, role=status, config class names. ValidationHint: error/valid/neutral states, warning icon, valid icon, class names. Spinner: sm/md/lg size classes, aria-hidden, motion-safe:animate-spin. Co-Authored-By: Claude Opus 4.7 --- .../src/components/__tests__/Spinner.test.tsx | 58 ++++++++++++++ .../components/__tests__/StatusBadge.test.tsx | 57 ++++++++++++++ .../__tests__/ValidationHint.test.tsx | 77 +++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 canvas/src/components/__tests__/Spinner.test.tsx create mode 100644 canvas/src/components/__tests__/StatusBadge.test.tsx create mode 100644 canvas/src/components/__tests__/ValidationHint.test.tsx diff --git a/canvas/src/components/__tests__/Spinner.test.tsx b/canvas/src/components/__tests__/Spinner.test.tsx new file mode 100644 index 00000000..610f3a03 --- /dev/null +++ b/canvas/src/components/__tests__/Spinner.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +/** + * Tests for Spinner component. + * + * Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class. + */ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Spinner } from "../Spinner"; + +describe("Spinner — size variants", () => { + it("renders with sm size class", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeTruthy(); + expect(svg?.className).toContain("w-3"); + expect(svg?.className).toContain("h-3"); + }); + + it("renders with md size class (default)", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.className).toContain("w-4"); + expect(svg?.className).toContain("h-4"); + }); + + it("renders with lg size class", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.className).toContain("w-5"); + expect(svg?.className).toContain("h-5"); + }); + + it("defaults to md size when no size prop given", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.className).toContain("w-4"); + expect(svg?.className).toContain("h-4"); + }); + + it("has aria-hidden=true so screen readers skip it", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("includes the motion-safe:animate-spin class for CSS animation", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.className).toContain("motion-safe:animate-spin"); + }); + + it("renders exactly one SVG element", () => { + const { container } = render(); + expect(container.querySelectorAll("svg").length).toBe(1); + }); +}); diff --git a/canvas/src/components/__tests__/StatusBadge.test.tsx b/canvas/src/components/__tests__/StatusBadge.test.tsx new file mode 100644 index 00000000..4a8ccddf --- /dev/null +++ b/canvas/src/components/__tests__/StatusBadge.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +/** + * Tests for StatusBadge component. + * + * Covers: renders all three status variants, aria-label, role=status, + * icon presence, className variants, no render when passed invalid status. + */ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StatusBadge } from "../ui/StatusBadge"; + +describe("StatusBadge — render", () => { + it("renders verified status with ✓ icon", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.textContent).toBe("✓"); + expect(badge.getAttribute("aria-label")).toBe("Connection status: verified"); + }); + + it("renders invalid status with ✗ icon", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.textContent).toBe("✗"); + expect(badge.getAttribute("aria-label")).toBe("Connection status: invalid"); + }); + + it("renders unverified status with ○ icon", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.textContent).toBe("○"); + expect(badge.getAttribute("aria-label")).toBe("Connection status: unverified"); + }); + + it("has role=status on the badge element", () => { + render(); + expect(screen.getByRole("status")).toBeTruthy(); + }); + + it("includes the config className on the rendered element", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.className).toContain("status-badge--valid"); + }); + + it("includes status-badge--invalid class for invalid status", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.className).toContain("status-badge--invalid"); + }); + + it("includes status-badge--unverified class for unverified status", () => { + render(); + const badge = screen.getByRole("status"); + expect(badge.className).toContain("status-badge--unverified"); + }); +}); diff --git a/canvas/src/components/__tests__/ValidationHint.test.tsx b/canvas/src/components/__tests__/ValidationHint.test.tsx new file mode 100644 index 00000000..1b2fc015 --- /dev/null +++ b/canvas/src/components/__tests__/ValidationHint.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +/** + * Tests for ValidationHint component. + * + * Covers: error state, valid state, neutral/hidden state, + * aria-live for error, icon rendering. + */ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ValidationHint } from "../ui/ValidationHint"; + +describe("ValidationHint — error state", () => { + it("renders error message when error is a non-null string", () => { + render(); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText("Invalid email address")).toBeTruthy(); + }); + + it("includes the warning icon in error state", () => { + render(); + expect(screen.getByText(/⚠/)).toBeTruthy(); + }); + + it("uses the error class on the paragraph element", () => { + render(); + const el = screen.getByRole("alert"); + expect(el.className).toContain("validation-hint--error"); + }); + + it("renders error even when showValid is true", () => { + render(); + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.queryByText(/✓/)).toBeNull(); + }); +}); + +describe("ValidationHint — valid state", () => { + it("renders valid message when error is null and showValid is true", () => { + render(); + expect(screen.getByText("Valid format")).toBeTruthy(); + }); + + it("includes the checkmark icon in valid state", () => { + render(); + expect(screen.getByText(/✓ Valid format/)).toBeTruthy(); + }); + + it("uses the valid class on the paragraph element", () => { + render(); + const el = document.body.querySelector(".validation-hint--valid"); + expect(el).toBeTruthy(); + }); + + it("renders nothing when error is null and showValid is false (default)", () => { + const { container } = render(); + expect(container.textContent).toBe(""); + }); + + it("renders nothing when error is empty string", () => { + const { container } = render(); + expect(container.textContent).toBe(""); + }); +}); + +describe("ValidationHint — neutral / not-yet-validated", () => { + it("renders nothing when error is null and showValid defaults to false", () => { + const { container } = render(); + expect(container.textContent).toBe(""); + }); + + it("renders nothing when error is undefined", () => { + // @ts-expect-error — testing runtime behavior with undefined + const { container } = render(); + expect(container.textContent).toBe(""); + }); +}); From 9313fc82ac43cabd1c4f100189312d8135395d5e Mon Sep 17 00:00:00 2001 From: Molecule AI Core Platform Lead Date: Sun, 10 May 2026 02:17:06 +0000 Subject: [PATCH 7/8] trigger From 14afa586067c66a84e968a52e9152818de5bd25c Mon Sep 17 00:00:00 2001 From: Molecule AI Core Platform Lead Date: Sun, 10 May 2026 02:23:40 +0000 Subject: [PATCH 8/8] trigger