Files
jobtrackingapp/job-tracker-ui/src/ai-section-assistant.test.tsx
T

243 lines
13 KiB
TypeScript

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<typeof AiSectionAssistant>;
type Proposal = Awaited<ReturnType<AssistantProps["onGenerate"]>>;
function proposal(result: string, proposalToken = "proposal-1"): Proposal {
return { original: "Built reliable APIs", result, proposalToken };
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((complete) => { resolve = complete; });
return { promise, resolve };
}
function renderAssistant(overrides: Partial<AssistantProps> = {}) {
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) => (
<I18nProvider>
<ToastProvider>
<AiSectionAssistant {...nextProps} />
</ToastProvider>
</I18nProvider>
);
const rendered = render(view(props));
return {
onGenerate: props.onGenerate as jest.Mock,
onAccept: props.onAccept as jest.Mock,
rerenderAssistant: (updates: Partial<AssistantProps>) => 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<Proposal>();
const newRequest = deferred<Proposal>();
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<Proposal>();
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<Proposal>();
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<void>();
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();
});