import React from 'react';
import '@testing-library/jest-dom';
import { 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', () => () =>
Setup retained
);
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 ; }
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]));
});