// @vitest-environment jsdom import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { beforeEach, afterEach, expect, it, vi } from 'vitest'; import { useApp } from '../../src/app/AppProvider'; import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; import { AssessorApiClient } from '../../src/api/assessor-api-client'; import { ClientContextHeatmap, ContextHeatmap } from '../../src/features/context-layer/ContextHeatmap'; import { resetAssessmentContextRequestsForTests } from '../../src/features/context-layer/use-assessment-context'; import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel'; import { makeSheet } from '../../src/importers/sheet-factory'; import { genericProfile } from '../../src/profiles/generic/generic-profile'; import { syntheticHeatmapContext, reassignedHeatmapContext, emptyHeatmapContext } from './context-heatmap-fixture'; import { contextClusterIndex } from '../../src/features/context-layer/context-cluster-facet'; import type { FindingRecord } from '../../src/domain/finding'; vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => Synthetic timeslot })); let host: HTMLDivElement; let root: Root; beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); localStorage.clear(); resetEnginePreferencesForTests(); resetAssessmentContextRequestsForTests(); host = document.createElement('div'); document.body.append(host); root = createRoot(host); const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity'], ['Control A', 'Failed', 'High']], '', 'synthetic.csv', { isProjectSheet: true }); vi.mocked(useApp).mockReturnValue({ dataset: { fileName: 'Synthetic client', sheets: [sheet], scanTool: 'Generic' }, activeSheet: sheet, activeProfile: genericProfile, reviews: {}, targetProjectId: 'synthetic-client', activeAssessmentIds: ['synthetic-timeslot'], activeScanRunIds: ['synthetic-run'], facetFilters: {}, setStatusFilter: vi.fn(), setFacetFilter: vi.fn(), setPathFindingIds: vi.fn(), resetFilters: vi.fn(), setView: vi.fn(), setActionOnly: vi.fn(), setActiveSheet: vi.fn() } as unknown as ReturnType); vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue({ observations: [], sections: [] } as never); vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); }); afterEach(() => { act(() => root.unmount()); host.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it.each(['dark', 'light'])('shows only severity, category and total findings and opens the cluster directly in %s', async theme => { document.documentElement.dataset.theme = theme; const context = syntheticHeatmapContext(); const onOpenCluster = vi.fn(); await act(async () => root.render()); expect(host.querySelectorAll('.context-heatmap__cell')).toHaveLength(22); const tile = host.querySelector('[data-cluster="cloud-privileged-access-exposure"]')!; expect(tile.dataset.severity).toBe('critical'); expect(tile.children).toHaveLength(3); expect(tile.textContent).toContain('Base Severity'); expect(tile.textContent).toContain('2 findings'); expect(host.textContent).not.toMatch(/supporting conditions|recognized|estimated|assigned deviations|Severity catalog/); await act(async () => tile.click()); expect(onOpenCluster).toHaveBeenCalledWith('cloud-privileged-access-exposure'); expect(host.querySelector('[data-cluster="credential-relay-exposure"]')?.getAttribute('data-severity')).toBe('neutral'); expect(host.querySelector('.context-heatmap__detail')).toBeNull(); const ranks = [...host.querySelectorAll('.context-heatmap__cell')].map(cell => ({ critical: 0, high: 1, medium: 2, neutral: 3, undetermined: 3 })[cell.dataset.severity!] ?? 3); expect(ranks).toEqual([...ranks].sort((a, b) => a - b)); }); it.each(['dark', 'light'])('shows the unique findings total directly beside the title without the old subtitle or legend in %s', async theme => { document.documentElement.dataset.theme = theme; const context = reassignedHeatmapContext(); const otherCondition = context.clusters.find(item => item.clusterId === 'adcs-escalation')!.conditions[0].conditionId; context.observations.push({ ...context.observations[0], id: 'overlapping-evidence', conditionId: otherCondition }); context.observations.push({ ...context.observations[0], id: 'passed-evidence', occurrenceId: 'passed-only', outcome: 'pass' }); context.recognitions.push({ ...context.recognitions[0], id: 'overlapping-setting', clusterId: 'adcs-escalation' }); context.evaluations.push({ ...context.evaluations[0], id: 'passed-setting', identityKey: 'passed-only', matchedBy: 'setting', verdict: 'passed' }); const original = JSON.stringify(context); const onOpenCluster = vi.fn(); await act(async () => root.render()); const header = host.querySelector('.context-heatmap__header')!; expect(header.textContent).toBe('Context heatmapFindings: 31'); expect(header.children).toHaveLength(2); expect(header.firstElementChild?.tagName).toBe('H2'); expect(header.firstElementChild?.nextElementSibling?.className).toBe('context-heatmap__total'); expect(host.querySelector('.context-heatmap__legend')).toBeNull(); expect(host.textContent).not.toContain('Selected timeslot'); await act(async () => host.querySelector('[data-cluster="other-deviations"]')!.click()); expect(onOpenCluster).toHaveBeenCalledExactlyOnceWith('other-deviations'); expect(header.textContent).toBe('Context heatmapFindings: 31'); expect(JSON.stringify(context)).toBe(original); await act(async () => root.render()); expect(host.querySelector('.context-heatmap__header')!.textContent).toBe('Context heatmapFindings: 0'); expect([...host.querySelectorAll('.context-heatmap__amount')].every(cell => cell.textContent === '0 findings')).toBe(true); }); it('uses the real Client Info click path to open all sources for exactly the selected Work cluster', async () => { vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticHeatmapContext()); const projectSheet = useApp().activeSheet!; const sourceSheet = makeSheet('Source only', [['Name', 'Status'], ['Control B', 'Fail']], '', 'source.csv'); useApp().dataset!.sheets.push(sourceSheet); useApp().activeSheet = sourceSheet; useApp().facetFilters = { 'provenance:source-scan': { values: ['Scanner A'] } }; const onOpenWork = vi.fn(); await act(async () => root.render()); await act(async () => host.querySelector('[data-cluster="cloud-privileged-access-exposure"]')!.click()); expect(useApp().resetFilters).toHaveBeenCalledOnce(); expect(useApp().setStatusFilter).toHaveBeenCalledWith('deviation'); expect(useApp().setActionOnly).toHaveBeenCalledWith(false); expect(useApp().setFacetFilter).toHaveBeenCalledExactlyOnceWith('context:cluster', { values: ['cloud-privileged-access-exposure'] }); expect(useApp().setView).toHaveBeenCalledWith('columns'); expect(useApp().setActiveSheet).toHaveBeenCalledWith(projectSheet.id); expect(onOpenWork).toHaveBeenCalledOnce(); expect(host.querySelector('.context-heatmap__detail')).toBeNull(); }); it('opens Other with the same count as its actual Work membership after GPO reassignment', async () => { const context = reassignedHeatmapContext(); vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context); const onOpenWork = vi.fn(); await act(async () => root.render()); const tile = host.querySelector('[data-cluster="other-deviations"]')!; expect(tile.textContent).toContain('2 findings'); expect(tile.textContent).not.toContain('31 findings'); expect(host.querySelector('.context-heatmap__header')!.textContent).toBe('Context heatmapFindings: 31'); await act(async () => tile.click()); expect(useApp().setFacetFilter).toHaveBeenCalledWith('context:cluster', { values: ['other-deviations'] }); expect(onOpenWork).toHaveBeenCalledOnce(); const records = [ ...context.findings.map(item => ({ findingOccurrenceId: item.occurrenceId, fields: [] })), ...context.settings.map(item => ({ fields: [{ label: 'Technical Identity', value: item.identityKey }, { label: 'Scope', value: item.scope }] })) ] as unknown as FindingRecord[]; const facet = contextClusterIndex(context).facet; expect(records.filter(record => facet.valuesFor!(record).includes('other-deviations'))).toHaveLength(2); }); it('toggles the real Client Info extension without changing any Core content or making requests while off', async () => { const context = syntheticHeatmapContext(); const original = JSON.stringify(useApp().dataset); const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context); setEngineEnabled('context', false); await act(async () => root.render()); const core = () => { const clone = host.cloneNode(true) as HTMLElement; clone.querySelector('.context-heatmap')?.remove(); clone.querySelector('.client-business-impact')?.remove(); return clone.innerHTML; }; const baseline = core(); expect(request).not.toHaveBeenCalled(); await act(async () => setEngineEnabled('context', true)); expect(request).toHaveBeenCalledTimes(1); expect(request).toHaveBeenCalledWith('synthetic-client', ['synthetic-timeslot'], ['synthetic-run']); expect(host.querySelectorAll('.context-heatmap__cell')).toHaveLength(22); expect(core()).toBe(baseline); await act(async () => setEngineEnabled('context', false)); expect(host.querySelector('.context-heatmap')).toBeNull(); expect(core()).toBe(baseline); expect(JSON.stringify(useApp().dataset)).toBe(original); expect(useApp().setFacetFilter).not.toHaveBeenCalled(); await act(async () => setEngineEnabled('context', true)); expect(request).toHaveBeenCalledTimes(1); }); it('suppresses late results when disabled and shows an error instead of a falsely empty heatmap', async () => { let complete!: (value: ReturnType) => void; vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockImplementation(() => new Promise(resolve => { complete = resolve; })); await act(async () => root.render()); expect(host.textContent).toContain('Loading Context'); await act(async () => setEngineEnabled('context', false)); await act(async () => complete(syntheticHeatmapContext())); expect(host.textContent).toBe(''); resetAssessmentContextRequestsForTests(); vi.mocked(AssessorApiClient.prototype.getAssessmentContext).mockRejectedValue(new Error('Synthetic unavailable')); await act(async () => setEngineEnabled('context', true)); expect(host.querySelector('[role="alert"]')?.textContent).toContain('could not be loaded'); expect(host.querySelector('.context-heatmap__cell')).toBeNull(); });