// @vitest-environment jsdom
import { act } from 'react';
import { readFileSync } from 'node:fs';
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);
const contour = readFileSync('src/features/context-layer/context-heatmap.css', 'utf8');
expect(contour).toContain('min-height: var(--tile-h)');
expect(contour).not.toContain('aspect-ratio:');
expect(contour).toContain('padding: var(--tile-inset)');
expect(contour).not.toContain('clip-path:');
expect(contour).not.toContain('--context-hex-width');
expect(tile.querySelector('.context-heatmap__number')?.textContent).toBe('01');
expect(tile.querySelector('strong')?.textContent).toBe('Cloud Privileged Access Exposure');
expect(tile.querySelector('.context-heatmap__category')?.textContent).toBe('Critical');
expect(tile.textContent).not.toContain('Base Severity');
expect(host.querySelector('.context-heatmap__key')).toBeNull();
expect(host.textContent).not.toContain('Badges: 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'])('moves an empty High category into the High group when populated and opens its exact findings in %s', async theme => {
document.documentElement.dataset.theme = theme;
const context = syntheticHeatmapContext();
const clusterId = 'email-spoofing-phishing-exposure';
const cluster = context.clusters.find(item => item.clusterId === clusterId)!;
const conditionIds = new Set(cluster.conditions.map(item => item.conditionId));
cluster.satisfied = 0;
context.observations = context.observations.filter(item => !conditionIds.has(item.conditionId));
const onOpenCluster = vi.fn();
await act(async () => root.render());
let tile = host.querySelector(`[data-cluster="${clusterId}"]`)!;
expect(tile.dataset.severity).toBe('neutral');
expect(tile.querySelector('.context-heatmap__category')?.textContent).toBe('High');
expect(tile.textContent).toContain('0 findings');
let cells = [...host.querySelectorAll('.context-heatmap__cell')];
const lastMediumIndex = cells.reduce((last, item, index) => item.dataset.severity === 'medium' ? index : last, -1);
expect(cells.indexOf(tile)).toBeGreaterThan(lastMediumIndex);
const conditionId = cluster.conditions[0].conditionId;
const populated = { ...context, observations: [...context.observations, {
id: 'synthetic-email', conditionId, tool: 'purple-knight' as const,
sourceRuleId: 'SYNTHETIC', ruleText: 'Synthetic email deviation', matchedBy: 'rule-id' as const,
resultProjectionId: 'synthetic-email', occurrenceId: 'synthetic-email', outcome: 'fail' as const,
assertion: 'observed-by-scanner' as const
}] };
await act(async () => root.render());
tile = host.querySelector(`[data-cluster="${clusterId}"]`)!;
expect(tile.dataset.severity).toBe('high');
expect(tile.textContent).toContain('1 finding');
cells = [...host.querySelectorAll('.context-heatmap__cell')];
expect(cells.indexOf(tile)).toBeLessThan(cells.findIndex(item => item.dataset.severity === 'medium'));
await act(async () => tile.click());
expect(onOpenCluster).toHaveBeenCalledExactlyOnceWith(clusterId);
});
it.each(['dark', 'light'])('shows only the heading and unique findings total without the removed badge explanation 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 Core Overview content or Security totals 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());
// Security now has its own published Context categories. Compare the original Overview
// surface in full, and independently preserve the authoritative Security total.
const core = () => { const clone = host.querySelector('[role="tabpanel"]')!.cloneNode(true) as HTMLElement; clone.querySelector('.context-heatmap')?.remove(); clone.querySelector('.client-business-impact')?.remove(); return clone.innerHTML; };
const securityTotal = () => host.querySelector('.client-security-header')!.textContent;
const baseline = core();
const securityBaseline = securityTotal();
expect(securityBaseline).toContain('1 deviations');
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);
expect(securityTotal()).toBe(securityBaseline);
await act(async () => setEngineEnabled('context', false));
expect(host.querySelector('.context-heatmap')).toBeNull();
expect(core()).toBe(baseline);
expect(securityTotal()).toBe(securityBaseline);
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();
});