9edcbfc5de
Make job/CV comparisons language-aware and filter recruitment noise. Improve responsive career navigation, shared spacing, dashboard priorities, settings, localized workspace controls, and portable browser tests.
256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
import React from 'react';
|
|
import '@testing-library/jest-dom';
|
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
|
import { ConfirmProvider } from './confirm';
|
|
import { PromptProvider } from './prompt';
|
|
import { ToastProvider } from './toast';
|
|
import { I18nProvider } from './i18n/I18nProvider';
|
|
import DashboardView from './components/DashboardView';
|
|
import RemindersView from './components/RemindersView';
|
|
import JobTable from './components/JobTable';
|
|
import { api } from './api';
|
|
|
|
const mockedApi = api as jest.Mocked<typeof api>;
|
|
|
|
const pagedJobs = {
|
|
items: [
|
|
{
|
|
id: 42,
|
|
jobTitle: 'Backend Developer',
|
|
status: 'Waiting',
|
|
dateApplied: new Date().toISOString(),
|
|
daysSince: 10,
|
|
company: { name: 'Acme' },
|
|
companyId: 1,
|
|
needsFollowUp: true,
|
|
followUpReason: 'Follow-up due soon',
|
|
shortSummary: 'Strong backend match',
|
|
tailoredCvText: 'Saved CV',
|
|
notes: 'Notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nSaved answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
|
workflowSignal: {
|
|
actionKey: 'follow-up',
|
|
reason: 'Follow-up due soon',
|
|
workspaceTab: 'follow-up',
|
|
followMode: 'waiting-update',
|
|
needsAttention: true,
|
|
hasPackageGap: false,
|
|
needsInterviewPrep: false,
|
|
needsFollowUpAction: true,
|
|
hasTailoredCv: true,
|
|
hasSavedApplicationAnswerDraft: true,
|
|
hasInterviewPrepNotes: true,
|
|
},
|
|
},
|
|
{
|
|
id: 43,
|
|
jobTitle: 'Platform Engineer',
|
|
status: 'Applied',
|
|
dateApplied: new Date().toISOString(),
|
|
daysSince: 4,
|
|
company: { name: 'Beta' },
|
|
companyId: 2,
|
|
needsFollowUp: false,
|
|
followUpReason: 'Tailored CV missing',
|
|
shortSummary: 'Platform work',
|
|
tailoredCvText: null,
|
|
notes: null,
|
|
workflowSignal: {
|
|
actionKey: 'package-work',
|
|
reason: 'Tailored CV missing for this role.',
|
|
workspaceTab: 'tailored-cv',
|
|
followMode: null,
|
|
needsAttention: true,
|
|
hasPackageGap: true,
|
|
needsInterviewPrep: false,
|
|
needsFollowUpAction: false,
|
|
hasTailoredCv: false,
|
|
hasSavedApplicationAnswerDraft: false,
|
|
hasInterviewPrepNotes: false,
|
|
},
|
|
},
|
|
],
|
|
total: 2,
|
|
page: 1,
|
|
pageSize: 15,
|
|
};
|
|
|
|
const reminderItems = [
|
|
{
|
|
id: 42,
|
|
jobTitle: 'Backend Developer',
|
|
status: 'Waiting',
|
|
dateApplied: new Date().toISOString(),
|
|
daysSince: 10,
|
|
company: { name: 'Acme' },
|
|
needsFollowUp: true,
|
|
followUpReason: 'Follow-up due soon',
|
|
tailoredCvText: 'Saved CV',
|
|
followUpAt: new Date().toISOString(),
|
|
workflowSignal: {
|
|
actionKey: 'follow-up',
|
|
reason: 'Follow-up due soon',
|
|
workspaceTab: 'follow-up',
|
|
followMode: 'waiting-update',
|
|
needsAttention: true,
|
|
hasPackageGap: false,
|
|
needsInterviewPrep: false,
|
|
needsFollowUpAction: true,
|
|
hasTailoredCv: true,
|
|
hasSavedApplicationAnswerDraft: true,
|
|
hasInterviewPrepNotes: true,
|
|
},
|
|
},
|
|
{
|
|
id: 43,
|
|
jobTitle: 'Platform Engineer',
|
|
status: 'Applied',
|
|
dateApplied: new Date().toISOString(),
|
|
daysSince: 4,
|
|
company: { name: 'Beta' },
|
|
needsFollowUp: true,
|
|
followUpReason: 'Tailored CV missing',
|
|
tailoredCvText: null,
|
|
followUpAt: new Date().toISOString(),
|
|
workflowSignal: {
|
|
actionKey: 'package-work',
|
|
reason: 'Tailored CV missing for this role.',
|
|
workspaceTab: 'tailored-cv',
|
|
followMode: null,
|
|
needsAttention: true,
|
|
hasPackageGap: true,
|
|
needsInterviewPrep: false,
|
|
needsFollowUpAction: true,
|
|
hasTailoredCv: false,
|
|
hasSavedApplicationAnswerDraft: false,
|
|
hasInterviewPrepNotes: false,
|
|
},
|
|
},
|
|
];
|
|
|
|
function setupApiMocks() {
|
|
mockedApi.get.mockImplementation((url: string) => {
|
|
if (url === '/companies') return Promise.resolve({ data: [{ id: 1, name: 'Acme' }, { id: 2, name: 'Beta' }] } as any);
|
|
if (url === '/jobapplications/reminders') return Promise.resolve({ data: reminderItems } as any);
|
|
if (url === '/jobapplications') return Promise.resolve({ data: pagedJobs } as any);
|
|
if (url === '/jobapplications/stats') return Promise.resolve({ data: { total: 2, active: 2, deleted: 0, byStatus: {}, appliedLast30Days: 2, averageDaysSinceApplied: 7 } } as any);
|
|
if (url === '/jobapplications/analytics-overview') return Promise.resolve({ data: { funnel: [], responseRateBySource: [], topCompanies: [], totalResponses: 1, totalActive: 2 } } as any);
|
|
if (url === '/jobapplications/analytics' || url === '/jobapplications/tags') return Promise.resolve({ data: [] } as any);
|
|
if (url === '/jobapplications/tag-trends') return Promise.resolve({ data: { months: [], series: [] } } as any);
|
|
if (url === '/jobapplications/42') {
|
|
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Waiting', dateApplied: new Date().toISOString(), daysSince: 10, company: { name: 'Acme', recruiterEmail: 'maria@acme.test' }, tailoredCvText: 'Saved CV', coverLetterText: 'Saved cover letter', recruiterMessageDraft: 'Saved recruiter message', notes: 'Notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nSaved answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>', shortSummary: 'Strong backend match' } } as any);
|
|
}
|
|
if (url === '/jobapplications/43') {
|
|
return Promise.resolve({ data: { id: 43, jobTitle: 'Platform Engineer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 4, company: { name: 'Beta', recruiterEmail: 'recruiter@beta.test' }, tailoredCvText: '', coverLetterText: '', recruiterMessageDraft: '', notes: '', shortSummary: 'Platform work' } } as any);
|
|
}
|
|
if (url === '/jobapplications/42/history' || url === '/jobapplications/43/history') return Promise.resolve({ data: [] } as any);
|
|
if (url === '/attachments/42' || url === '/attachments/43') return Promise.resolve({ data: [] } as any);
|
|
if (url === '/jobapplications/42/followup-draft') {
|
|
return Promise.resolve({ data: { subject: 'Re: Backend Developer application update', body: 'Hi Maria,\n\nFollow-up draft.\n\nThanks,\nDemo', reason: 'Follow-up due soon', suggestedSendOn: new Date().toISOString(), contextSummary: 'Saved application package material is available for reuse.', contextSignals: ['Saved cover letter available'], threadSubject: 'Backend Developer application update', lastCorrespondenceFrom: 'Maria Recruiter <maria@acme.test>', lastCorrespondenceAt: new Date().toISOString() } } as any);
|
|
}
|
|
return Promise.resolve({ data: [] } as any);
|
|
});
|
|
}
|
|
|
|
function LocationIndicator() {
|
|
const location = useLocation();
|
|
return <div data-testid="location-indicator">{location.pathname}{location.search}</div>;
|
|
}
|
|
|
|
function renderLoop(initialPath: string) {
|
|
return render(
|
|
<ToastProvider>
|
|
<I18nProvider>
|
|
<ConfirmProvider>
|
|
<PromptProvider>
|
|
<MemoryRouter initialEntries={[initialPath]}>
|
|
<LocationIndicator />
|
|
<Routes>
|
|
<Route path="/dashboard" element={<DashboardView />} />
|
|
<Route path="/reminders" element={<RemindersView />} />
|
|
<Route path="/jobs" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" />} />
|
|
<Route path="/jobs/:id" element={<div data-testid="application-workspace" />} />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</PromptProvider>
|
|
</ConfirmProvider>
|
|
</I18nProvider>
|
|
</ToastProvider>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
setupApiMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
test('dashboard attention card opens follow-up workspace', async () => {
|
|
renderLoop('/dashboard');
|
|
|
|
expect(await screen.findByText(/^today$/i)).toBeInTheDocument();
|
|
fireEvent.click(await screen.findByRole('button', { name: /follow up/i }));
|
|
|
|
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs/42?section=communication&followMode=waiting-update'));
|
|
expect(screen.getByTestId('application-workspace')).toBeInTheDocument();
|
|
});
|
|
|
|
test('empty dashboard prioritizes onboarding and suppresses meaningless analytics', async () => {
|
|
mockedApi.get.mockImplementation((url: string) => {
|
|
if (url === '/jobapplications/stats') return Promise.resolve({ data: { total: 0, active: 0, deleted: 0, byStatus: {}, appliedLast30Days: 0, averageDaysSinceApplied: 0 } } as any);
|
|
if (url === '/jobapplications/analytics-overview') return Promise.resolve({ data: { funnel: Array.from({ length: 10 }, (_, index) => ({ label: `Stage ${index}`, count: 0 })), responseRateBySource: [], topCompanies: [], totalResponses: 0, totalActive: 0, timeInStage: [] } } as any);
|
|
if (url === '/jobapplications/reminders' || url === '/jobapplications/analytics' || url === '/jobapplications/tags') return Promise.resolve({ data: [] } as any);
|
|
if (url === '/jobapplications/tag-trends') return Promise.resolve({ data: { months: [], series: [] } } as any);
|
|
return Promise.resolve({ data: {} } as any);
|
|
});
|
|
|
|
const view = renderLoop('/dashboard');
|
|
|
|
expect(await screen.findByText(/get set up/i)).toBeInTheDocument();
|
|
await waitFor(() => expect(screen.queryByText(/application activity/i)).not.toBeInTheDocument());
|
|
expect(screen.queryByText(/conversion funnel/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/median time in stage/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/top companies by activity/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/^top skills$/i)).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /dismiss setup checklist/i }));
|
|
expect(screen.queryByText(/get set up/i)).not.toBeInTheDocument();
|
|
view.unmount();
|
|
|
|
renderLoop('/dashboard');
|
|
await waitFor(() => expect(screen.queryByText(/get set up/i)).not.toBeInTheDocument());
|
|
});
|
|
|
|
test('reminders open action routes tailored-cv gaps into the tailored cv workspace', async () => {
|
|
renderLoop('/reminders');
|
|
|
|
expect(await screen.findByText(/missing tailored cv/i)).toBeInTheDocument();
|
|
const platformJob = await screen.findByText(/platform engineer/i);
|
|
const platformCard = platformJob.closest('.MuiPaper-root') ?? platformJob.parentElement?.parentElement;
|
|
expect(platformCard).toBeTruthy();
|
|
fireEvent.click(within(platformCard as HTMLElement).getByRole('button', { name: /build package/i }));
|
|
|
|
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs/43?section=cv'));
|
|
expect(screen.getByTestId('application-workspace')).toBeInTheDocument();
|
|
});
|
|
|
|
test('job table urgency signals and next actions route into the shared workspace flow', async () => {
|
|
const firstRender = renderLoop('/jobs');
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /backend developer — follow up signal/i }));
|
|
|
|
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs/42?section=communication&followMode=waiting-update'));
|
|
expect(screen.getByTestId('application-workspace')).toBeInTheDocument();
|
|
|
|
firstRender.unmount();
|
|
renderLoop('/jobs');
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /platform engineer.*build package signal/i }));
|
|
|
|
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs/43?section=cv'));
|
|
expect(screen.getByTestId('application-workspace')).toBeInTheDocument();
|
|
});
|