import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import AiSectionAssistant from "./components/cv/AiSectionAssistant"; import { I18nProvider } from "./i18n/I18nProvider"; import { ToastProvider } from "./toast"; jest.mock("./accountPlan", () => ({ useAccountPlan: () => ({ canUseAi: true }) })); jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn(), interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, }, getApiErrorMessage: (error: unknown, fallback?: string) => error instanceof Error ? error.message : fallback ?? "Request failed.", })); type AssistantProps = React.ComponentProps; type Proposal = Awaited>; function proposal(result: string, proposalToken = "proposal-1"): Proposal { return { original: "Built reliable APIs", result, proposalToken }; } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((complete) => { resolve = complete; }); return { promise, resolve }; } function renderAssistant(overrides: Partial = {}) { const props: AssistantProps = { sectionName: "Erfaring", text: "Built reliable APIs", targetId: "experience:job-1", context: "Career facts", documentLanguage: "nb-NO", onGenerate: jest.fn().mockResolvedValue(proposal("Proposed wording")), onAccept: jest.fn().mockResolvedValue(undefined), ...overrides, }; const view = (nextProps: AssistantProps) => ( ); const rendered = render(view(props)); return { onGenerate: props.onGenerate as jest.Mock, onAccept: props.onAccept as jest.Mock, rerenderAssistant: (updates: Partial) => rendered.rerender(view({ ...props, ...updates })), }; } beforeEach(() => { window.localStorage.removeItem("uiLanguage"); jest.clearAllMocks(); }); afterEach(() => window.localStorage.removeItem("uiLanguage")); test("section AI uses the CV document language and requires explicit server acceptance", async () => { const generated = proposal("Utviklet driftssikre API-er"); const { onGenerate, onAccept } = renderAssistant({ onGenerate: jest.fn().mockResolvedValue(generated) }); fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); expect(screen.getByLabelText("Output language")).toHaveTextContent("Norsk bokmål"); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); await waitFor(() => expect(onGenerate).toHaveBeenCalledWith(expect.objectContaining({ action: "improve", language: "nb-NO", text: "Built reliable APIs", targetId: "experience:job-1", context: expect.stringContaining("Career facts"), }))); expect(onAccept).not.toHaveBeenCalled(); expect(await screen.findByDisplayValue(generated.result)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Apply" })); expect(onAccept).toHaveBeenCalledWith(generated, generated.result); await waitFor(() => expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument()); }); test("section AI controls render in Bokmål without changing the document language", () => { window.localStorage.setItem("uiLanguage", "nb"); renderAssistant(); fireEvent.click(screen.getByRole("button", { name: "AI-forslag" })); expect(screen.getByLabelText("AI-handling")).toBeInTheDocument(); expect(screen.getByLabelText("Språk for resultat")).toHaveTextContent("Norsk bokmål"); expect(screen.getByRole("button", { name: "Foreslå" })).toBeInTheDocument(); }); test.each(["Reject", "Close"])("[PRES-C11] %s removes the suggestion without accepting content", async (action) => { const { onAccept } = renderAssistant({ onGenerate: jest.fn().mockResolvedValue(proposal("Unaccepted draft")) }); fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); expect(await screen.findByDisplayValue("Unaccepted draft")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: action })); expect(screen.queryByDisplayValue("Unaccepted draft")).not.toBeInTheDocument(); expect(onAccept).not.toHaveBeenCalled(); if (action === "Close") fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument(); }); test("[PRES-C11] accepts the user's edited suggestion with the original proposal token", async () => { const generated = proposal("Proposed wording"); const { onAccept } = renderAssistant({ onGenerate: jest.fn().mockResolvedValue(generated) }); fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); fireEvent.change(await screen.findByDisplayValue(generated.result), { target: { value: "My reviewed wording" } }); expect(onAccept).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Apply" })); expect(onAccept).toHaveBeenCalledTimes(1); expect(onAccept).toHaveBeenCalledWith(generated, "My reviewed wording"); await waitFor(() => expect(screen.queryByDisplayValue("My reviewed wording")).not.toBeInTheDocument()); }); test("[PRES-C11] a failed suggestion can be retried without accepting content", async () => { const onGenerate = jest.fn().mockRejectedValueOnce(new Error("offline")) .mockResolvedValueOnce(proposal("Recovered suggestion")); const { onAccept } = renderAssistant({ onGenerate }); fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); expect(await screen.findByText("offline")).toBeInTheDocument(); await waitFor(() => expect(screen.getByRole("button", { name: "Suggest" })).toBeEnabled()); expect(onAccept).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); expect(await screen.findByDisplayValue("Recovered suggestion")).toBeInTheDocument(); expect(onAccept).not.toHaveBeenCalled(); }); test("closing and reopening ignores an old request even after a newer suggestion completes", async () => { const oldRequest = deferred(); const newRequest = deferred(); const freshProposal = proposal("Fresh suggestion", "proposal-2"); const onGenerate = jest.fn().mockReturnValueOnce(oldRequest.promise).mockReturnValueOnce(newRequest.promise); const { onAccept } = renderAssistant({ onGenerate }); fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); fireEvent.click(screen.getByRole("button", { name: "Close" })); fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); expect(onGenerate).toHaveBeenCalledTimes(2); await act(async () => { newRequest.resolve(freshProposal); }); expect(screen.getByDisplayValue(freshProposal.result)).toBeInTheDocument(); await act(async () => { oldRequest.resolve(proposal("Late stale suggestion")); }); expect(screen.queryByDisplayValue("Late stale suggestion")).not.toBeInTheDocument(); expect(screen.getByDisplayValue(freshProposal.result)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Apply" })); expect(onAccept).toHaveBeenCalledWith(freshProposal, freshProposal.result); await waitFor(() => expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument()); }); test.each([ ["text", { text: "Changed career facts" }], ["target", { targetId: "experience:job-2" }], ["document language", { documentLanguage: "en" }], ] as const)("a changed %s invalidates a pending suggestion", async (_source, updates) => { const request = deferred(); const { onAccept, rerenderAssistant } = renderAssistant({ onGenerate: jest.fn().mockReturnValue(request.promise) }); fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); rerenderAssistant(updates); await act(async () => { request.resolve(proposal("Stale source suggestion")); }); expect(screen.queryByDisplayValue("Stale source suggestion")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument(); expect(onAccept).not.toHaveBeenCalled(); }); test("source changes remove an existing review before it can be accepted", async () => { const { onAccept, rerenderAssistant } = renderAssistant(); fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); expect(await screen.findByDisplayValue("Proposed wording")).toBeInTheDocument(); rerenderAssistant({ text: "Changed career facts" }); expect(screen.queryByDisplayValue("Proposed wording")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument(); expect(onAccept).not.toHaveBeenCalled(); }); test("regeneration replaces the proposal and its token before acceptance", async () => { const first = proposal("First suggestion"); const regenerated = proposal("Regenerated suggestion", "proposal-2"); const onGenerate = jest.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(regenerated); const { onAccept } = renderAssistant({ onGenerate }); fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); expect(await screen.findByDisplayValue(first.result)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); expect(await screen.findByDisplayValue(regenerated.result)).toBeInTheDocument(); expect(screen.queryByDisplayValue(first.result)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Apply" })); expect(onAccept).toHaveBeenCalledWith(regenerated, regenerated.result); await waitFor(() => expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument()); }); test("Reject invalidates a pending regeneration and clears the previous review", async () => { const regenerated = deferred(); const onGenerate = jest.fn().mockResolvedValueOnce(proposal("First suggestion")).mockReturnValueOnce(regenerated.promise); const { onAccept } = renderAssistant({ onGenerate }); fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); const suggestion = await screen.findByDisplayValue("First suggestion"); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); expect(suggestion).toBeDisabled(); expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: "Reject" })); await act(async () => { regenerated.resolve(proposal("Rejected late suggestion", "proposal-2")); }); expect(screen.queryByDisplayValue("First suggestion")).not.toBeInTheDocument(); expect(screen.queryByDisplayValue("Rejected late suggestion")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Apply" })).not.toBeInTheDocument(); expect(onAccept).not.toHaveBeenCalled(); }); test("failed server acceptance keeps the edited review and allows retry without duplicate submits", async () => { const accepting = deferred(); const generated = proposal("Proposed wording"); const onAccept = jest.fn().mockRejectedValueOnce(new Error("Source changed")).mockReturnValueOnce(accepting.promise); renderAssistant({ onGenerate: jest.fn().mockResolvedValue(generated), onAccept }); fireEvent.click(screen.getByRole("button", { name: "AI suggestions" })); fireEvent.click(screen.getByRole("button", { name: "Suggest" })); fireEvent.change(await screen.findByDisplayValue(generated.result), { target: { value: "My reviewed wording" } }); fireEvent.click(screen.getByRole("button", { name: "Apply" })); expect(await screen.findByText("Source changed")).toBeInTheDocument(); expect(screen.getByDisplayValue("My reviewed wording")).toBeInTheDocument(); const retry = screen.getByRole("button", { name: "Apply" }); await waitFor(() => expect(retry).toBeEnabled()); fireEvent.click(retry); expect(retry).toBeDisabled(); expect(screen.getByDisplayValue("My reviewed wording")).toBeDisabled(); fireEvent.click(retry); expect(onAccept).toHaveBeenCalledTimes(2); expect(onAccept).toHaveBeenNthCalledWith(1, generated, "My reviewed wording"); expect(onAccept).toHaveBeenNthCalledWith(2, generated, "My reviewed wording"); await act(async () => { accepting.resolve(undefined); }); expect(screen.queryByDisplayValue("My reviewed wording")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "AI suggestions" })).toBeInTheDocument(); });