Files
jobtrackingapp/job-tracker-ui/src/dashboard-presentation.test.tsx
T

125 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import '@testing-library/jest-dom';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import DashboardView from './components/DashboardView';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
jest.mock('./components/OnboardingChecklist', () => ({ hasJobs }: { hasJobs: boolean | null }) => <><div>Setup retained</div><output aria-label="Setup job status">{hasJobs === null ? 'unknown' : String(hasJobs)}</output></>);
const mockedApi = api as jest.Mocked<typeof api>;
const stats = { total: 9, active: 8, deleted: 1, byStatus: { Applied: 3, Interview: 2, Offer: 1, 'Faglig vurdering': 2 }, appliedLast30Days: 4 };
const overview = { funnel: [{ label: 'Applied', count: 3 }, { label: 'Interview', count: 2 }, { label: 'Offer', count: 1 }], totalActive: 8, totalResponses: 3, responseRateBySource: [{ label: 'NAV', total: 8, responses: 3, rate: 37.5 }], topCompanies: [{ companyId: 7, company: 'Nordlys AS', count: 8, responses: 3, responseRate: 37.5 }], timeInStage: [{ stage: 'Applied', medianDays: 4, count: 3 }], salaryInsights: [{ currency: 'NOK', period: 'year', count: 2, minimum: 600000, maximum: 800000, averageMidpoint: 700000 }] };
const monthly = [{ month: '2026-08', applied: 4, responses: 1 }, { month: '2026-09', applied: 2, responses: 1 }];
function Location() { return <output aria-label="Destination">{useLocation().pathname}{useLocation().search}</output>; }
function open() { return render(<I18nProvider><MemoryRouter><Location /><DashboardView /></MemoryRouter></I18nProvider>); }
function data(url: string) {
if (url.endsWith('/stats')) return stats;
if (url.endsWith('/analytics-overview')) return overview;
if (url.endsWith('/analytics')) return monthly;
if (url.endsWith('/tags')) return [{ tag: 'TypeScript', count: 5 }];
if (url.endsWith('/tag-trends')) return { months: ['2026-08', '2026-09'], series: [{ tag: 'TypeScript', counts: [3, 2] }] };
return [];
}
beforeEach(() => { localStorage.clear(); mockedApi.get.mockReset(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: data(url) }) as any); });
test('[PRES-S01] exact monthly values, sample, custom statuses and separate salary group remain readable', async () => {
open();
expect(await screen.findByText('Tracked jobs')).toBeVisible();
const activity = await screen.findByRole('region', { name: 'Application activity' });
expect(within(activity).getByText(/2 \/ 6 recorded responses/)).toBeInTheDocument();
fireEvent.click(within(activity).getByText('View monthly values'));
const table = within(activity).getByRole('table');
expect(within(table).getByRole('row', { name: 'August 2026 4 1' })).toBeVisible();
expect(within(table).getByRole('row', { name: 'September 2026 2 1' })).toBeVisible();
expect(screen.getByText('Faglig vurdering')).toBeInTheDocument();
expect(screen.getByText(/NOK.*2 jobs/)).toBeInTheDocument();
expect(screen.queryByText(/upcoming|conversion funnel|active applications/i)).not.toBeInTheDocument();
});
test('[PRES-S02] all five stored visibility flags hide their groups and survive remount', async () => {
let view = open(); await screen.findByText('Tracked jobs');
fireEvent.click(screen.getByRole('button', { name: 'Customize dashboard' }));
for (const item of screen.getAllByRole('menuitemcheckbox')) fireEvent.click(item);
expect(screen.getAllByRole('menuitemcheckbox').every(item => item.getAttribute('aria-checked') === 'false')).toBe(true);
view.unmount(); view = open();
await screen.findByText('Your dashboard sections are hidden.');
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
for (const name of ['Application activity', 'Current pipeline', 'Top companies by activity', 'Job tags']) expect(screen.queryByRole('region', { name })).not.toBeInTheDocument();
expect(screen.getByText('Setup retained')).toBeInTheDocument();
});
test('[PRES-S01] a trends failure retains summary and retries without showing a false zero response rate', async () => {
let fail = true;
mockedApi.get.mockImplementation((url: string) => url.endsWith('/analytics') && fail ? Promise.reject(new Error('offline')) : Promise.resolve({ data: data(url) }) as any);
open();
await screen.findByText('Unable to load dashboard trends');
expect(screen.getByText('Tracked jobs')).toBeVisible();
expect(screen.queryByText('0%')).not.toBeInTheDocument();
fail = false; fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
await screen.findByRole('region', { name: 'Application activity' });
});
test('[PRES-S01] a summary failure does not suppress independent trends', async () => {
mockedApi.get.mockImplementation((url: string) => url.endsWith('/stats') ? Promise.reject(new Error('offline')) : Promise.resolve({ data: data(url) }) as any);
open(); await screen.findByText('Unable to load dashboard summary');
expect(await screen.findByRole('region', { name: 'Application activity' })).toBeVisible();
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
});
test('[PRES-J05 J09 S01] raw status and company destinations use supported exact query fields', async () => {
open(); fireEvent.click(await screen.findByRole('link', { name: /Faglig vurdering/ }));
expect(screen.getByLabelText('Destination')).toHaveTextContent('/jobs?status=Faglig+vurdering');
fireEvent.click(screen.getByRole('link', { name: 'Nordlys AS' }));
expect(screen.getByLabelText('Destination')).toHaveTextContent('/jobs?companyId=7');
});
test('[PRES-S01 S02] period changes only reload trends and retain current snapshot meaning', async () => {
open(); await screen.findByText('Tracked jobs');
mockedApi.get.mockClear(); fireEvent.click(screen.getByRole('button', { name: '6 mo' }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/analytics', { params: { months: 6 } }));
expect(mockedApi.get).not.toHaveBeenCalledWith('/jobapplications/stats');
expect(screen.getByRole('button', { name: '6 mo' })).toHaveAttribute('aria-pressed', 'true');
});
test('[PRES-S01] zero activity is an explicit window-empty state without a fabricated response percentage', async () => {
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/analytics') ? [{ month: '2026-09', applied: 0, responses: 0 }] : data(url) }) as any);
open();
await screen.findByText('No applications or dated responses in this period.');
expect(screen.queryByText(/0%/)).not.toBeInTheDocument();
expect(screen.getByText('Tracked jobs')).toBeVisible();
});
test('[PRES-S01] activity chart uses one shared zero-based scale and a readable legend', async () => {
open();
const activity = await screen.findByRole('region', { name: 'Application activity' });
expect(within(activity).getByText('Solid: applications. Dashed: dated responses. Shared scale: 0–4. Exact values below.')).toBeVisible();
// The fixture's 4 applications must plot above 1 response, not at the same independently scaled height.
const paths = activity.querySelectorAll('svg path');
const firstY = (path: Element) => Number(path.getAttribute('d')!.split(' ')[2]);
expect(firstY(paths[0])).toBeLessThan(firstY(paths[1]));
});
test('[P7-02 PRES-S01 S02] setup guidance treats pending and failed summaries as unknown until their owner retries', async () => {
let resolveStats!: (response: { data: typeof stats }) => void;
let fail = false;
const pending = new Promise<{ data: typeof stats }>(resolve => { resolveStats = resolve; });
mockedApi.get.mockImplementation((url: string) => url.endsWith('/stats')
? fail ? Promise.reject(new Error('summary unavailable')) : pending
: Promise.resolve({ data: data(url) }) as any);
const view = open();
expect(screen.getByLabelText('Setup job status')).toHaveTextContent('unknown');
await screen.findByRole('region', { name: 'Application activity' });
await act(async () => { resolveStats({ data: { ...stats, total: 0 } }); });
await waitFor(() => expect(screen.getByLabelText('Setup job status')).toHaveTextContent('false'));
view.unmount();
fail = true;
open();
await screen.findByText('Unable to load dashboard summary');
expect(screen.getByLabelText('Setup job status')).toHaveTextContent('unknown');
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: data(url) }) as any);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
await waitFor(() => expect(screen.getByLabelText('Setup job status')).toHaveTextContent('true'));
expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/stats');
});