feat(ui): instant match-score panel on the Candidate Fit tab
Adds a MatchScoreCard at the top of the Candidate Fit tab that loads the deterministic /match-score endpoint independently of the slow AI narrative, so users see a reproducible score, matched/missing keyword chips, and per-section coverage immediately. - MatchScore types + cached, attachment-independent load effect - graceful 'not enough signal' state - EN/NB translations - frontend panel test (matched/missing/section + degraded state) - backend integration tests for GetMatchScore (happy path + missing CV) - README endpoint reference Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,60 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
Assert.Contains("Profile page", badRequest.Value?.ToString());
|
Assert.Contains("Profile page", badRequest.Value?.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Match_score_scores_job_against_profile_cv()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "user-1",
|
||||||
|
UserName = "u",
|
||||||
|
Email = "u@example.com",
|
||||||
|
ProfileCvText = "Backend engineer skilled in C#, .NET, SQL and Docker. Built REST APIs.",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication
|
||||||
|
{
|
||||||
|
JobTitle = "Senior C# Backend Developer",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
Description = "We need strong C#, .NET, SQL, Docker and REST API experience.",
|
||||||
|
};
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = CreateController(db, "user-1");
|
||||||
|
var result = await controller.GetMatchScore(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var dto = Assert.IsType<JobApplicationsController.MatchScoreDto>(ok.Value);
|
||||||
|
Assert.True(dto.HasEnoughSignal);
|
||||||
|
Assert.True(dto.Score >= 75, $"expected strong score, got {dto.Score}");
|
||||||
|
Assert.Contains("C#", dto.MatchedKeywords);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Match_score_requires_profile_cv()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "u", Email = "u@example.com" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Description = "C# .NET" };
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = CreateController(db, "user-1");
|
||||||
|
var result = await controller.GetMatchScore(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_normalizes_structured_salary()
|
public async Task Create_normalizes_structured_salary()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -201,6 +201,8 @@ Authentication:
|
|||||||
- Returns a unified timeline combining job events, correspondence, and attachments.
|
- Returns a unified timeline combining job events, correspondence, and attachments.
|
||||||
- `GET /api/jobapplications/stats`
|
- `GET /api/jobapplications/stats`
|
||||||
- Returns totals, counts by status, applied-last-30-days, and average days since applied.
|
- Returns totals, counts by status, applied-last-30-days, and average days since applied.
|
||||||
|
- `GET /api/jobapplications/{id}/match-score`
|
||||||
|
- Deterministic CV↔job keyword-coverage score (0–100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.)
|
||||||
- `DELETE /api/jobapplications/{id}`
|
- `DELETE /api/jobapplications/{id}`
|
||||||
- Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event.
|
- Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event.
|
||||||
- `POST /api/jobapplications/{id}/restore`
|
- `POST /api/jobapplications/{id}/restore`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
|
LinearProgress,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
Tab,
|
Tab,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types";
|
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, TailoredCvDraft } from "../types";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useDialogActions } from "../dialogs";
|
import { useDialogActions } from "../dialogs";
|
||||||
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
|
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
|
||||||
@@ -130,6 +131,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
const { confirmAction } = useDialogActions();
|
const { confirmAction } = useDialogActions();
|
||||||
const followUpCache = useWorkspaceTabCache<FollowUpDraft | null>();
|
const followUpCache = useWorkspaceTabCache<FollowUpDraft | null>();
|
||||||
const candidateFitCache = useWorkspaceTabCache<CandidateFit | null>();
|
const candidateFitCache = useWorkspaceTabCache<CandidateFit | null>();
|
||||||
|
const matchScoreCache = useWorkspaceTabCache<MatchScore | null>();
|
||||||
const focusPlanCache = useWorkspaceTabCache<FocusPlanResponse | null>();
|
const focusPlanCache = useWorkspaceTabCache<FocusPlanResponse | null>();
|
||||||
const interviewPrepCache = useWorkspaceTabCache<InterviewPrepResponse | null>();
|
const interviewPrepCache = useWorkspaceTabCache<InterviewPrepResponse | null>();
|
||||||
const readinessCache = useWorkspaceTabCache<ReadinessResponse | null>();
|
const readinessCache = useWorkspaceTabCache<ReadinessResponse | null>();
|
||||||
@@ -168,6 +170,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
const [sendingDraft, setSendingDraft] = useState(false);
|
const [sendingDraft, setSendingDraft] = useState(false);
|
||||||
const [refreshingAi, setRefreshingAi] = useState(false);
|
const [refreshingAi, setRefreshingAi] = useState(false);
|
||||||
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
|
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
|
||||||
|
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
|
||||||
|
const [loadingMatchScore, setLoadingMatchScore] = useState(false);
|
||||||
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
|
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
|
||||||
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
||||||
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
|
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
|
||||||
@@ -200,6 +204,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
if (!open || !jobId) return;
|
if (!open || !jobId) return;
|
||||||
setFollowUpDraft(null);
|
setFollowUpDraft(null);
|
||||||
setCandidateFit(null);
|
setCandidateFit(null);
|
||||||
|
setMatchScore(null);
|
||||||
setFocusPlan(null);
|
setFocusPlan(null);
|
||||||
setInterviewPrep(null);
|
setInterviewPrep(null);
|
||||||
setReadiness(null);
|
setReadiness(null);
|
||||||
@@ -280,6 +285,24 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||||
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
|
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
|
||||||
|
|
||||||
|
// Match score is deterministic and cheap: load it on the Candidate Fit tab
|
||||||
|
// independently of the slow AI narrative so users see the number instantly.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !jobId || tab !== 5 || matchScore) return;
|
||||||
|
const cacheKey = `${jobId}:match-score`;
|
||||||
|
const cached = matchScoreCache.getCached(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
setMatchScore(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingMatchScore(true);
|
||||||
|
api.get<MatchScore>(`/jobapplications/${jobId}/match-score`).then((r) => {
|
||||||
|
matchScoreCache.setCached(cacheKey, r.data);
|
||||||
|
setMatchScore(r.data);
|
||||||
|
}).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false));
|
||||||
|
}, [open, jobId, tab, matchScore, matchScoreCache]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
||||||
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
||||||
@@ -1058,6 +1081,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
|
|
||||||
{tab === 5 && (
|
{tab === 5 && (
|
||||||
<Box>
|
<Box>
|
||||||
|
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
||||||
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||||
@@ -1136,6 +1160,73 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
if (loading && !score) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||||
|
<CircularProgress size={18} />
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!score) return null;
|
||||||
|
|
||||||
|
const color: "success" | "warning" | "error" | "inherit" =
|
||||||
|
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
|
||||||
|
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{score.hasEnoughSignal ? `${score.score}%` : "—"}</Typography>
|
||||||
|
<Typography variant="overline">{t("matchScoreTitle")}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
|
||||||
|
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{score.hasEnoughSignal ? (
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={score.score}
|
||||||
|
color={color === "inherit" ? "primary" : color}
|
||||||
|
sx={{ height: 8, borderRadius: 4, mb: 1.5 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||||
|
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||||
|
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{score.sectionCoverage.length ? (
|
||||||
|
<Box sx={{ mt: 1.5 }}>
|
||||||
|
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||||
|
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|||||||
@@ -867,6 +867,20 @@ export const translations = {
|
|||||||
jobDetailsFollowUpSent: "Follow-up sent and logged.",
|
jobDetailsFollowUpSent: "Follow-up sent and logged.",
|
||||||
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
|
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
|
||||||
jobDetailsHowYouMatch: "How you match",
|
jobDetailsHowYouMatch: "How you match",
|
||||||
|
matchScoreTitle: "Match score",
|
||||||
|
matchScoreLoading: "Scoring your CV against this role…",
|
||||||
|
matchScoreBand_Strong: "Strong match",
|
||||||
|
matchScoreBand_Partial: "Partial match",
|
||||||
|
matchScoreBand_Low: "Low match",
|
||||||
|
matchScoreBand_Unknown: "Not enough signal",
|
||||||
|
matchScoreKeywordsCovered: "{matched}/{total} keywords",
|
||||||
|
matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.",
|
||||||
|
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable.",
|
||||||
|
matchScoreMatched: "Matched keywords",
|
||||||
|
matchScoreMissing: "Missing keywords",
|
||||||
|
matchScoreNoneYet: "No matches found yet.",
|
||||||
|
matchScoreAllCovered: "Every keyword is covered.",
|
||||||
|
matchScoreSectionCoverage: "Where your CV covers this role",
|
||||||
jobDetailsStrategySnapshot: "Strategy snapshot",
|
jobDetailsStrategySnapshot: "Strategy snapshot",
|
||||||
jobDetailsGenerateStrategySnapshot: "Generate strategy snapshot",
|
jobDetailsGenerateStrategySnapshot: "Generate strategy snapshot",
|
||||||
jobDetailsStrategySnapshotEmpty: "Generate a snapshot to see fit, positioning, and immediate priorities in one place.",
|
jobDetailsStrategySnapshotEmpty: "Generate a snapshot to see fit, positioning, and immediate priorities in one place.",
|
||||||
@@ -1784,6 +1798,20 @@ export const translations = {
|
|||||||
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
|
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
|
||||||
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
|
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
|
||||||
jobDetailsHowYouMatch: "Slik matcher du",
|
jobDetailsHowYouMatch: "Slik matcher du",
|
||||||
|
matchScoreTitle: "Match-score",
|
||||||
|
matchScoreLoading: "Vurderer CV-en mot denne stillingen…",
|
||||||
|
matchScoreBand_Strong: "Sterk match",
|
||||||
|
matchScoreBand_Partial: "Delvis match",
|
||||||
|
matchScoreBand_Low: "Lav match",
|
||||||
|
matchScoreBand_Unknown: "For lite grunnlag",
|
||||||
|
matchScoreKeywordsCovered: "{matched}/{total} nøkkelord",
|
||||||
|
matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.",
|
||||||
|
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar.",
|
||||||
|
matchScoreMatched: "Treff på nøkkelord",
|
||||||
|
matchScoreMissing: "Manglende nøkkelord",
|
||||||
|
matchScoreNoneYet: "Ingen treff ennå.",
|
||||||
|
matchScoreAllCovered: "Alle nøkkelord er dekket.",
|
||||||
|
matchScoreSectionCoverage: "Hvor CV-en dekker denne rollen",
|
||||||
jobDetailsStrategySnapshot: "Strategioversikt",
|
jobDetailsStrategySnapshot: "Strategioversikt",
|
||||||
jobDetailsGenerateStrategySnapshot: "Generer strategioversikt",
|
jobDetailsGenerateStrategySnapshot: "Generer strategioversikt",
|
||||||
jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.",
|
jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.",
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { ConfirmProvider } from './confirm';
|
||||||
|
import { PromptProvider } from './prompt';
|
||||||
|
import { ToastProvider } from './toast';
|
||||||
|
import { I18nProvider } from './i18n/I18nProvider';
|
||||||
|
import JobDetailsDialog from './components/JobDetailsDialog';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
jest.setTimeout(15000);
|
||||||
|
|
||||||
|
jest.mock('./api', () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
put: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
delete: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
const matchScore = {
|
||||||
|
score: 82,
|
||||||
|
band: 'Strong',
|
||||||
|
matchedCount: 4,
|
||||||
|
totalKeywords: 6,
|
||||||
|
matchedKeywords: ['C#', '.NET', 'SQL', 'Docker'],
|
||||||
|
missingKeywords: ['Kubernetes', 'GraphQL'],
|
||||||
|
sectionCoverage: [
|
||||||
|
{ section: 'Skills', matched: 4, total: 6 },
|
||||||
|
{ section: 'Experience', matched: 3, total: 6 },
|
||||||
|
],
|
||||||
|
hasEnoughSignal: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderDialog() {
|
||||||
|
return render(
|
||||||
|
<ToastProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={5} />
|
||||||
|
</PromptProvider>
|
||||||
|
</ConfirmProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
|
if (url === '/jobapplications/42') {
|
||||||
|
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
|
||||||
|
}
|
||||||
|
if (url === '/jobapplications/42/match-score') {
|
||||||
|
return Promise.resolve({ data: matchScore } as any);
|
||||||
|
}
|
||||||
|
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
|
||||||
|
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
|
||||||
|
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
|
||||||
|
// Candidate-fit AI narrative: leave pending-ish/empty so we only assert on the fast panel.
|
||||||
|
if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any);
|
||||||
|
return Promise.resolve({ data: {} } as any);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('match score panel shows the score, matched and missing keywords', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
expect(await screen.findByText('82%')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/strong match/i)).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('4/6 keywords')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Matched keyword chips
|
||||||
|
expect(await screen.findByText('C#')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Docker')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Missing keyword chips
|
||||||
|
expect(await screen.findByText('Kubernetes')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('GraphQL')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Section coverage
|
||||||
|
expect(await screen.findByText('Skills: 4/6')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('match score panel degrades gracefully when there is not enough signal', async () => {
|
||||||
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
|
if (url === '/jobapplications/42') {
|
||||||
|
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
|
||||||
|
}
|
||||||
|
if (url === '/jobapplications/42/match-score') {
|
||||||
|
return Promise.resolve({ data: { ...matchScore, score: 0, band: 'Unknown', matchedCount: 0, matchedKeywords: [], missingKeywords: [], sectionCoverage: [], hasEnoughSignal: false } } as any);
|
||||||
|
}
|
||||||
|
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
|
||||||
|
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
|
||||||
|
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
|
||||||
|
if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any);
|
||||||
|
return Promise.resolve({ data: {} } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
expect(await screen.findByText('—')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/not enough signal/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -132,6 +132,23 @@ export interface CandidateFitChannelGuidance {
|
|||||||
recruiterMessage: string[];
|
recruiterMessage: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MatchScoreSectionCoverage {
|
||||||
|
section: string;
|
||||||
|
matched: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchScore {
|
||||||
|
score: number;
|
||||||
|
band: string;
|
||||||
|
matchedCount: number;
|
||||||
|
totalKeywords: number;
|
||||||
|
matchedKeywords: string[];
|
||||||
|
missingKeywords: string[];
|
||||||
|
sectionCoverage: MatchScoreSectionCoverage[];
|
||||||
|
hasEnoughSignal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CandidateFit {
|
export interface CandidateFit {
|
||||||
matchSummary: string;
|
matchSummary: string;
|
||||||
fitLevel: string;
|
fitLevel: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user