102 lines
7.0 KiB
TypeScript
102 lines
7.0 KiB
TypeScript
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', () => () => <div>Setup retained</div>);
|
||
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]));
|
||
});
|