import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import CareerEvidenceBank from "./views/career/CareerEvidenceBank"; import { I18nProvider } from "./i18n/I18nProvider"; import { translations } from "./i18n/translations"; import { ConfirmProvider } from "./confirm"; import { api } from "./api"; jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), delete: jest.fn(), put: jest.fn(), patch: jest.fn() }, getApiErrorMessage: jest.requireActual("./api").getApiErrorMessage, })); const mocked = api as jest.Mocked; const evidence = { id: 1, category: "achievement", title: "Reduced incidents", statement: "Added rollback checks", tags: ["reliability"], sourceReference: "retrospective" }; function open(language: "en" | "nb" = "en") { window.localStorage.setItem("uiLanguage", language); return render(); } function fillDraft() { fireEvent.change(screen.getByLabelText("Short title", { exact: false }), { target: { value: evidence.title } }); fireEvent.change(screen.getByLabelText("Evidence statement", { exact: false }), { target: { value: evidence.statement } }); fireEvent.change(screen.getByLabelText("Skills and keywords"), { target: { value: "reliability, C#" } }); fireEvent.change(screen.getByLabelText("Source or provenance"), { target: { value: evidence.sourceReference } }); } function expectNoWrites() { for (const method of [mocked.post, mocked.delete, mocked.put, mocked.patch]) expect(method).not.toHaveBeenCalled(); } beforeEach(() => { jest.clearAllMocks(); window.localStorage.removeItem("uiLanguage"); mocked.get.mockReset().mockResolvedValue({ data: [] } as any); for (const method of [mocked.post, mocked.delete, mocked.put, mocked.patch]) method.mockReset().mockRejectedValue(new Error("Unexpected write")); }); afterEach(() => window.localStorage.removeItem("uiLanguage")); test("saves factual evidence with provenance and tags", async () => { mocked.post.mockResolvedValue({ data: evidence } as any); open(); await screen.findByText(translations.en.careerEvidenceEmpty); fillDraft(); fireEvent.click(screen.getByRole("button", { name: "Save evidence" })); await waitFor(() => expect(mocked.post).toHaveBeenCalledWith("/career/evidence", { category: "achievement", title: evidence.title, statement: evidence.statement, tags: ["reliability", " C#"], sourceType: "manual", sourceReference: evidence.sourceReference, isVerified: true, })); expect(await screen.findByText(evidence.statement)).toBeInTheDocument(); }); test("pending evidence reads announce loading without claiming an empty bank or allowing a raced save", async () => { let finish!: (value: any) => void; mocked.get.mockReturnValue(new Promise((resolve) => { finish = resolve; })); open(); expect(screen.getByRole("status")).toHaveTextContent(translations.en.loading); expect(screen.queryByText(translations.en.careerEvidenceEmpty)).not.toBeInTheDocument(); fillDraft(); const save = screen.getByRole("button", { name: translations.en.careerEvidenceSave }); expect(save).toBeDisabled(); fireEvent.click(save); expectNoWrites(); await act(async () => finish({ data: [] })); expect(await screen.findByText(translations.en.careerEvidenceEmpty)).toBeVisible(); expect(save).toBeEnabled(); expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title); }); test("failed list reads remain distinct from empty state and retry only GET while preserving the draft", async () => { mocked.get.mockRejectedValueOnce({ response: { status: 503, data: "Evidence list unavailable" } }); open(); const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent("Evidence list unavailable"); expect(screen.queryByText(translations.en.careerEvidenceEmpty)).not.toBeInTheDocument(); fillDraft(); fireEvent.click(within(alert).getByRole("button", { name: translations.en.retry })); expect(await screen.findByText(translations.en.careerEvidenceEmpty)).toBeVisible(); expect(screen.getByLabelText("Evidence statement", { exact: false })).toHaveValue(evidence.statement); expect(mocked.get.mock.calls.map(([url]) => url)).toEqual(["/career/evidence", "/career/evidence"]); expectNoWrites(); }); test("failed saves retain factual inputs and retry the same explicit save without reloading the list", async () => { mocked.post.mockRejectedValueOnce({ response: { status: 503, data: "Evidence save unavailable" } }).mockResolvedValueOnce({ data: evidence } as any); open(); await screen.findByText(translations.en.careerEvidenceEmpty); fillDraft(); const form = screen.getByRole("region", { name: translations.en.careerEvidenceAdd }); fireEvent.click(within(form).getByRole("button", { name: translations.en.careerEvidenceSave })); expect(await within(form).findByRole("alert")).toHaveTextContent("Evidence save unavailable"); expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title); expect(screen.getByLabelText("Evidence statement", { exact: false })).toHaveValue(evidence.statement); expect(screen.getByLabelText("Skills and keywords")).toHaveValue("reliability, C#"); expect(screen.getByLabelText("Source or provenance")).toHaveValue(evidence.sourceReference); fireEvent.click(within(form).getByRole("button", { name: translations.en.careerEvidenceSave })); expect(await screen.findByRole("article", { name: evidence.title })).toHaveTextContent(evidence.statement); expect(mocked.post).toHaveBeenCalledTimes(2); expect(mocked.post.mock.calls[1]).toEqual(mocked.post.mock.calls[0]); expect(mocked.get).toHaveBeenCalledTimes(1); expect(mocked.delete).not.toHaveBeenCalled(); expect(mocked.put).not.toHaveBeenCalled(); expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(""); }); test("a pending save prevents duplicate writes and prevents edits being cleared by the response", async () => { let finish!: (value: any) => void; mocked.post.mockReturnValue(new Promise((resolve) => { finish = resolve; })); open(); await screen.findByText(translations.en.careerEvidenceEmpty); fillDraft(); const save = screen.getByRole("button", { name: translations.en.careerEvidenceSave }); fireEvent.click(save); expect(save).toBeDisabled(); expect(screen.getByLabelText("Short title", { exact: false })).toBeDisabled(); expect(screen.getByLabelText("Evidence statement", { exact: false })).toBeDisabled(); fireEvent.click(save); expect(mocked.post).toHaveBeenCalledTimes(1); await act(async () => finish({ data: evidence })); expect(await screen.findByRole("article", { name: evidence.title })).toBeVisible(); expect(save).toBeDisabled(); expect(screen.getByLabelText("Short title", { exact: false })).toBeEnabled(); }); test("contextual deletion is named and cancellation performs no writes or draft loss", async () => { mocked.get.mockResolvedValue({ data: [evidence] } as any); open(); const item = await screen.findByRole("article", { name: evidence.title }); fillDraft(); const deleteButton = within(item).getByRole("button", { name: /Reduced incidents/ }); deleteButton.focus(); fireEvent.click(deleteButton); const dialog = await screen.findByRole("dialog", { name: translations.en.careerEvidenceDeleteTitle }); expect(dialog).toHaveTextContent(evidence.title); expect(deleteButton).toBeEnabled(); fireEvent.click(within(dialog).getByRole("button", { name: translations.en.cancel })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); expect(deleteButton).toHaveFocus(); expect(item).toBeVisible(); expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title); expectNoWrites(); }); test("failed deletion keeps the row and draft, then reconfirms the same delete without list reload", async () => { mocked.get.mockResolvedValue({ data: [evidence] } as any); mocked.delete.mockRejectedValueOnce({ response: { status: 503, data: "Evidence deletion unavailable" } }).mockResolvedValueOnce({} as any); open(); const item = await screen.findByRole("article", { name: evidence.title }); fillDraft(); fireEvent.click(within(item).getByRole("button", { name: /Reduced incidents/ })); let dialog = await screen.findByRole("dialog", { name: translations.en.careerEvidenceDeleteTitle }); fireEvent.click(within(dialog).getByRole("button", { name: translations.en.deleteAction })); expect(await within(item).findByRole("alert")).toHaveTextContent("Evidence deletion unavailable"); expect(mocked.delete).toHaveBeenCalledWith("/career/evidence/1"); expect(screen.getByLabelText("Evidence statement", { exact: false })).toHaveValue(evidence.statement); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); fireEvent.click(within(item).getByRole("button", { name: translations.en.retry })); dialog = await screen.findByRole("dialog", { name: translations.en.careerEvidenceDeleteTitle }); fireEvent.click(within(dialog).getByRole("button", { name: translations.en.deleteAction })); await waitFor(() => expect(screen.queryByRole("article", { name: evidence.title })).not.toBeInTheDocument()); expect(mocked.delete.mock.calls.map(([url]) => url)).toEqual(["/career/evidence/1", "/career/evidence/1"]); expect(mocked.get).toHaveBeenCalledTimes(1); expect(mocked.post).not.toHaveBeenCalled(); expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title); await waitFor(() => expect(screen.getByLabelText("Short title", { exact: false })).toHaveFocus()); }); test("evidence sections and factual fields have semantic headings and required labels in Bokmål", async () => { mocked.get.mockResolvedValue({ data: [evidence] } as any); open("nb"); expect(screen.getByRole("heading", { name: translations.nb.careerEvidenceTitle, level: 2 })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: translations.nb.careerEvidenceAdd, level: 3 })).toBeInTheDocument(); expect(screen.getByLabelText(translations.nb.careerEvidenceItemTitle, { exact: false })).toBeRequired(); expect(screen.getByLabelText(translations.nb.careerEvidenceStatement, { exact: false })).toBeRequired(); const item = await screen.findByRole("article", { name: evidence.title }); expect(within(item).getByRole("heading", { name: evidence.title, level: 3 })).toBeInTheDocument(); expect(item).toHaveTextContent(translations.nb.careerEvidenceCategory_achievement); expect(item).toHaveTextContent(evidence.sourceReference); expectNoWrites(); });