// @vitest-environment jsdom /** * AssistantDialog (T-172…T-175) — behavior pins. * * - the dialog is closed until the ui-store opens it with a mode; * - extract-actions: the Run button is disabled until text is entered; a * successful run lists the created tasks and toasts the count; * - a 422 (invalid model output) shows the error banner + Retry (the server * wrote nothing); * - find-stale: the days selector (default 90) is sent with the call; the * result renders as links to the pages; * - suggest-metadata: the suggestion renders; Apply calls apply-metadata * with exactly the suggested fields (the server re-checks the match); * Dismiss closes without applying. */ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ assistantOpen: false, assistantContext: null as null | { mode: string; pageId?: string }, setAssistant: vi.fn(), toast: vi.fn(), extractActions: vi.fn(), generateFaqs: vi.fn(), findDuplicates: vi.fn(), findStale: vi.fn(), suggestMetadata: vi.fn(), applyMetadata: vi.fn(), invalidateQueries: vi.fn() })); vi.mock("@/stores/ui-store", () => ({ useUiStore: (sel: (s: Record) => unknown) => sel(mocks), toast: mocks.toast })); vi.mock("@/hooks/use-workspace", () => ({ useWorkspace: () => ({ data: { id: "ws1", slug: "w" } }) })); vi.mock("next/navigation", () => ({ useParams: () => ({ workspace: "w" }), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) })); vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }) })); vi.mock("@/lib/ai", () => ({ assistantApi: { extractActions: mocks.extractActions, generateFaqs: mocks.generateFaqs, findDuplicates: mocks.findDuplicates, findStale: mocks.findStale, suggestMetadata: mocks.suggestMetadata, applyMetadata: mocks.applyMetadata } })); import { AssistantDialog } from "../assistant-dialog"; import { ApiError } from "@/lib/api"; let root: Root; let host: HTMLElement; function q(sel: string): Element | null { return host.querySelector(sel); } async function type(el: HTMLTextAreaElement, value: string) { // React's value tracker blocks a naive `.value =` — set it through the // native prototype setter (the house pattern, code-block/cell-editor tests). const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!; setter.call(el, value); el.dispatchEvent(new Event("input", { bubbles: true })); } async function open(mode: string, pageId?: string) { // The fake store has no subscription — flip the mock state, then force a // fresh render pass that re-reads it (same trick the real zustand store // does for free). mocks.assistantOpen = true; mocks.assistantContext = pageId ? { mode, pageId } : { mode }; await act(async () => { root.render(); }); } beforeEach(() => { document.body.innerHTML = ""; host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); mocks.assistantOpen = false; mocks.assistantContext = null; vi.clearAllMocks(); }); afterEach(async () => { await act(async () => root.unmount()); }); describe("AssistantDialog", () => { it("is closed until the store opens it with a mode", async () => { act(() => root.render()); expect(q('[data-testid="assistant-dialog"]')).toBeNull(); }); it("extract-actions: disabled until text, then runs + lists tasks + toasts", async () => { mocks.extractActions.mockResolvedValue({ tasks: [ { id: "t1", title: "Ship the whiteboard export" }, { id: "t2", title: "Review the Q3 budget", dueDate: "2026-11-01" } ], tokensIn: 10, tokensOut: 20 }); act(() => root.render()); await open("extract-actions"); const input = q('[data-testid="assistant-input"]') as HTMLTextAreaElement; const run = q('[data-testid="assistant-run"]') as HTMLButtonElement; expect(input).toBeTruthy(); expect(run.disabled).toBe(true); // empty text await act(async () => { type(input, "- Ship the whiteboard export\n- Review the Q3 budget"); }); expect(run.disabled).toBe(false); await act(async () => { run.click(); }); expect(mocks.extractActions).toHaveBeenCalledWith("ws1", "- Ship the whiteboard export\n- Review the Q3 budget", expect.anything()); expect(mocks.toast).toHaveBeenCalledWith("success", "Created 2 tasks", "From the AI assistant"); const result = q('[data-testid="assistant-result"]'); expect(result?.textContent).toContain("Ship the whiteboard export"); expect(result?.textContent).toContain("due 2026-11-01"); }); it("a 422 shows the error banner + Retry (nothing written server-side)", async () => { // A REAL ApiError (the component guards with `isApiError` — a plain // object would fall through to the generic message). mocks.extractActions.mockRejectedValue( new ApiError("VALIDATION_ERROR", 422, "The AI returned an invalid structure.", [{ reason: "ai_invalid_structure" }]) ); act(() => root.render()); await open("extract-actions"); const input = q('[data-testid="assistant-input"]') as HTMLTextAreaElement; await act(async () => { type(input, "- one item"); }); await act(async () => { (q('[data-testid="assistant-run"]') as HTMLButtonElement).click(); }); const banner = q('[data-testid="assistant-error-banner"]'); expect(banner?.textContent).toMatch(/invalid result/i); // Retry re-sends the same call (the server-side retry-once already // happened; this is the USER retry). const retry = [...host.querySelectorAll("button")].find((b) => b.textContent === "Retry"); expect(retry).toBeTruthy(); mocks.extractActions.mockClear(); await act(async () => { (retry as HTMLButtonElement).click(); }); expect(mocks.extractActions).toHaveBeenCalledTimes(1); }); it("find-stale: sends the days selector (default 90) and renders page links", async () => { mocks.findStale.mockResolvedValue({ pages: [{ pageId: "s1", title: "Old Doc", updatedAt: "2026-01-01T00:00:00Z", reason: "No activity." }], considered: 5, tokensIn: 10, tokensOut: 20 }); act(() => root.render()); await open("find-stale"); await act(async () => { (q('[data-testid="assistant-run"]') as HTMLButtonElement).click(); }); expect(mocks.findStale).toHaveBeenCalledWith("ws1", { days: 90 }, expect.anything()); const result = q('[data-testid="assistant-result"]'); expect(result?.textContent).toContain("Old Doc"); expect(result?.textContent).toContain("Not updated in 90+ days"); }); it("suggest-metadata: renders the suggestion; Apply calls apply-metadata; Dismiss does not", async () => { mocks.suggestMetadata.mockResolvedValue({ suggestionId: "asm1", pageId: "p1", current: { title: "Old title", description: null }, suggested: { title: "Revised: Old title", description: "A better description." }, tokensIn: 10, tokensOut: 20 }); mocks.applyMetadata.mockResolvedValue({ pageId: "p1", applied: ["title", "description"] }); act(() => root.render()); await open("suggest-metadata", "p1"); await act(async () => { (q('[data-testid="assistant-run"]') as HTMLButtonElement).click(); }); expect(mocks.suggestMetadata).toHaveBeenCalledWith("ws1", "p1", expect.anything()); const result = q('[data-testid="assistant-result"]'); expect(result?.textContent).toContain("Revised: Old title"); expect(result?.textContent).toContain("A better description."); // Apply → the exact suggested fields (the server re-checks the match). await act(async () => { (q('[data-testid="assistant-apply"]') as HTMLButtonElement).click(); }); expect(mocks.applyMetadata).toHaveBeenCalledWith("ws1", "asm1", { title: "Revised: Old title", description: "A better description." }); expect(mocks.toast).toHaveBeenCalledWith("success", "Metadata applied", "Title and/or description updated"); }); it("suggest-metadata: Dismiss closes without calling apply-metadata", async () => { mocks.suggestMetadata.mockResolvedValue({ suggestionId: "asm2", pageId: "p1", current: { title: "Old title", description: null }, suggested: { title: "New title" }, tokensIn: 10, tokensOut: 20 }); act(() => root.render()); await open("suggest-metadata", "p1"); await act(async () => { (q('[data-testid="assistant-run"]') as HTMLButtonElement).click(); }); await act(async () => { (q('[data-testid="assistant-dismiss"]') as HTMLButtonElement).click(); }); expect(mocks.setAssistant).toHaveBeenCalledWith(false, null); expect(mocks.applyMetadata).not.toHaveBeenCalled(); }); });