243 lines
13 KiB
TypeScript
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();
|
|
});
|