Assessor_2.0/tests/features/source-selection-stays-in-v...

165 lines
8.7 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useApp } from '../../src/app/AppProvider';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { SearchFilterBar } from '../../src/features/search-filter/SearchFilterBar';
import { makeSheet } from '../../src/importers/sheet-factory';
import { genericProfile } from '../../src/profiles/generic/generic-profile';
import type { ImportedDataset } from '../../src/domain/dataset';
import type { DynamicFacetFilter } from '../../src/features/search-filter/dynamic-facets';
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Synthetic timeslot</span> }));
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
function fixture() {
const sheet = makeSheet('Synthetic', [
['Name', 'Status', 'Severity', 'Source: Scan'],
['Synthetic A', 'Passed', 'Low', 'Scanner A'],
['Synthetic B', 'Failed', 'High', 'Scanner B'],
['Synthetic C', 'Failed', 'Critical', 'Scanner B']
], '', 'synthetic.csv', { isProjectSheet: true });
const dataset = { fileName: 'Synthetic', sheets: [sheet], scanTool: 'Generic' } as ImportedDataset;
const state = {
dataset, activeSheet: sheet, activeSheetId: sheet.id, activeProfile: genericProfile, reviews: {},
facetFilters: {}, search: '', statusFilter: 'all', pathFindingIds: [], actionOnly: false, view: 'table',
setView: vi.fn(), resetFilters: vi.fn(), setActionOnly: vi.fn(), setStatusFilter: vi.fn(), setPathFindingIds: vi.fn(),
setActiveSheet: vi.fn(),
setFacetFilter: vi.fn((id: string, filter: DynamicFacetFilter | null) => {
state.facetFilters = { ...state.facetFilters };
if (filter) state.facetFilters[id] = filter;
else delete state.facetFilters[id];
})
} as unknown as ReturnType<typeof useApp>;
vi.mocked(useApp).mockImplementation(() => state);
return state;
}
describe('source selection preserves the current view', () => {
it('shows the Data tile first and opens its deviation count in Work', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const state = fixture();
const openWork = vi.fn();
const container = document.createElement('div');
const root = createRoot(container);
try {
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
const header = container.querySelector('.client-priority-findings > header');
expect(header?.querySelector('h2')?.textContent).toBe('Data');
expect(header?.textContent).toContain('Deviations');
expect(header?.querySelector('.client-priority-total')?.getAttribute('aria-label')).toBe('Deviations: 2, 67%');
expect(header?.textContent).not.toContain('Critical');
expect(header?.textContent).not.toContain('High');
const priorityTiles = container.querySelectorAll('.client-priority > section');
expect(priorityTiles[0]?.classList.contains('client-priority-findings')).toBe(true);
expect(priorityTiles[1]?.classList.contains('client-domain-information')).toBe(true);
const dataFacts = priorityTiles[0]?.querySelector('.client-data-facts');
expect(dataFacts?.querySelectorAll('.client-data-fact')).toHaveLength(6);
expect(dataFacts?.querySelectorAll('.client-glyph')).toHaveLength(6);
expect(container.querySelector('.client-overview-facts')).toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('.client-priority-total')!.click());
expect(state.setStatusFilter).toHaveBeenCalledWith('deviation');
expect(openWork).toHaveBeenCalledOnce();
} finally { await act(async () => root.unmount()); }
});
it('omits the generic Tabular Data row from the Environment Detail source list', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const state = fixture();
const sheet = makeSheet('Synthetic with generic source', [
['Name', 'Status', 'Severity', 'Source: Scan'],
['Recognized result', 'Passed', 'Low', 'Scanner A'],
['Generic row', 'Failed', 'High', 'Tabular Data']
], '', 'synthetic.csv', { isProjectSheet: true });
state.dataset = { ...state.dataset!, sheets: [sheet] };
state.activeSheet = sheet;
state.activeSheetId = sheet.id;
const container = document.createElement('div');
const root = createRoot(container);
try {
await act(async () => root.render(<DashboardPanel onOpenWork={vi.fn()}/>));
const sourceRows = [...container.querySelectorAll('.client-environment-sources li')]
.map(row => row.textContent ?? '');
expect(sourceRows).toHaveLength(1);
expect(sourceRows[0]).toContain('Scanner A');
expect(sourceRows.join(' ')).not.toContain('Tabular Data');
} finally { await act(async () => root.unmount()); }
});
it('updates Client Info in place, retains every source option, resets to All and reuses unchanged analysis', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const state = fixture();
const openWork = vi.fn();
const container = document.createElement('div');
const root = createRoot(container);
const render = () => root.render(<DashboardPanel onOpenWork={openWork}/>);
const select = () => container.querySelector<HTMLSelectElement>('.client-overview-source select')!;
const deviations = () => container.querySelector('.client-priority-findings header strong')?.textContent;
const passed = () => container.querySelector('.client-priority-context strong')?.textContent;
const change = async (value: string) => {
await act(async () => { select().value = value; select().dispatchEvent(new Event('change', { bubbles: true })); });
await act(async () => render());
};
try {
await act(async () => render());
expect(deviations()).toBe('2');
await change('scanner a');
expect(select().value).toBe('scanner a');
expect(select().options).toHaveLength(3);
expect(deviations()).toBe('0');
expect(passed()).toBe('1');
expect(container.querySelectorAll('.client-environment-sources li > button')).toHaveLength(1);
// An unchanged source must reuse the dataset-owned analysis, including after remount.
let reads = 0;
for (const record of state.activeSheet!.analysis.records) {
const fields = record.fields;
Object.defineProperty(record, 'fields', { get: () => { reads++; return fields; }, configurable: true });
}
await act(async () => root.render(null));
await act(async () => render());
expect(reads).toBe(0);
expect(select().value).toBe('scanner a');
await change('scanner b');
expect(deviations()).toBe('2');
expect(passed()).toBe('0');
await change('all');
expect(deviations()).toBe('2');
expect(passed()).toBe('1');
expect(state.facetFilters['provenance:source-scan']).toBeUndefined();
expect(openWork).not.toHaveBeenCalled();
expect(state.setView).not.toHaveBeenCalled();
expect(state.resetFilters).not.toHaveBeenCalled();
expect(state.view).toBe('table');
// Replacing the loaded timeslot cannot reuse the previous source totals.
const replacement = makeSheet('Next synthetic timeslot', [
['Name', 'Status', 'Source: Scan'], ['Synthetic next', 'Passed', 'Scanner C']
], '', 'synthetic.csv', { isProjectSheet: true });
state.dataset = { ...state.dataset!, sheets: [replacement] };
state.activeSheet = replacement;
await act(async () => render());
expect(select().value).toBe('all');
expect([...select().options].map(option => option.value)).toEqual(['all', 'scanner c']);
expect(deviations()).toBe('0');
expect(passed()).toBe('1');
} finally { await act(async () => root.unmount()); }
});
it.each(['columns', 'table', 'records', 'raw'] as const)('keeps the %s Work view when changing Source', async view => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const state = fixture();
state.view = view;
const container = document.createElement('div');
const root = createRoot(container);
try {
await act(async () => root.render(<SearchFilterBar/>));
const select = container.querySelector<HTMLSelectElement>('select[aria-label="Source: Scan"]')!;
await act(async () => { select.value = 'scanner b'; select.dispatchEvent(new Event('change', { bubbles: true })); });
expect(state.facetFilters['provenance:source-scan']?.values).toEqual(['scanner b']);
expect(state.setView).not.toHaveBeenCalled();
expect(state.view).toBe(view);
} finally { await act(async () => root.unmount()); }
});
});