183 lines
10 KiB
TypeScript
183 lines
10 KiB
TypeScript
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<typeof api>;
|
|
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(<I18nProvider><ConfirmProvider><CareerEvidenceBank /></ConfirmProvider></I18nProvider>);
|
|
}
|
|
|
|
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();
|
|
});
|