fix(cv): gate AI proposals on grounding and source freshness
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { api } from "./api";
|
||||
import AiSectionAssistant from "./components/cv/AiSectionAssistant";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { ToastProvider } from "./toast";
|
||||
@@ -13,25 +12,46 @@ jest.mock("./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) => fallback ?? "Request failed.",
|
||||
getApiErrorMessage: (error: unknown, fallback?: string) => error instanceof Error ? error.message : fallback ?? "Request failed.",
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
type AssistantProps = React.ComponentProps<typeof AiSectionAssistant>;
|
||||
type Proposal = Awaited<ReturnType<AssistantProps["onGenerate"]>>;
|
||||
|
||||
function renderAssistant(onApply = jest.fn()) {
|
||||
render(
|
||||
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
|
||||
sectionName="Erfaring"
|
||||
text="Built reliable APIs"
|
||||
documentLanguage="nb-NO"
|
||||
onApply={onApply}
|
||||
/>
|
||||
<AiSectionAssistant {...nextProps} />
|
||||
</ToastProvider>
|
||||
</I18nProvider>,
|
||||
</I18nProvider>
|
||||
);
|
||||
return onApply;
|
||||
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(() => {
|
||||
@@ -41,23 +61,26 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => window.localStorage.removeItem("uiLanguage"));
|
||||
|
||||
test("section AI uses the CV document language and requires explicit apply", async () => {
|
||||
mockedApi.post.mockResolvedValue({ data: { original: "Built reliable APIs", result: "Utviklet driftssikre API-er" } } as never);
|
||||
const onApply = renderAssistant();
|
||||
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(mockedApi.post).toHaveBeenCalledWith("/cv/ai/assist", expect.objectContaining({
|
||||
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(onApply).not.toHaveBeenCalled();
|
||||
expect(await screen.findByDisplayValue("Utviklet driftssikre API-er")).toBeInTheDocument();
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
expect(await screen.findByDisplayValue(generated.result)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
expect(onApply).toHaveBeenCalledWith("Utviklet driftssikre API-er");
|
||||
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", () => {
|
||||
@@ -70,43 +93,150 @@ test("section AI controls render in Bokmål without changing the document langua
|
||||
expect(screen.getByRole("button", { name: "Foreslå" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each(["Reject", "Close"])("[PRES-C11] %s leaves the source unchanged and removes the suggestion", async (action) => {
|
||||
mockedApi.post.mockResolvedValue({ data: { result: "Unaccepted draft" } } as never);
|
||||
const onApply = renderAssistant();
|
||||
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(onApply).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
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] applies the user's edited suggestion only after acceptance", async () => {
|
||||
mockedApi.post.mockResolvedValue({ data: { result: "Proposed wording" } } as never);
|
||||
const onApply = renderAssistant();
|
||||
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("Proposed wording"), { target: { value: "My reviewed wording" } });
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
fireEvent.change(await screen.findByDisplayValue(generated.result), { target: { value: "My reviewed wording" } });
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
expect(onApply).toHaveBeenCalledTimes(1);
|
||||
expect(onApply).toHaveBeenCalledWith("My reviewed wording");
|
||||
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 applying or saving content", async () => {
|
||||
mockedApi.post.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce({ data: { result: "Recovered suggestion" } } as never);
|
||||
const onApply = renderAssistant();
|
||||
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.findByRole("alert")).toBeInTheDocument();
|
||||
expect(await screen.findByText("offline")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Suggest" })).toBeEnabled());
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Suggest" }));
|
||||
expect(await screen.findByDisplayValue("Recovered suggestion")).toBeInTheDocument();
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user