feat(career): migrate overview and factual evidence

This commit is contained in:
cesnimda
2026-10-12 07:50:32 +02:00
parent 01281f52d9
commit 8b4d5c2112
16 changed files with 9006 additions and 116 deletions
@@ -1,16 +1,23 @@
import React from "react";
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { cvBuilderApi } from "./cvBuilder";
import CareerWorkspaceOverview, { CareerWorkspaceImportRun } from "./views/career/CareerWorkspaceOverview";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import { api } from "./api";
import { ToastProvider } from "./toast";
import { ConfirmProvider } from "./confirm";
import CareerProfilePage from "./views/CareerProfilePage";
jest.mock("./cvBuilder", () => ({
cvBuilderApi: { list: jest.fn() },
}));
const mockedList = cvBuilderApi.list as jest.MockedFunction<typeof cvBuilderApi.list>;
const mockedApi = api as jest.Mocked<typeof api>;
const careerResponse = { profile: { version: "1", contact: { fullName: "Stored career name" }, skills: ["Stored C#"] }, completeness: { percent: 70, missing: ["Projects"], sections: [] }, cvText: "Stored raw text" };
function renderOverview({
percent = 0,
@@ -18,12 +25,22 @@ function renderOverview({
runs = [],
loading = false,
loadError = null,
runsLoading = false,
runsLoadError = null,
onRetryProfile,
onRetryRuns,
profileDirty = false,
}: {
percent?: number;
missing?: string[];
runs?: CareerWorkspaceImportRun[];
loading?: boolean;
loadError?: string | null;
runsLoading?: boolean;
runsLoadError?: string | null;
onRetryProfile?: () => void;
onRetryRuns?: () => void;
profileDirty?: boolean;
} = {}) {
return render(
<I18nProvider>
@@ -32,14 +49,28 @@ function renderOverview({
runs={runs}
loading={loading}
loadError={loadError}
runsLoading={runsLoading}
runsLoadError={runsLoadError}
onRetryProfile={onRetryProfile}
onRetryRuns={onRetryRuns}
profileDirty={profileDirty}
/>
</I18nProvider>,
);
}
beforeEach(() => {
jest.clearAllMocks();
window.history.replaceState({}, "", "/career");
window.localStorage.removeItem("uiLanguage");
mockedList.mockResolvedValue([]);
mockedList.mockReset().mockResolvedValue([]);
mockedApi.get.mockReset().mockImplementation((url: string) => {
if (url === "/career/profile") return Promise.resolve({ data: careerResponse } as any);
if (url === "/auth/me") return Promise.resolve({ data: { id: "career-owner", provider: "local", displayName: "Account name" } } as any);
if (["/profile-cv/runs", "/career/profile/versions", "/career/evidence"].includes(url)) return Promise.resolve({ data: [] } as any);
return Promise.reject(new Error(`Unexpected read: ${url}`));
});
for (const method of [mockedApi.post, mockedApi.put, mockedApi.patch, mockedApi.delete]) method.mockReset().mockRejectedValue(new Error("Unexpected write"));
});
afterEach(() => {
@@ -107,3 +138,118 @@ test("workspace represents loading and profile-load errors without inventing sta
expect(screen.queryByText(/start by adding career information/i)).not.toBeInTheDocument();
await waitFor(() => expect(mockedList).toHaveBeenCalledTimes(1));
});
test("a settled failed profile read hides the completeness score and retries only through its existing owner", async () => {
const retry = jest.fn();
renderOverview({ percent: 70, loadError: "Profile unavailable", onRetryProfile: retry });
const region = screen.getByRole("region", { name: translations.en.careerOverviewProfileStatus });
expect(within(region).queryByRole("progressbar")).not.toBeInTheDocument();
expect(within(region).queryByText("70%")).not.toBeInTheDocument();
expect(within(region).getByRole("alert")).toHaveTextContent(translations.en.careerOverviewUnavailable);
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
expect(retry).toHaveBeenCalledTimes(1);
expect(mockedApi.put).not.toHaveBeenCalled();
await screen.findByText(translations.en.careerOverviewNoCvs);
});
test("an unknown import read cannot claim no imports, completion or first-run guidance", async () => {
renderOverview({ runsLoading: true });
const region = screen.getByRole("region", { name: translations.en.careerOverviewCvImport });
expect(within(region).getByRole("status")).toHaveTextContent(translations.en.loading);
expect(screen.queryByText(translations.en.careerOverviewNoImport)).not.toBeInTheDocument();
expect(screen.queryByText(translations.en.careerOverviewImportComplete)).not.toBeInTheDocument();
await screen.findByText(translations.en.careerOverviewNoCvs);
expect(screen.queryByText(translations.en.careerOverviewFirstRun)).not.toBeInTheDocument();
});
test("a failed import read stays unknown and keeps its own retry separate from the profile read", async () => {
const retryRuns = jest.fn();
const retryProfile = jest.fn();
renderOverview({ runsLoadError: "Run read unavailable", onRetryRuns: retryRuns, onRetryProfile: retryProfile });
const region = screen.getByRole("region", { name: translations.en.careerOverviewCvImport });
expect(within(region).getByRole("alert")).toHaveTextContent(translations.en.careerOverviewImportUnavailable);
expect(screen.queryByText(translations.en.careerOverviewNoImport)).not.toBeInTheDocument();
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
expect(retryRuns).toHaveBeenCalledTimes(1);
expect(retryProfile).not.toHaveBeenCalled();
await screen.findByText(translations.en.careerOverviewNoCvs);
expect(screen.queryByText(translations.en.careerOverviewFirstRun)).not.toBeInTheDocument();
});
test("failed recent-CV reads do not imply first-run and retry the same list without writes", async () => {
mockedList.mockRejectedValueOnce(new Error("Recent list unavailable"));
renderOverview();
const region = screen.getByRole("region", { name: translations.en.careerOverviewRecentCvs });
expect(await within(region).findByRole("alert")).toHaveTextContent(translations.en.careerOverviewRecentFailed);
expect(screen.queryByText(translations.en.careerOverviewFirstRun)).not.toBeInTheDocument();
expect(within(region).queryByText(translations.en.careerOverviewNoCvs)).not.toBeInTheDocument();
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
expect(await within(region).findByText(translations.en.careerOverviewNoCvs)).toBeVisible();
expect(mockedList).toHaveBeenCalledTimes(2);
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
function renderCareer() {
return render(<ToastProvider><I18nProvider><ConfirmProvider><CareerProfilePage /></ConfirmProvider></I18nProvider></ToastProvider>);
}
test("the parent exposes failed import-read retry without canonical saves or history restores", async () => {
let fail = true;
const originalGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string) => url === "/profile-cv/runs" && fail
? Promise.reject(new Error("Run read unavailable")) : originalGet(url));
renderCareer();
const region = screen.getByRole("region", { name: translations.en.careerOverviewCvImport });
expect(await within(region).findByRole("alert")).toHaveTextContent(translations.en.careerOverviewImportUnavailable);
const careerReads = mockedApi.get.mock.calls.filter(([url]) => url === "/career/profile").length;
fail = false;
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
expect(await within(region).findByText(translations.en.careerOverviewNoImport)).toBeVisible();
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/profile-cv/runs")).toHaveLength(2);
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/career/profile")).toHaveLength(careerReads);
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("overview retry cannot overwrite a dirty canonical draft after a failed source refresh", async () => {
renderCareer();
await screen.findByText("70%");
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceProfileTab }));
fireEvent.change(screen.getByLabelText("Full name"), { target: { value: "Unsaved canonical name" } });
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceOverviewTab }));
const originalGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string) => url === "/career/profile"
? Promise.reject(new Error("Source refresh unavailable")) : originalGet(url));
act(() => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
const region = await screen.findByRole("region", { name: translations.nb.careerOverviewProfileStatus });
expect(await within(region).findByRole("alert")).toHaveTextContent(translations.nb.careerOverviewRetryDraftHint);
const reads = mockedApi.get.mock.calls.length;
const retry = within(region).getByRole("button", { name: translations.nb.retry });
expect(retry).toBeDisabled();
fireEvent.click(retry);
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
fireEvent.click(screen.getByRole("tab", { name: translations.nb.careerWorkspaceProfileTab }));
expect(screen.getByLabelText("Fullt navn")).toHaveValue("Unsaved canonical name");
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("visited evidence keeps its draft across sections without an unvisited or repeated list request", async () => {
renderCareer();
await screen.findByText("70%");
expect(mockedApi.get).not.toHaveBeenCalledWith("/career/evidence");
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceEvidenceTab }));
await screen.findByText(translations.en.careerEvidenceEmpty);
fireEvent.change(screen.getByLabelText(translations.en.careerEvidenceItemTitle, { exact: false }), { target: { value: "Unsaved evidence title" } });
fireEvent.change(screen.getByLabelText(translations.en.careerEvidenceStatement, { exact: false }), { target: { value: "Unsaved factual statement" } });
fireEvent.change(screen.getByLabelText(translations.en.careerEvidenceTags), { target: { value: "C#, reliability" } });
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceProfileTab }));
expect(screen.queryByRole("region", { name: translations.en.careerEvidenceTitle })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceEvidenceTab }));
expect(screen.getByLabelText(translations.en.careerEvidenceItemTitle, { exact: false })).toHaveValue("Unsaved evidence title");
expect(screen.getByLabelText(translations.en.careerEvidenceStatement, { exact: false })).toHaveValue("Unsaved factual statement");
expect(screen.getByLabelText(translations.en.careerEvidenceTags)).toHaveValue("C#, reliability");
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/career/evidence")).toHaveLength(1);
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});