// @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: () => Synthetic timeslot })); 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; 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()); 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('.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()); 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(); const select = () => container.querySelector('.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()); const select = container.querySelector('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()); } }); });