feat(ui): migrate recorded offer comparison

This commit is contained in:
cesnimda
2026-10-13 13:19:53 +02:00
parent a8aba85299
commit c723a4e303
14 changed files with 2935 additions and 42 deletions
+210 -7
View File
@@ -1,34 +1,237 @@
import React from "react";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations, type UiLanguage } from "./i18n/translations";
import type { JobApplication } from "./types";
import OfferComparisonPage from "./views/OfferComparisonPage";
jest.mock("./api", () => ({
api: { get: jest.fn() },
api: { get: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() },
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
}));
const mockedApi = api as jest.Mocked<typeof api>;
const request = { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "dateApplied", sortDir: "asc" } };
beforeEach(() => {
window.localStorage.clear();
jest.clearAllMocks();
mockedApi.get.mockReset();
});
afterEach(() => jest.restoreAllMocks());
function copy(key: string, language: UiLanguage = "en", params: Record<string, number> = {}) {
const template = (translations[language] as Record<string, string>)[key] ?? key;
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
}
function offer(id: number, overrides: Partial<JobApplication> = {}): JobApplication {
return {
id, jobTitle: `Offer role ${id}`, company: { id, name: `Company ${id}` }, status: "Offer",
dateApplied: "2026-08-01", savedAt: "2026-07-20", responseReceived: true, daysSince: 10,
...overrides,
};
}
function paged(items: JobApplication[], total = items.length) {
return { data: { items, total, page: 1, pageSize: 25 } };
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function RouteContext() {
const location = useLocation();
return <output aria-label="Current route">{location.pathname}{location.search}{location.hash}</output>;
}
function renderPage(language: UiLanguage = "en") {
window.localStorage.setItem("uiLanguage", language);
return render(<I18nProvider><MemoryRouter initialEntries={["/offers"]}><RouteContext /><OfferComparisonPage /></MemoryRouter></I18nProvider>);
}
function changeLanguage(language: UiLanguage) {
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: language })); });
}
function assertNoWrites() {
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
}
test("compares recorded offer facts without filling missing values", async () => {
mockedApi.get.mockResolvedValue({ data: { items: [
{ id: 1, jobTitle: "Backend Engineer", company: { id: 1, name: "Acme" }, status: "Offer", salaryMin: 800000, salaryMax: 900000, salaryCurrency: "NOK", salaryPeriod: "year", location: "Oslo", deadline: "2026-09-10", nextAction: "Review contract", notes: "Hybrid three days", dateApplied: "2026-08-01", savedAt: "2026-07-20", responseReceived: true, daysSince: 10 },
{ id: 2, jobTitle: "Platform Engineer", company: { id: 2, name: "Beta" }, status: "Offer", dateApplied: "2026-08-02", savedAt: "2026-07-21", responseReceived: true, daysSince: 9 },
], total: 2, page: 1, pageSize: 25 } } as any);
render(<I18nProvider><MemoryRouter><OfferComparisonPage /></MemoryRouter></I18nProvider>);
renderPage();
expect(await screen.findByText("Backend Engineer")).toBeInTheDocument();
expect(screen.getByText("800,000 – 900,000 NOK / year")).toBeInTheDocument();
expect(screen.getByText(`800,000 – 900,000 NOK / ${translations.en.salaryPeriodYear}`)).toBeInTheDocument();
expect(screen.getByText("Hybrid three days")).toBeInTheDocument();
expect(screen.getAllByText("Not recorded").length).toBeGreaterThan(0);
expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "deadline", sortDir: "asc" } });
expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", request);
assertNoWrites();
});
test("shows an honest empty state when there are no offers", async () => {
mockedApi.get.mockResolvedValue({ data: { items: [], total: 0, page: 1, pageSize: 25 } } as any);
render(<I18nProvider><MemoryRouter><OfferComparisonPage /></MemoryRouter></I18nProvider>);
renderPage();
expect(await screen.findByText("No active offers to compare")).toBeInTheDocument();
assertNoWrites();
});
test("shows the first 25 of the real total in server application-date order without inventing the remaining offers", async () => {
const items = Array.from({ length: 25 }, (_, index) => offer(index + 1, {
dateApplied: `2026-08-${String(index + 1).padStart(2, "0")}`,
deadline: index === 0 ? "2026-12-01" : "2026-09-01",
}));
mockedApi.get.mockResolvedValue(paged(items, 61) as any);
renderPage();
expect(await screen.findByText(copy("offerComparisonSampleCount", "en", { shown: 25, total: 61 }))).toBeInTheDocument();
expect(screen.getByText(copy("offerComparisonSampleHelp"))).toBeInTheDocument();
expect(screen.getAllByRole("article")).toHaveLength(25);
expect(screen.getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(items.map((item) => item.jobTitle));
expect(screen.queryByText("Offer role 26")).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", request);
assertNoWrites();
});
test("opens the full recorded offer source with the same supported filter and ordering", async () => {
mockedApi.get.mockResolvedValue(paged([offer(7)], 42) as any);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: copy("offerComparisonAllOffers") }));
const route = new URL(screen.getByLabelText("Current route").textContent!, "https://example.test");
expect(route.pathname).toBe("/jobs");
expect(Object.fromEntries(route.searchParams.entries())).toEqual({ status: "Offer", sortBy: "dateApplied", sortDir: "asc" });
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});
test("distinguishes held and failed reads from empty results and recovers through one explicit identical retry", async () => {
const initial = deferred<ReturnType<typeof paged>>();
const retry = deferred<ReturnType<typeof paged>>();
mockedApi.get.mockImplementationOnce(() => initial.promise as any).mockImplementationOnce(() => retry.promise as any);
renderPage();
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
await act(async () => { initial.reject({ response: { status: 503 } }); });
expect(await screen.findByRole("alert")).toHaveTextContent(translations.en.offerComparisonLoadFailed);
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: translations.en.retry }));
expect(screen.queryByRole("button", { name: translations.en.retry })).not.toBeInTheDocument();
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
expect(mockedApi.get).toHaveBeenNthCalledWith(1, "/jobapplications", request);
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications", request);
await act(async () => { retry.resolve(paged([offer(91)])); });
expect(await screen.findByRole("article", { name: "Offer role 91" })).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
assertNoWrites();
});
test("a live locale change translates loaded offer chrome and preserves recorded facts without another GET", async () => {
const notes = "Recorded English notes stay unchanged — æøå";
mockedApi.get.mockResolvedValue(paged([offer(8, { jobTitle: "English source title", notes, salary: "GBP package as recorded" })]) as any);
renderPage();
expect(await screen.findByText(notes)).toBeInTheDocument();
changeLanguage("nb");
expect(screen.getByRole("heading", { level: 2, name: translations.nb.offerComparisonTitle })).toBeInTheDocument();
const article = screen.getByRole("article", { name: "English source title" });
expect(within(article).getByText(notes)).toBeInTheDocument();
expect(within(article).getByText("GBP package as recorded")).toBeInTheDocument();
expect(within(article).getByText(translations.nb.statusOffer)).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});
test("an existing read failure translates without refetching and Retry retains the same query", async () => {
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce(paged([offer(9)]) as any);
renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent(translations.en.offerComparisonLoadFailed);
changeLanguage("nb");
expect(screen.getByRole("alert")).toHaveTextContent(translations.nb.offerComparisonLoadFailed);
expect(mockedApi.get).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: translations.nb.retry }));
expect(await screen.findByRole("article", { name: "Offer role 9" })).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications", request);
expect(mockedApi.get).toHaveBeenCalledTimes(2);
assertNoWrites();
});
test("structured salaries follow UI locale rather than host locale while preserving zero, bounds, currency, unknown periods and free text", async () => {
const originalLocaleString = Number.prototype.toLocaleString;
jest.spyOn(Number.prototype, "toLocaleString").mockImplementation(function (
this: number,
locales?: Parameters<typeof Number.prototype.toLocaleString>[0],
options?: Parameters<typeof Number.prototype.toLocaleString>[1],
) {
return originalLocaleString.call(this, locales ?? "de-DE", options);
});
const rawSalary = " Negotiable — no numeric estimate ";
const items = [
offer(11, { salaryMin: 0, salaryCurrency: "NOK", salaryPeriod: "year" }),
offer(12, { salaryMin: 850000, salaryCurrency: "NOK", salaryPeriod: "month" }),
offer(13, { salaryMax: 900000, salaryCurrency: "USD", salaryPeriod: "hour" }),
offer(14, { salaryMin: 800000, salaryMax: 800000, salaryPeriod: "fortnight" }),
offer(15, { salary: rawSalary }),
offer(16, { salaryMin: 800000, salaryMax: 900000, salaryCurrency: "SEK" }),
offer(17, { salaryMin: 700000, salaryCurrency: "NOK", salaryPeriod: "constructor" }),
];
mockedApi.get.mockResolvedValue(paged(items) as any);
renderPage();
await screen.findByRole("article", { name: items[0].jobTitle });
const salaryText = (id: number) => {
const article = screen.getByRole("article", { name: `Offer role ${id}` });
const label = within(article).getByText(copy("offerComparisonCompensation", document.documentElement.lang === "nb-NO" ? "nb" : "en"));
return label.parentElement!.textContent!.replace(label.textContent!, "");
};
const normalizeSpaces = (value: string) => value.replace(/\s+/g, " ").trim();
for (const language of ["en", "nb"] as const) {
if (language === "nb") changeLanguage(language);
const fmt = (value: number) => new Intl.NumberFormat(language === "nb" ? "nb-NO" : "en").format(value);
expect(normalizeSpaces(salaryText(11))).toBe(`0 NOK / ${translations[language].salaryPeriodYear}`);
expect(normalizeSpaces(salaryText(12))).toBe(normalizeSpaces(`${fmt(850000)} NOK / ${translations[language].salaryPeriodMonth}`));
expect(normalizeSpaces(salaryText(13))).toBe(normalizeSpaces(`${fmt(900000)} USD / ${translations[language].salaryPeriodHour}`));
expect(normalizeSpaces(salaryText(14))).toBe(normalizeSpaces(`${fmt(800000)} – ${fmt(800000)} / fortnight`));
expect(salaryText(15)).toBe(rawSalary);
expect(normalizeSpaces(salaryText(16))).toBe(normalizeSpaces(`${fmt(800000)} – ${fmt(900000)} SEK`));
expect(normalizeSpaces(salaryText(17))).toBe(normalizeSpaces(`${fmt(700000)} NOK / constructor`));
}
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});
test("uses recorded applied/deadline/contact dates, keeps absent applied dates unknown, and opens the exact current application", async () => {
const item = offer(402, { jobTitle: "Recorded role", dateApplied: "2026-08-01", deadline: "2026-09-10", followUpAt: "2026-09-12" });
const missing = offer(407, { jobTitle: "No applied date", dateApplied: null, savedAt: "2026-07-20" });
mockedApi.get.mockResolvedValue(paged([item, missing]) as any);
renderPage("nb");
const article = await screen.findByRole("article", { name: item.jobTitle });
const fact = (label: string) => within(article).getByText(label).parentElement!;
const date = (value: string) => new Date(value).toLocaleDateString("nb-NO");
expect(fact(translations.nb.jobTableDateApplied)).toHaveTextContent(date(item.dateApplied!));
expect(fact(translations.nb.offerComparisonDecisionDeadline)).toHaveTextContent(date(item.deadline!));
expect(fact(translations.nb.companiesNextContact)).toHaveTextContent(date(item.followUpAt!));
expect(within(article).getByText(translations.nb.statusOffer)).toBeInTheDocument();
const missingArticle = screen.getByRole("article", { name: missing.jobTitle });
expect(within(missingArticle).getByText(translations.nb.jobTableDateApplied).parentElement).toHaveTextContent("—");
expect(within(missingArticle).queryByText(date(missing.savedAt))).not.toBeInTheDocument();
fireEvent.click(within(article).getByRole("button", { name: translations.nb.workspaceOpenFull }));
await waitFor(() => expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/jobs\/402$/));
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});