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 }) => <>
Setup retained
{hasJobs === null ? 'unknown' : String(hasJobs)}); const mockedApi = api as jest.Mocked; 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 {useLocation().pathname}{useLocation().search}; } function open() { return render(); } 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'); });