Files
jobtrackingapp/job-tracker-ui/src/match-score-panel.test.tsx
T
cesnimda 209528c8b5 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>
2026-07-03 03:24:53 +02:00

114 lines
4.3 KiB
TypeScript

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();
});