import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
import {
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
} from "./components/ApplicationIntelligence";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
const render = (ui: React.ReactElement) => rtlRender({ui});
jest.mock("./api", () => ({
api: {
get: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
}));
const mockedApi = api as jest.Mocked;
const timeline = {
days: [
{
date: "2026-07-19",
label: "Today",
events: [
{ id: 2, type: "StatusChanged", category: "stage", summary: "Moved from Applied to Interview", detail: null, isMilestone: true, at: "2026-07-19T09:00:00Z" },
{ id: 3, type: "AiRefreshed", category: "ai", summary: "AI suggestions refreshed", detail: null, isMilestone: false, at: "2026-07-19T08:00:00Z" },
],
},
],
milestones: [
{ id: 2, type: "StatusChanged", category: "stage", summary: "Moved from Applied to Interview", detail: null, isMilestone: true, at: "2026-07-19T09:00:00Z" },
],
categories: ["ai", "stage"],
totalEvents: 2,
};
const analysis = {
role: "Senior Backend Developer",
company: "Acme",
location: "Oslo",
employmentType: "Full-time",
seniority: "Senior",
salary: null,
technologies: ["C#", ".NET"],
skills: ["Collaboration"],
responsibilities: ["Build and operate REST APIs"],
keywords: ["C#", ".NET", "Collaboration"],
summary: "Senior Backend Developer at Acme · Oslo.",
importantRequirements: ["Strong experience with C# and .NET"],
interviewTopics: ["C#", ".NET"],
missingInformation: ["Salary or compensation range"],
hasJobDescription: true,
aiSuggestionCount: 0,
};
const match = {
score: 72,
band: "Good",
hasEnoughSignal: true,
hasCareerProfile: true,
hasSelectedCv: true,
selectedCvName: "Backend CV",
jobLanguage: "en",
cvLanguage: "en",
languageMismatch: false,
usedTranslatedJobDescription: false,
matchedSkills: ["C#", "SQL"],
missingSkills: ["Kubernetes"],
relevantExperience: [{ title: "Backend Developer", subtitle: "Initech · 2021 – present", matched: ["C#"] }],
relevantProjects: [],
suggestions: ["Solid match. Lead with the matched skills."],
aiSuggestionCount: 0,
};
beforeEach(() => jest.clearAllMocks());
// ---------- Timeline ----------
test("timeline renders grouped days, milestones and readable summaries", async () => {
mockedApi.get.mockResolvedValue({ data: timeline } as any);
render();
expect(await screen.findByText("Milestones")).toBeInTheDocument();
expect(screen.getByText(new Date(`${timeline.days[0].date}T12:00:00`).toLocaleDateString("en", { dateStyle: "long" }))).toBeInTheDocument();
expect(screen.getByRole("list")).toHaveTextContent("AI suggestions refreshed");
expect(screen.getAllByText("Moved from Applied to Interview").length).toBeGreaterThan(0);
expect(screen.getByText("AI suggestions refreshed")).toBeInTheDocument();
});
test("timeline filters by category", async () => {
mockedApi.get.mockResolvedValue({ data: timeline } as any);
render();
fireEvent.click(await screen.findByRole("button", { name: "AI" }));
await waitFor(() =>
expect(mockedApi.get).toHaveBeenLastCalledWith(
"/jobapplications/7/timeline",
{ params: { category: "ai", milestonesOnly: undefined } },
));
});
test("timeline shows an empty state when nothing has happened", async () => {
mockedApi.get.mockResolvedValue({ data: { days: [], milestones: [], categories: [], totalEvents: 0 } } as any);
render();
expect(await screen.findByText(/Nothing has happened yet/i)).toBeInTheDocument();
});
test("timeline surfaces an error instead of rendering nothing", async () => {
mockedApi.get.mockRejectedValue(new Error("boom"));
render();
expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument();
});
// ---------- Analysis ----------
test("analysis renders the extracted structure", async () => {
mockedApi.get.mockResolvedValue({ data: analysis } as any);
render();
expect(await screen.findByText("Senior Backend Developer at Acme · Oslo.")).toBeInTheDocument();
expect(screen.getByText("Full-time")).toBeInTheDocument();
expect(screen.getByText("Strong experience with C# and .NET")).toBeInTheDocument();
expect(screen.getByText("Salary or compensation range")).toBeInTheDocument();
});
test("analysis prompts for the advert when there is none", async () => {
mockedApi.get.mockResolvedValue({
data: { ...analysis, hasJobDescription: false, technologies: [], responsibilities: [], importantRequirements: [] },
} as any);
render();
expect(await screen.findByText(/No advert text saved yet/i)).toBeInTheDocument();
});
test("analysis shows a loading state before the data arrives", () => {
mockedApi.get.mockReturnValue(new Promise(() => {}) as any);
render();
expect(screen.getByRole("progressbar", { name: "Loading..." })).toBeInTheDocument();
});
// ---------- Match ----------
test("match renders the score, evidence and suggestions", async () => {
mockedApi.get.mockResolvedValue({ data: match } as any);
render();
expect(await screen.findByText("72%")).toBeInTheDocument();
expect(screen.getByText("Good")).toBeInTheDocument();
expect(screen.getByText("Backend Developer")).toBeInTheDocument();
expect(screen.getByText("Kubernetes")).toBeInTheDocument();
expect(screen.getByText(/Solid match/i)).toBeInTheDocument();
});
test("match asks for a career profile before showing a score", async () => {
mockedApi.get.mockResolvedValue({
data: {
...match, score: 0, band: "No profile", hasCareerProfile: false,
matchedSkills: [], missingSkills: [], relevantExperience: [],
suggestions: ["Build your career profile first."],
},
} as any);
render();
expect(await screen.findByText(/linked CV cannot be matched/i)).toBeInTheDocument();
expect(screen.queryByText("0%")).not.toBeInTheDocument();
});
test("match warns when the advert is too short to score", async () => {
mockedApi.get.mockResolvedValue({ data: { ...match, hasEnoughSignal: false } } as any);
render();
expect(await screen.findByText(/too short to score reliably/i)).toBeInTheDocument();
});
test("match explains when a saved translation is used for a cross-language comparison", async () => {
mockedApi.get.mockResolvedValue({
data: {
...match,
jobLanguage: "nb",
cvLanguage: "en",
languageMismatch: true,
usedTranslatedJobDescription: true,
},
} as any);
render();
expect(await screen.findByText(/uses the saved English translation/i)).toBeInTheDocument();
});