feat(career): migrate overview and factual evidence
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user