feat(comparison): Compare tab, Work Change facet and Finding Reader section (2.26.0)

Register src/features/comparison as the Comparison Engine UI path and wire its
extension points: the Engines popup entry, the Client Info Compare tab (A/B
selection, score delta over comparable sources, change counts, source table),
the Work Change facet before Source: Scan (only after the tab loaded a
comparison of the open timeslot) and a collapsed Finding Reader Compare
section with both states and the retained GPO values side by side. The engine
now carries those values. The Data Flow diagram shows the engine; product
version 2.26.0.
This commit is contained in:
Binh 2026-10-13 14:13:57 +02:00
parent 1dd2164a85
commit 522d0b47f7
33 changed files with 906 additions and 64 deletions

View File

@ -17,7 +17,7 @@ export const ENGINE_PATHS = [
['Report Engine', ['src/reporting/', 'server/reporting/', 'tests/reporting/', 'tests/server/report-archive.test.ts',
'tests/postgres/report-read-only-runtime.test.ts']],
['Compliance Engine', ['src/compliance/', 'server/compliance/', 'tests/compliance/']],
['Comparison Engine', ['src/comparison/', 'server/comparison/', 'tests/comparison/']],
['Comparison Engine', ['src/comparison/', 'src/features/comparison/', 'server/comparison/', 'tests/comparison/']],
];
export const CORE_AREAS = [

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@
## Comparison Engine: decision and staging (planned 2026-10-13, Phase 8 timeslot comparison as an engine)
Status: **steps 1, 2 and 3a done and verified; step 3b partly done (2026-10-13); 3b extension-point wiring and step 4 open**. Explicit user decision 2026-10-13: the timeslot comparison of [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md) (P1-P6) becomes a separately switchable **Comparison Engine** that owns its comparison logic. Its folders follow the other engines without an `-engine` suffix: `src/comparison/`, `server/comparison/`, `tests/comparison/`. It reads the Core only through a declared read-only port and never writes, overrides or re-derives a Core verdict, score, review, risk acceptance or Result DB row. All contracts, the acceptance matrix and the conservative rules of the timeslot plan stay binding unchanged (object/scope identity, no `absent = resolved` without a declared profile negative-evidence contract, a missing source keeps its open deviations as unverified, score delta only over the shared population, UI is a consumer, never its own matcher).
Status: **steps 1, 2, 3a and 3b done (2026-10-13); step 4 (engine storage) and Security Domain deltas open**. Explicit user decision 2026-10-13: the timeslot comparison of [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md) (P1-P6) becomes a separately switchable **Comparison Engine** that owns its comparison logic. Its folders follow the other engines without an `-engine` suffix: `src/comparison/`, `server/comparison/`, `tests/comparison/`. It reads the Core only through a declared read-only port and never writes, overrides or re-derives a Core verdict, score, review, risk acceptance or Result DB row. All contracts, the acceptance matrix and the conservative rules of the timeslot plan stay binding unchanged (object/scope identity, no `absent = resolved` without a declared profile negative-evidence contract, a missing source keeps its open deviations as unverified, score delta only over the shared population, UI is a consumer, never its own matcher).
UI decision 2026-10-13 (mockups A and B in the local, git-ignored `artifacts/compare-engine/`; synthetic Timeslot A): entry is a Client Info tab `Compare` with A/B timeslot selection, change counts, Security Domain and source deltas, each count opening Work; Work receives a `Change` facet and change badges, and the Finding Reader a `Compare` section with A and B values side by side. A retained value change of a still deviating GPO setting is shown neutral (`Value changed`), neither better nor worse. Mockup C (better/worse bridge) is not selected.
@ -18,7 +18,9 @@ Step 3a (engine paths only, no Core release): `src/comparison/timeslot-compariso
Step 3b, first part (Core release Presentation, Platform): engine read port `src/comparison/comparison-read-port.ts`; Platform adapter `server/timeslot-comparison-input.ts` builds each timeslot as Work reads it (scanner models scoped to the selected runs, GPO verdicts through `currentPolicyEvaluationsForWork` and `buildPolicyWorkResults`, GPO observation time only from every contributing backup's `backupCreatedAt`); route `GET /api/v1/projects/:id/timeslot-comparison` (baseline/target assessment and scan-run IDs; 400 for a missing timeslot, a shared scan run or explicit dates in the wrong order, 404 for a foreign assessment, 409 on a concurrent change, 503 when switched off; read-only); `AssessorApiClient.getTimeslotComparison<T>` (engine-free) and the activity label `Comparing timeslots`. Engine UI written and synthetically tested but **parked, not in the repository** (git-ignored `artifacts/compare-engine/parked-ui/`): `ComparisonTab` (A/B selection, B defaults to the open timeslot, A to the one before it, only earlier timeslots as A; Overall Score A/B with a labelled delta over comparable sources; change facts that call `onOpenChange`; source table with comparison basis), `useTimeslotComparison` (timeslots grouped like Data Hub) and its four interaction tests. It cannot live in `src/comparison/`, which `tests/local-only-check.mjs` keeps platform-neutral (no React); like the Context Engine's `src/features/context-layer/`, it needs a registered engine UI path (proposed `src/features/comparison/`, a Governance change to the area map). Security Domain deltas are not in it yet. Not done, needs `Governance, Presentation, Core Tests`: the engine UI path, the Engines popup entry (`tests/components/engines-control.test.tsx` pins the engine list), the Client Info `Compare` tab, the Work `Change` facet and the Finding Reader `Compare` section (each a new pinned extension point in `tests/architecture/core-engine-boundary.test.ts`), and the Data Flow engine diagram (`tests/features/db-structure-flow.test.tsx`). No version change while unwired. Verified: 18 engine tests (3 route tests among them); import gate 528/528; main suite 2,465 passed, 18 optional skipped, 1 failed (the same pre-existing, unrelated `db-structure-layout.test.ts` check); local-only and design-language checks pass; both production builds pass.
Change log 2026-10-13: recorded the Comparison Engine decision, naming and staging; relocated the Core Phase 8 scan-run comparison and registered the Comparison Engine with its invariance test; step 3a engine comparison of scanners, GPO settings and score basis; step 3b read port, timeslot route and API client; Compare tab parked.
Step 3b, second part (Core release Governance, Presentation, Core Tests, Platform; the Data Flow diagram part finished under a renewed Presentation, Core Tests, Platform release after an earlier release had expired mid-turn): the engine UI path `src/features/comparison/` is registered (area map, AGENTS.md, onboarding) and holds the formerly parked `ComparisonTab`, `useTimeslotComparison`, `comparison-work-facets` and `FindingComparison`. Extension points (pinned in the boundary test): Engines popup entry `Comparison Engine` (default on); Client Info tab `Compare` after `Compliance`; Work facet `Change` before `Source: Scan` in the shared filter pipeline (filter-records, work-facets, use-filtered-records, SearchFilterBar, ActionlistControls, SidebarSecurityDomains), present only after the Compare tab loaded a comparison whose B is the open timeslot, so opening Work never starts a comparison; a change count opens Work with that facet value (`Resolved` with status All, all others Deviation) and is clickable only when timeslot B holds a record for it; Finding Reader section `Compare` after Context, collapsed, with both states and, for GPO settings, both retained configured values as Work displays them (the engine now carries them). The Data Flow engine diagram shows the Comparison Engine and its two relationships. Product version 2.26.0 (backward-compatible capability). Synthetic test client `compare test` (2026-06-10 and 2026-09-15) created through the regular import API from generated synthetic CIS-CAT reports (resolved, unchanged, regressed, new and absent rules) plus the synthetic Maester fixture in B only; the generated files are in git-ignored `artifacts/compare-engine/test-client/`. Verified: import gate 528/528; main suite 2,474 passed, 18 optional skipped, 1 failed (the same pre-existing, unrelated `db-structure-layout.test.ts` check); local-only and design-language checks pass; both production builds pass. Not yet verified in the running app: it was started outside this session and still runs 2.25.0.
Change log 2026-10-13: recorded the Comparison Engine decision, naming and staging; relocated the Core Phase 8 scan-run comparison and registered the Comparison Engine with its invariance test; step 3a engine comparison of scanners, GPO settings and score basis; step 3b read port, timeslot route, API client, Compare tab, Work Change facet, Finding Reader section and engine diagram; version 2.26.0.
## Results cluster foreground correction (2026-10-13, Phase 7 Presentation, 2.25.6)

View File

@ -259,7 +259,7 @@ Documentation under `docs/` is not gated.
| **Context Engine** | `src/assessment-context/`, `src/features/context-layer/` | Gives every deviation a context cluster, in a fixed order of tiers: *catalogued* (catalogue rule, CIS section, declared ATT&CK technique), *recognized* (distinctive terms, technical anchors) or *estimated* (local TF-IDF similarity, category fallback). Deterministic, no AI. Shown as badges, the `Context Cluster` facet, the Finding Reader section and the Client Info heatmap. A context statement says "favours", never "exploitable". | none (in memory) |
| **Report Engine** | `src/reporting/`, `server/reporting/` | Builds the Management Report (German, offline, no LLM, requires published Context results) and the Technical Report from one shared projection. | own file archive, separate read-only DB pool |
| **Compliance Engine** | `src/compliance/`, `server/compliance/` | Maps findings to regulatory controls; ISO/IEC 27001/27002 is the first framework. Owns **all AI integration**: ISO mapping and the AI overview. | own tables in PostgreSQL, Qdrant index |
| **Comparison Engine** | `src/comparison/`, `server/comparison/` | Compares two timeslots A and B of one client: verified resolved, still open, unverified, new, regressed and neutral value changes (see `docs/TIMESLOT_COMPARISON_PLAN.md`). In development; no UI yet. | none yet (own schema planned) |
| **Comparison Engine** | `src/comparison/`, `src/features/comparison/`, `server/comparison/` | Compares two timeslots A and B of one client: verified resolved, still open, unverified, new, regressed and neutral value changes (see `docs/TIMESLOT_COMPARISON_PLAN.md`). Shown in the Client Info `Compare` tab. | none yet (own schema planned) |
**Engine rules**:

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "assessor",
"version": "2.25.0",
"version": "2.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "assessor",
"version": "2.25.0",
"version": "2.26.0",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"jsdom": "^29.1.1",

View File

@ -1,6 +1,6 @@
{
"name": "assessor",
"version": "2.25.0",
"version": "2.26.0",
"private": true,
"type": "module",
"scripts": {

View File

@ -6,11 +6,12 @@ export const ENGINES = [
{ id: 'context', label: 'Context Engine', required: false },
{ id: 'report', label: 'Report Engine', required: false },
{ id: 'remediation', label: 'Remediation Engine', required: false },
{ id: 'compliance', label: 'Compliance Engine', required: false }
{ id: 'compliance', label: 'Compliance Engine', required: false },
{ id: 'comparison', label: 'Comparison Engine', required: false }
] as const;
export type EngineId = typeof ENGINES[number]['id'];
export const ENGINE_PREFERENCES_KEY = 'ASSESSOR.engines.v1';
const defaults = Object.freeze({ core: true, context: true, report: true, remediation: true, compliance: true });
const defaults = Object.freeze({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
type Preferences = Readonly<Record<EngineId, boolean>>;
let snapshot: Preferences | undefined;
const listeners = new Set<() => void>();
@ -20,7 +21,7 @@ export function readEnginePreferences(): Preferences {
try {
const saved = JSON.parse(window.localStorage.getItem(ENGINE_PREFERENCES_KEY) ?? '{}');
snapshot = Object.freeze({ core: true, context: saved?.context !== false, report: saved?.report !== false, remediation: saved?.remediation !== false,
compliance: saved?.compliance !== false });
compliance: saved?.compliance !== false, comparison: saved?.comparison !== false });
} catch { snapshot = defaults; }
return snapshot;
}

View File

@ -21,6 +21,8 @@ interface TimeslotSetting {
verdict?: SettingVerdict;
baselineIds: string;
actualValues: string;
/** The retained configured values as Work displays them; empty when none is retained. */
displayValues: string[];
resultIds: string[];
ambiguous: boolean;
}
@ -38,6 +40,9 @@ export interface GpoSettingDecision {
reasons: string[];
baselineResultIds: string[];
targetResultIds: string[];
/** Retained configured values of both timeslots as Work displays them, for a side-by-side reading. */
baselineValues: string[];
targetValues: string[];
}
export interface GpoTargetChange {
@ -82,6 +87,10 @@ function rollUp(results: PolicyWorkResult[]): Map<string, TimeslotSetting> {
baselineIds: canonicalJson([...new Set(verdicts.flatMap(item => item.comparison?.baselineId ?? []))].sort()),
actualValues: canonicalJson([...new Set(decisive.flatMap(item =>
item.comparison?.comparator.actual === undefined ? [] : [canonicalJson(item.comparison.comparator.actual)]))].sort()),
displayValues: [...new Set(decisive.flatMap(item => {
const actual = item.comparison?.comparator.actual;
return actual === undefined ? [] : [typeof actual === 'object' && actual !== null ? JSON.stringify(actual) : String(actual)];
}))].sort(),
resultIds: group.map(item => item.id).sort(),
ambiguous: new Set(group.flatMap(item => item.settingKey ?? [])).size > 1
});
@ -112,7 +121,8 @@ export function compareGpoTimeslots(baseline: GpoTimeslotInput, target: GpoTimes
valueChanged = false): GpoSettingDecision => ({
settingKey: item.key, scope: item.scope, identity: item.identity, baselineVerdict: 'Fail',
...(other?.verdict ? { targetVerdict: other.verdict } : {}), state, valueChanged, reasons,
baselineResultIds: item.resultIds, targetResultIds: other?.resultIds ?? []
baselineResultIds: item.resultIds, targetResultIds: other?.resultIds ?? [],
baselineValues: item.displayValues, targetValues: other?.displayValues ?? []
});
if (sourceReasons.length) return {
version, state: 'not-comparable', reasons: sourceReasons,

View File

@ -30,6 +30,8 @@ export interface TimeslotChangeEntry {
counterpartIds: string[];
identityKey?: string;
reasons: string[];
/** GPO settings only: retained configured values of A and B as Work displays them. */
values?: { baseline: string[]; target: string[] };
}
export interface TimeslotSourceScore {
@ -103,7 +105,7 @@ function gpoChanges(gpo: GpoTimeslotComparison, target?: GpoTimeslotInput): Time
sourceId: GPO_SOURCE_ID,
change: item.state === 'confirmed-open' && item.valueChanged ? 'value-changed' : changeOf[item.state],
side: 'baseline', recordIds: item.baselineResultIds, counterpartIds: item.targetResultIds,
identityKey: item.settingKey, reasons: item.reasons
identityKey: item.settingKey, reasons: item.reasons, values: { baseline: item.baselineValues, target: item.targetValues }
})),
...gpo.newDeviations.map((item): TimeslotChangeEntry => ({
sourceId: GPO_SOURCE_ID, change: 'new', side: 'target', recordIds: item.targetResultIds, counterpartIds: [],

View File

@ -14,6 +14,7 @@ import { WorkPackagesWindow } from '../../remediation/WorkPackagesWindow';
import { UNASSIGNED_WORK_PACKAGE, WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine';
import { numberedClusterLabel, publishedClusterColour } from '../sidebar/context-cluster-display';
import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
import { useComparisonWorkFacets } from '../comparison/comparison-work-facets';
export function ActionlistControls() {
const app = useApp();
@ -24,6 +25,7 @@ export function ActionlistControls() {
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
const packageOverview = remediation.status === 'ready' ? remediation.overview : undefined;
const complianceFacets = useComplianceWorkFacets();
const comparisonFacets = useComparisonWorkFacets();
const [failed, setFailed] = useState(false);
const [packagesOpen, setPackagesOpen] = useState(false);
const exportView = async (format: 'csv' | 'md' | 'html') => {
@ -31,7 +33,7 @@ export function ActionlistControls() {
const activity = beginActivity('Core Engine', `Preparing Work ${format.toUpperCase()} export`);
try {
await showActivityFrame();
const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters, workPackages, complianceFacets });
const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters, workPackages, complianceFacets, comparisonFacets });
const statusLabel = app.activeProfile.presentation.statusFilters.find(filter => filter.value === app.statusFilter)?.label ?? app.statusFilter;
const filters = Object.entries(app.facetFilters).map(([id, filter]) => {
const facet = facets.find(item => item.id === id);

View File

@ -0,0 +1,157 @@
import { useId } from 'react';
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
import { GPO_SOURCE_ID, type TimeslotChange, type TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
import { useTimeslotComparison, type ComparableTimeslot } from './use-timeslot-comparison';
import { changesWithWorkRecords } from './comparison-work-facets';
import './comparison.css';
interface Props {
projectId?: string;
/** Assessments of the timeslot open in Client Info; it is the default timeslot B. */
assessmentIds?: readonly string[];
/** Opens Work with the change facet; the extension point decides how. */
onOpenChange?(change: TimeslotChange, baseline: ComparableTimeslot, target: ComparableTimeslot): void;
}
/** Display order of the change facts; better first, then worse, then neutral and unclear states. */
export const CHANGE_FACTS: Array<{ change: TimeslotChange; label: string; tone: 'better' | 'worse' | 'neutral'; glyph: string }> = [
{ change: 'resolved', label: 'Resolved', tone: 'better', glyph: '▼' },
{ change: 'new', label: 'New', tone: 'worse', glyph: '▲' },
{ change: 'regressed', label: 'Regressed', tone: 'worse', glyph: '▲' },
{ change: 'value-changed', label: 'Value changed', tone: 'neutral', glyph: '≠' },
{ change: 'unchanged', label: 'Unchanged', tone: 'neutral', glyph: '=' },
{ change: 'unverified', label: 'Unverified, still open', tone: 'neutral', glyph: '?' },
{ change: 'not-comparable', label: 'Not comparable', tone: 'neutral', glyph: '○' }
];
const REASONS: Record<string, string> = {
'baseline-source-missing': 'Not in timeslot A',
'target-source-missing': 'Not in timeslot B',
'baseline-source-ambiguous': 'Several runs in timeslot A',
'target-source-ambiguous': 'Several runs in timeslot B',
'no-comparable-scan-pair': 'No comparable scan pair',
'scan-scope-changed': 'Assessed scope changed',
'comparison-scope-changed': 'Assessed scope changed',
'processing-contract-incompatible': 'Processed with different import versions',
'processing-contract-unknown': 'Import version unknown',
'newer-observation-not-verified': 'No newer scan or backup time',
'no-shared-decided-settings': 'No setting decided in both timeslots',
'comparison-profile-unavailable': 'Scanner not yet comparable',
'comparison-profile-missing': 'Scanner not yet comparable'
};
export const reasonText = (reasons: string[]) => [...new Set(reasons.map(reason => REASONS[reason] ?? reason))].join(' · ');
export function sourceName(sourceId: string): string {
if (sourceId === GPO_SOURCE_ID) return 'GPOs';
return toolProfileDefinitions.find(item => item.id === sourceId)?.name ?? sourceId;
}
export const date = (value: string) => {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
};
const rounded = (value?: number) => value === undefined ? undefined : Math.round(value);
function Delta({ before, after }: { before?: number; after?: number }) {
if (before === undefined || after === undefined) return <span className="comparison-delta is-neutral">—</span>;
const change = after - before;
if (change === 0) return <span className="comparison-delta is-neutral">= No change</span>;
return change > 0
? <span className="comparison-delta is-better">▲ +{change} Better</span>
: <span className="comparison-delta is-worse">▼ −{Math.abs(change)} Worse</span>;
}
function sourceRows(report: TimeslotComparisonReport) {
return report.sources.map(source => {
const changes = report.changes.filter(item => item.sourceId === source.sourceId);
const count = (change: TimeslotChange) => changes.filter(item => item.change === change).length;
const score = report.score.sources.find(item => item.sourceId === source.sourceId);
return { source, count, score };
});
}
/** Client Info `Compare` tab content of the Comparison Engine (mockup A, user decision 2026-10-13). */
export function ComparisonTab({ projectId, assessmentIds = [], onOpenChange }: Props) {
const id = useId();
const comparison = useTimeslotComparison(projectId, assessmentIds);
const { state, baseline, target, earlier, timeslots, targetIsOpen } = comparison;
const report = state.status === 'ready' ? state.report : undefined;
// Work shows the open timeslot; only changes with a record there can open Work.
const openable = report && targetIsOpen && onOpenChange ? changesWithWorkRecords(report) : new Set<TimeslotChange>();
const option = (item: ComparableTimeslot) => <option key={item.id} value={item.id}>{date(item.date)}</option>;
return <section className="comparison-tab" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
<header className="comparison-tab__header">
<h2 id={`${id}-title`}>Compare</h2>
{timeslots.length > 1 && target && <div className="comparison-tab__timeslots">
<label>A
<select className="comparison-tab__select" value={baseline?.id ?? ''} disabled={!earlier.length}
onChange={event => comparison.selectBaseline(event.target.value)}>{earlier.map(option)}</select>
</label>
<span aria-hidden="true" className="comparison-tab__arrow">→</span>
<label>B
<select className="comparison-tab__select" value={target.id} onChange={event => comparison.selectTarget(event.target.value)}>
{timeslots.map(option)}
</select>
</label>
</div>}
<span className="comparison-tab__note">Read-only comparison · verdicts and scores stay unchanged</span>
</header>
{state.status === 'loading' && <p className="comparison-tab__status">Comparing timeslots…</p>}
{state.status === 'error' && <p className="comparison-tab__status" role="alert">{state.message}</p>}
{state.status === 'unavailable' && <p className="comparison-tab__status">
{state.reason === 'single-timeslot' ? 'This client has only one timeslot. A comparison needs two.' : 'There is no earlier timeslot to compare with.'}
</p>}
{report && baseline && target && <>
<div className="comparison-tab__grid">
<section className="comparison-tile" aria-labelledby={`${id}-score`}>
<h3 id={`${id}-score`}>Overall Score</h3>
<dl className="comparison-score">
<div><dt>A · {date(baseline.date)}</dt><dd>{rounded(report.score.baselineScore) ?? '—'}</dd></div>
<div><dt>B · {date(target.date)}</dt><dd>{rounded(report.score.targetScore) ?? '—'}</dd></div>
</dl>
<Delta before={rounded(report.score.baselineScore)} after={rounded(report.score.targetScore)}/>
<p className="comparison-tile__note">{report.score.change === undefined
? 'No source has a comparable score in both timeslots.'
: `Comparable sources only: ${report.score.sources.filter(item => item.change !== undefined).map(item => sourceName(item.sourceId)).join(', ')}.`}</p>
</section>
<section className="comparison-tile comparison-tile--wide" aria-labelledby={`${id}-changes`}>
<h3 id={`${id}-changes`}>Changes</h3>
{!targetIsOpen && <p className="comparison-tile__note">Open timeslot B in Client Info to show these changes in Work.</p>}
<div className="comparison-facts">
{CHANGE_FACTS.map(fact => {
const count = fact.change === 'not-comparable'
? report.summary['not-comparable'] + report.summary.targetNotComparable : report.summary[fact.change];
return <button key={fact.change} type="button" className="comparison-fact" disabled={!count || !openable.has(fact.change)}
onClick={() => onOpenChange?.(fact.change, baseline, target)}>
<span className="comparison-fact__label">{fact.label}</span>
<span className={`comparison-fact__value is-${fact.tone}`}><span aria-hidden="true">{fact.glyph} </span>{count.toLocaleString('en-US')}</span>
</button>;
})}
</div>
</section>
</div>
<section className="comparison-tile" aria-labelledby={`${id}-sources`}>
<h3 id={`${id}-sources`}>Sources</h3>
<div className="comparison-table-scroll">
<table className="client-information-table comparison-table">
<thead><tr><th>Source</th><th>Resolved</th><th>New</th><th>Regressed</th><th>Value changed</th><th>Unverified</th>
<th>Not comparable</th><th>Score A</th><th>Score B</th><th>Change</th><th>Comparison basis</th></tr></thead>
<tbody>{sourceRows(report).map(({ source, count, score }) => <tr key={source.sourceId}>
<td>{sourceName(source.sourceId)}</td>
<td>{count('resolved')}</td><td>{count('new')}</td><td>{count('regressed')}</td><td>{count('value-changed')}</td>
<td>{count('unverified')}</td><td>{count('not-comparable')}</td>
<td>{rounded(score?.baselineScore) ?? '—'}</td><td>{rounded(score?.targetScore) ?? '—'}</td>
<td><Delta before={rounded(score?.baselineScore)} after={rounded(score?.targetScore)}/></td>
<td>{source.state === 'compared' ? (score?.reasons.length ? reasonText(score.reasons) : 'Compared') : reasonText(source.reasons)}</td>
</tr>)}</tbody>
</table>
</div>
</section>
</>}
</section>;
}

View File

@ -0,0 +1,65 @@
import { Fragment } from 'react';
import { TriangleIcon } from '../../components/TriangleIcon';
import type { FindingRecord } from '../../domain/finding';
import { policyComparisonDisplayLines } from '../../profiles/group-policy/presentation';
import type { TimeslotChange, TimeslotChangeEntry } from '../../comparison/timeslot-comparison-report';
import { useOptionalApp } from '../../app/AppProvider';
import { useEnginePreferences } from '../../app/engine-preferences';
import { comparisonWorkFacetIndex } from './comparison-work-facets';
import { usePublishedComparison } from './use-timeslot-comparison';
import { CHANGE_FACTS, date, reasonText } from './ComparisonTab';
/** What each change says about timeslot A and B; never a re-derived verdict, only the comparison's statement. */
const STATES: Record<TimeslotChange, { a: string; b: string }> = {
resolved: { a: 'Deviation', b: 'Passed' },
unchanged: { a: 'Deviation', b: 'Deviation' },
'value-changed': { a: 'Deviation', b: 'Deviation' },
unverified: { a: 'Deviation', b: 'Not decisive' },
'not-applicable': { a: 'Deviation', b: 'Not applicable' },
'not-comparable': { a: 'Not comparable', b: 'Not comparable' },
new: { a: 'No deviation', b: 'Deviation' },
regressed: { a: 'Passed', b: 'Deviation' }
};
function Values({ values }: { values: string[] }) {
if (!values.length) return <>—</>;
return <>{values.map((value, index) => <p className="policy-guidance-text" key={index}>{policyComparisonDisplayLines(value).map((line, lineIndex) =>
<Fragment key={lineIndex}>{lineIndex > 0 && '\n'}{line.emphasized ? <strong>{line.text}</strong> : line.text}</Fragment>)}</p>)}</>;
}
/**
* Finding Reader `Compare` section of the Comparison Engine (mockup B, user decision 2026-10-13): the change of
* this finding against timeslot A, both states and, for a GPO setting, both retained values side by side.
* Collapsed like the Context section; present only while a comparison of the open timeslot is loaded.
*/
export function FindingComparison({ record }: { record: FindingRecord }) {
// Like the Context section, the reader also renders without AppProvider; then no comparison applies.
const app = useOptionalApp();
const enabled = useEnginePreferences().comparison;
const comparison = usePublishedComparison(app?.targetProjectId, app?.activeAssessmentIds ?? []);
const index = enabled && comparison ? comparisonWorkFacetIndex(comparison) : undefined;
const entry: TimeslotChangeEntry | undefined = index?.entryOf(record);
if (!index || !entry) return null;
const fact = CHANGE_FACTS.find(item => item.change === entry.change);
const { baseline, target } = index.comparison;
const state = STATES[entry.change];
const reasons = entry.reasons.length ? reasonText(entry.reasons) : '';
return <details className="finding-optional-detail finding-comparison">
<summary><TriangleIcon direction="right"/>Compare <span className="finding-comparison__scope">A · {date(baseline.date)} → B · {date(target.date)}</span></summary>
<p className="finding-comparison__change">
<span className={`comparison-fact__value is-${fact?.tone ?? 'neutral'}`}><span aria-hidden="true">{fact?.glyph} </span>{fact?.label ?? entry.change}</span>
{entry.change === 'value-changed' && <span className="finding-comparison__note">Still a deviation; the configured value differs from timeslot A.</span>}
</p>
<div className="comparison-table-scroll">
<table className="client-information-table comparison-table">
<thead><tr><th></th><th>A · {date(baseline.date)}</th><th>B · {date(target.date)}</th></tr></thead>
<tbody>
<tr><td>Result</td><td>{state.a}</td><td>{state.b}</td></tr>
{entry.values && <tr><td>Configured value</td><td><Values values={entry.values.baseline}/></td><td><Values values={entry.values.target}/></td></tr>}
</tbody>
</table>
</div>
{reasons && <p className="finding-comparison__note">{reasons}</p>}
<p className="finding-comparison__note">The comparison reads both timeslots and changes no verdict or score.</p>
</details>;
}

View File

@ -0,0 +1,104 @@
import { useApp } from '../../app/AppProvider';
import { useEnginePreferences } from '../../app/engine-preferences';
import type { FindingRecord } from '../../domain/finding';
import { gpoWorkSettingIdentity } from '../results-browser/work-record-lookup';
import type { DynamicFacet } from '../search-filter/dynamic-facets';
import { GPO_SOURCE_ID, type TimeslotChange, type TimeslotChangeEntry, type TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
import { usePublishedComparison, type PublishedComparison } from './use-timeslot-comparison';
/** Work facet of the Comparison Engine; present only while a comparison of the open timeslot B is loaded. */
export const COMPARISON_CHANGE_FACET_ID = 'comparison:change';
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
/** Facet order: better first, then worse, then neutral and unclear states. */
export const CHANGE_LABELS: Array<{ change: TimeslotChange; label: string }> = [
{ change: 'resolved', label: 'Resolved' },
{ change: 'new', label: 'New' },
{ change: 'regressed', label: 'Regressed' },
{ change: 'value-changed', label: 'Value changed' },
{ change: 'unchanged', label: 'Unchanged' },
{ change: 'unverified', label: 'Unverified, still open' },
{ change: 'not-applicable', label: 'Not applicable' },
{ change: 'not-comparable', label: 'Not comparable' }
];
export interface ComparisonWorkFacetIndex {
facets: DynamicFacet[];
/** The change of a Work record of timeslot B, if the comparison names one. */
changeOf(record: FindingRecord): TimeslotChange | undefined;
/** The full comparison entry behind that change, for the Finding Reader. */
entryOf(record: FindingRecord): TimeslotChangeEntry | undefined;
comparison: PublishedComparison;
}
const gpoKey = (scope: unknown, identity: unknown) => JSON.stringify([scope, identity]);
/**
* Maps the report onto Work records of timeslot B: scanner records by occurrence (a current record, or the
* B counterpart of an A deviation), GPO settings by scope and technical identity, like Result DB drill-downs.
* A deviation of A without a B record has no Work row in B and is therefore not offered.
*/
function entryIndex(report: TimeslotComparisonReport) {
const byOccurrence = new Map<string, TimeslotChangeEntry>();
const bySetting = new Map<string, TimeslotChangeEntry>();
for (const entry of report.changes) {
if (entry.sourceId === GPO_SOURCE_ID) {
if (entry.identityKey && (entry.side === 'target' || entry.counterpartIds.length)) bySetting.set(entry.identityKey, entry);
continue;
}
for (const id of entry.side === 'target' ? entry.recordIds : entry.counterpartIds) byOccurrence.set(id, entry);
}
return (record: FindingRecord): TimeslotChangeEntry | undefined => {
if (record.findingOccurrenceId) return byOccurrence.get(record.findingOccurrenceId);
const identity = gpoWorkSettingIdentity(record.id);
return identity ? bySetting.get(gpoKey(identity.scope, identity.settingId)) : undefined;
};
}
/** Changes that have at least one Work record in timeslot B, so the Compare tab can open them in Work. */
export function changesWithWorkRecords(report: TimeslotComparisonReport): Set<TimeslotChange> {
return new Set(report.changes.filter(entry => entry.side === 'target' || entry.counterpartIds.length).map(entry => entry.change));
}
const indexes = new WeakMap<PublishedComparison, ComparisonWorkFacetIndex>();
export function comparisonWorkFacetIndex(comparison: PublishedComparison): ComparisonWorkFacetIndex {
const existing = indexes.get(comparison);
if (existing) return existing;
const entryOf = entryIndex(comparison.report);
const changeOf = (record: FindingRecord) => entryOf(record)?.change;
const date = new Date(comparison.baseline.date);
const label = Number.isNaN(date.getTime()) ? comparison.baseline.date
: date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
const index: ComparisonWorkFacetIndex = {
comparison,
changeOf,
entryOf,
facets: [{
id: COMPARISON_CHANGE_FACET_ID,
label: 'Change',
source: 'derived',
kind: 'category',
description: `Comparison Engine: change of each finding and GPO setting against timeslot A (${label}). It does not change results.`,
values: CHANGE_LABELS.map(item => ({ value: item.change, label: item.label, count: 0 })),
valuesFor: record => { const change = changeOf(record); return change ? [change] : []; }
}]
};
indexes.set(comparison, index);
return index;
}
/** Adds the `Change` facet directly before the permanent final `Source: Scan` control. */
export function withComparisonFacets(facets: DynamicFacet[], index: ComparisonWorkFacetIndex | undefined): DynamicFacet[] {
if (!index) return facets;
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);
return sourceScan < 0 ? [...facets, ...index.facets]
: [...facets.slice(0, sourceScan), ...index.facets, ...facets.slice(sourceScan)];
}
/** The facet index for Work, or undefined while the engine is off or no comparison of the open timeslot is loaded. */
export function useComparisonWorkFacets(): ComparisonWorkFacetIndex | undefined {
const app = useApp();
const enabled = useEnginePreferences().comparison;
const comparison = usePublishedComparison(app.targetProjectId, app.activeAssessmentIds ?? []);
return enabled && comparison ? comparisonWorkFacetIndex(comparison) : undefined;
}

View File

@ -0,0 +1,44 @@
/* Comparison Engine UI. Shared tokens only; better and worse always pair their colour with a glyph and text. */
.comparison-tab { display: grid; gap: var(--segment-gap); margin-block: 0 var(--space-8); color: var(--text-primary); min-width: 0; }
.comparison-tab__header { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
.comparison-tab__header > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.comparison-tab__timeslots { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.comparison-tab__timeslots label { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--type-table-body-size); color: var(--text-secondary); }
.comparison-tab__select { height: var(--btn-h); padding: 0 var(--space-2); border: var(--bw-hairline) solid var(--border-default); border-radius: var(--btn-radius); background: var(--surface-input); color: var(--text-primary); font: var(--fw-semibold) var(--type-button-size) var(--font-ui); }
.comparison-tab__select:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-hairline); }
.comparison-tab__arrow { color: var(--text-secondary); }
.comparison-tab__note { margin-inline-start: auto; font-size: var(--type-meta-size); color: var(--text-secondary); }
.comparison-tab__status { margin: 0; font-size: var(--type-body-size); color: var(--text-secondary); }
.comparison-tab__grid { display: grid; grid-template-columns: minmax(var(--tile-environment-min-width), 1fr) minmax(0, 2fr); gap: var(--segment-gap); align-items: stretch; }
@media (max-width: 900px) { .comparison-tab__grid { grid-template-columns: minmax(0, 1fr); } }
.comparison-tile { min-width: 0; padding: var(--tile-inset); border: var(--bw-hairline) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); }
.comparison-tile > h3 { margin: 0 0 var(--space-3); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.comparison-tile__note { margin: var(--space-3) 0 0; font-size: var(--type-table-body-size); color: var(--text-secondary); }
.comparison-score { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0 0 var(--space-2); }
.comparison-score > div { padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-divider); }
.comparison-score dt { font-size: var(--type-table-body-size); color: var(--text-secondary); }
.comparison-score dd { margin: 0; font-size: var(--type-h2-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
.comparison-delta { font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; white-space: nowrap; }
.comparison-delta.is-better, .comparison-fact__value.is-better { color: var(--outcome-pass); }
.comparison-delta.is-worse, .comparison-fact__value.is-worse { color: var(--outcome-fail); }
.comparison-delta.is-neutral, .comparison-fact__value.is-neutral { color: var(--text-primary); }
.comparison-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
.comparison-fact { display: grid; gap: var(--space-1); padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.comparison-fact:hover:not(:disabled) { background: var(--tile-surface-hover); }
.comparison-fact:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
.comparison-fact:disabled { cursor: default; }
.comparison-fact__label { font-size: var(--type-table-body-size); color: var(--text-secondary); }
.comparison-fact__value { font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
.comparison-table-scroll { overflow-x: auto; }
.comparison-table td { font-variant-numeric: tabular-nums; }
/* Finding Reader `Compare` section, collapsed like Context. */
.finding-comparison__scope { margin-inline-start: var(--space-2); font-size: var(--type-meta-size); font-weight: var(--fw-regular); color: var(--text-secondary); }
.finding-comparison__change { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-3); margin: var(--space-2) 0; }
.finding-comparison__note { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); color: var(--text-secondary); }

View File

@ -0,0 +1,148 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { groupProjectImportFiles, type ScanImportGroup } from '../project-import/datahub-import-groups';
import type { TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
const api = new AssessorApiClient();
export interface ComparableTimeslot {
id: string;
date: string;
assessmentIds: string[];
scanRunIds: string[];
}
export type TimeslotComparisonState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'unavailable'; reason: 'single-timeslot' | 'no-earlier-timeslot' }
| { status: 'ready'; report: TimeslotComparisonReport };
/** The comparison the Compare tab last loaded for the timeslot open in Client Info; Work reads it for its facet. */
export interface PublishedComparison {
projectId: string;
/** Sorted assessment IDs of timeslot B, the open timeslot. */
targetAssessmentIds: string[];
baseline: ComparableTimeslot;
target: ComparableTimeslot;
report: TimeslotComparisonReport;
}
const key = (values: readonly string[]) => [...values].sort().join('\u0000');
const sameSet = (left: readonly string[], right: readonly string[]) => key(left) === key(right);
const listeners = new Set<() => void>();
let storeVersion = 0;
const notify = () => { storeVersion++; listeners.forEach(listener => listener()); };
const version = () => storeVersion;
const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
// Timeslot A chosen per client and timeslot B, shared by the Compare tab and Work.
const baselineChoices = new Map<string, string>();
let published: PublishedComparison | undefined;
export function publishedComparison(): PublishedComparison | undefined { return published; }
/** The published comparison while it belongs to this client and open timeslot; never a comparison of another scope. */
export function usePublishedComparison(projectId: string | undefined, openAssessmentIds: readonly string[]): PublishedComparison | undefined {
const current = useSyncExternalStore(subscribe, publishedComparison, publishedComparison);
return current && current.projectId === projectId && sameSet(current.targetAssessmentIds, openAssessmentIds) ? current : undefined;
}
/** Test support. */
export function resetTimeslotComparisonRequestsForTests(): void {
reports.clear(); timeslotRequests.clear(); baselineChoices.clear(); published = undefined; notify();
}
function comparable(group: ScanImportGroup): ComparableTimeslot {
return { id: group.id, date: group.fileDate, assessmentIds: [...group.assessmentIds].sort(), scanRunIds: [...group.scanRunIds].sort() };
}
const timeslotRequests = new Map<string, Promise<ComparableTimeslot[]>>();
/** Oldest first, the order in which a comparison reads A before B. Reloaded for every newly opened scope. */
function loadTimeslots(projectId: string, scope: string): Promise<ComparableTimeslot[]> {
const requestKey = `${projectId}\u0000${scope}`;
let request = timeslotRequests.get(requestKey);
if (!request) {
request = Promise.all([
api.listProjectImportFiles(projectId), api.listProjectAssessmentHistory(projectId), api.listProjectGpoBackupStatus(projectId)
]).then(([files, history, gpo]) => groupProjectImportFiles(files, history.assessments, history.scanRuns, gpo.records)
.filter(group => group.assessmentIds.length)
.map(comparable)
.sort((left, right) => Date.parse(left.date) - Date.parse(right.date) || left.id.localeCompare(right.id)));
timeslotRequests.set(requestKey, request);
while (timeslotRequests.size > 8) timeslotRequests.delete(timeslotRequests.keys().next().value!);
request.catch(() => timeslotRequests.delete(requestKey));
}
return request;
}
const reports = new Map<string, Promise<TimeslotComparisonReport>>();
function requestReport(projectId: string, baseline: ComparableTimeslot, target: ComparableTimeslot) {
const reportKey = JSON.stringify([projectId, baseline.assessmentIds, baseline.scanRunIds, target.assessmentIds, target.scanRunIds]);
let request = reports.get(reportKey);
if (!request) {
request = api.getTimeslotComparison<TimeslotComparisonReport>(projectId, baseline, target);
reports.set(reportKey, request);
while (reports.size > 8) reports.delete(reports.keys().next().value!);
request.catch(() => reports.delete(reportKey));
}
return request;
}
/**
* Timeslots of the open client and the explicit A/B report. B defaults to the timeslot open in Client
* Info, A to the timeslot directly before it; both stay selectable, A always before B. A report whose B is
* the open timeslot is published for the Work `Change` facet.
*/
export function useTimeslotComparison(projectId: string | undefined, openAssessmentIds: readonly string[]) {
const openKey = key(openAssessmentIds);
const [timeslots, setTimeslots] = useState<{ scope: string; items?: ComparableTimeslot[]; error?: string }>();
const [targetChoice, setTargetChoice] = useState<{ scope: string; id: string }>();
const [result, setResult] = useState<{ key: string; state: TimeslotComparisonState }>();
const scope = `${projectId}\u0000${openKey}`;
useSyncExternalStore(subscribe, version, version);
useEffect(() => {
if (!projectId) return;
let current = true;
loadTimeslots(projectId, openKey).then(items => { if (current) setTimeslots({ scope, items }); },
error => { if (current) setTimeslots({ scope, error: error instanceof Error ? error.message : 'Timeslots could not be loaded.' }); });
return () => { current = false; };
}, [scope]); // eslint-disable-line react-hooks/exhaustive-deps
const items = timeslots?.scope === scope ? timeslots.items : undefined;
const open = useMemo(() => items?.find(item => sameSet(item.assessmentIds, openAssessmentIds)), [items, openKey]); // eslint-disable-line react-hooks/exhaustive-deps
const target = items?.find(item => targetChoice?.scope === scope && item.id === targetChoice.id) ?? open ?? items?.[items.length - 1];
const earlier = items && target ? items.slice(0, items.indexOf(target)) : [];
const choiceKey = target ? JSON.stringify([projectId, target.id]) : '';
const baseline = earlier.find(item => item.id === baselineChoices.get(choiceKey)) ?? earlier[earlier.length - 1];
const requestKey = projectId && baseline && target ? JSON.stringify([projectId, baseline.id, target.id]) : '';
useEffect(() => {
if (!projectId || !baseline || !target) return;
let current = true;
requestReport(projectId, baseline, target).then(report => {
if (!current) return;
setResult({ key: requestKey, state: { status: 'ready', report } });
if (target === open) {
published = { projectId, targetAssessmentIds: [...target.assessmentIds], baseline, target, report };
notify();
}
}, error => { if (current) setResult({ key: requestKey, state: { status: 'error', message: error instanceof Error ? error.message : 'The comparison could not be loaded.' } }); });
return () => { current = false; };
}, [requestKey]); // eslint-disable-line react-hooks/exhaustive-deps
let state: TimeslotComparisonState;
if (timeslots?.scope === scope && timeslots.error) state = { status: 'error', message: timeslots.error };
else if (!items) state = { status: 'loading' };
else if (items.length < 2) state = { status: 'unavailable', reason: 'single-timeslot' };
else if (!baseline) state = { status: 'unavailable', reason: 'no-earlier-timeslot' };
else state = result?.key === requestKey ? result.state : { status: 'loading' };
return {
state, timeslots: items ?? [], baseline, target, earlier,
/** True while B is the timeslot open in Client Info, so Work shows the same records. */
targetIsOpen: Boolean(target && target === open),
selectBaseline: (id: string) => { if (choiceKey) { baselineChoices.set(choiceKey, id); notify(); } },
selectTarget: (id: string) => setTargetChoice({ scope, id })
};
}

View File

@ -7,6 +7,8 @@ import { ClientSecurityPanel, type SecurityFindingGroup } from './ClientSecurity
import { managementCategoryTexts } from '../../reporting/management-report-texts';
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
import { ClientIsoControls, preloadClientComplianceOverview } from '../../compliance/ui/ClientIsoControls';
import { ComparisonTab } from '../comparison/ComparisonTab';
import { COMPARISON_CHANGE_FACET_ID } from '../comparison/comparison-work-facets';
import { BSI_PRACTICE_FACET_ID, BSI_REQUIREMENT_FACET_ID, ISO_CONTROL_FACET_ID, ISO_MAPPING_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets';
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
import { useAssessmentContext } from '../context-layer/use-assessment-context';
@ -1256,6 +1258,17 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setView('columns');
onOpenWork();
};
// Comparison Engine tab (user decision 2026-10-13): the open timeslot is B, an earlier timeslot A.
const comparisonContent = engines.comparison && app.backendStatus === 'connected' && app.targetProjectId
? <div className="dashboard-page client-command-center"><ComparisonTab projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds}
onOpenChange={change => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) app.setActiveSheet(targetSheet.id);
// A resolved finding is Passed in timeslot B; every other change keeps the standard Deviation status.
setStatusFilter(change === 'resolved' ? 'all' : 'deviation');
setFacetFilter(COMPARISON_CHANGE_FACET_ID, { values: [change] });
}, false)}/></div>
: null;
// Compliance Engine tab: one tile per framework; a Results window opens a control or theme in Work.
const complianceContent = showCompliance ? <ClientIsoControls projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds}
backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => {
@ -1607,6 +1620,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
content: <ClientWorkPackagesPanel key={reportScopeKey}/> }] : []),
...(defenderContent ? [{ id: 'defender', label: 'Microsoft Defender for IoT', content: defenderContent, lazy: true }] : []),
...(complianceContent ? [{ id: 'compliance', label: 'Compliance', content: complianceContent, lazy: true }] : []),
...(comparisonContent ? [{ id: 'compare', label: 'Compare', content: comparisonContent, lazy: true }] : []),
...(showAiOverview ? [{ id: 'ai', label: 'AI Overview', content: <AiOverviewPanel controller={aiOverview} />, lazy: true }] : [])]} />
{scoringOpen && <ScoringMethodology sources={scoringOpen === 'security' ? securityAnalysis.sourceScores : sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(null)} />}</>
);

View File

@ -20,7 +20,9 @@ const nodes = [
{ id: 'compliance', engine: true, name: 'Compliance Engine', caption: 'Regulatory control mapping · optional', description: 'Maps findings and GPO baseline comparisons to regulatory controls; ISO/IEC 27001/27002 is the first framework. Reads Core results through the read-only Core port and writes only its own tables. Proposals never change verdicts, scores or client records.' },
{ id: 'compliance-tables', name: 'Compliance tables', caption: 'Mappings · decisions · batches', description: 'Mapping runs, accept/reject decisions and batches in three engine-owned tables of the canonical PostgreSQL. Written through a separate small pool; engine writes never advance the Core revision.' },
{ id: 'retrieval', name: 'Retrieval & model', caption: 'Qdrant index · LiteLLM', description: 'The rebuildable Qdrant index of the uploaded control corpus and the configured LiteLLM model. They receive privacy-safe finding text and corpus evidence, never assets or customer values.' },
{ id: 'compliance-ui', name: 'ISO mapping in the UI', caption: 'Finding Reader · Result DB · Data Hub', description: 'Control proposals, accept/reject decisions, filtered batches and the ISO corpus upload appear through existing UI extension points. A proposal indicates relevance, not proof of compliance.' }
{ id: 'compliance-ui', name: 'ISO mapping in the UI', caption: 'Finding Reader · Result DB · Data Hub', description: 'Control proposals, accept/reject decisions, filtered batches and the ISO corpus upload appear through existing UI extension points. A proposal indicates relevance, not proof of compliance.' },
{ id: 'comparison', engine: true, name: 'Comparison Engine', caption: 'Timeslot comparison · optional', description: 'Compares two timeslots A and B of one client: verified resolved, still open, unverified, new and regressed findings, neutral GPO value changes and a score delta over sources comparable in both. Reads both timeslots as Work does through the read-only Core port; stores nothing yet. Verdicts, scores, reviews and Result DB stay unchanged.' },
{ id: 'comparison-ui', name: 'Comparison in the UI', caption: 'Client Info · Work · Finding Reader', description: 'The Client Info Compare tab, the Work Change facet and the Finding Reader Compare section appear through existing UI extension points. They never override technical results or client records.' }
];
const relationships = [
{ from: 'input', to: 'core', label: 'Import', detail: 'Import and evaluation', path: 'M240 340 H310' },
@ -39,6 +41,8 @@ const relationships = [
{ from: 'port', to: 'compliance', label: 'Read only', detail: 'Single Result DB row by client, filtered Result DB pages, client, assessment and scan-run lists', path: 'M710 620 V750' },
{ from: 'compliance', to: 'compliance-tables', label: 'Own data', detail: 'Mapping runs, decisions and batches; never Core tables', path: 'M600 800 H530', bidirectional: true },
{ from: 'compliance', to: 'retrieval', label: 'Retrieval', detail: 'Privacy-safe query, retrieved control evidence and model proposals', path: 'M820 800 H890', bidirectional: true },
{ from: 'port', to: 'comparison', label: 'Read only', detail: 'Scanner runs and rolled-up GPO verdicts of both selected timeslots, read as Work reads them', path: 'M810 620 V710 H1160 V1050 H1180' },
{ from: 'comparison', to: 'comparison-ui', label: 'Changes', detail: 'Change categories and score delta through existing UI extension points', path: 'M1180 1080 H1110' },
{ from: 'compliance', to: 'compliance-ui', label: 'Proposals', detail: 'Grounded control proposals and decisions through existing UI extension points', path: 'M710 850 V900 H130 V850' }
];
const nodeName = (id: string) => nodes.find(node => node.id === id)!.name;
@ -53,7 +57,7 @@ export function EngineDataFlow() {
<header className="db-engine-flow__heading"><h3>Engines & relationships</h3><span>Solid: implemented · Dashed: planned</span></header>
<div className="db-engine-flow__scroll" role="region" aria-label="Interactive engine diagram" tabIndex={0}>
<div className="db-engine-flow__map">
<svg className="db-engine-flow__edges" width="1450" height="940" aria-hidden="true">
<svg className="db-engine-flow__edges" width="1450" height="1130" aria-hidden="true">
<defs><marker id={markerId} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 Z" /></marker></defs>
{relationships.map(edge => <path key={`${edge.from}-${edge.to}`} d={edge.path} markerEnd={`url(#${markerId})`} markerStart={edge.bidirectional ? `url(#${markerId})` : undefined}
className={`${edge.planned ? 'is-planned' : ''} ${connections.includes(edge) ? 'is-related' : ''}`} />)}

View File

@ -45,6 +45,7 @@ import type { BackendStatus } from '../../app/app-state';
import { RegulatoryMappingDialog } from '../../compliance/ui/RegulatoryMappingPanel';
import { FindingIsoControls } from '../../compliance/ui/FindingIsoControls';
import { FindingBsiRequirements } from '../../compliance/ui/FindingBsiRequirements';
import { FindingComparison } from '../comparison/FindingComparison';
import { regulatoryMappingResultId, useRegulatoryMapping } from '../../compliance/ui/use-regulatory-mapping';
interface Props {
@ -572,6 +573,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
&& <PolicyBaselineComparison comparisons={record.policyBaselineComparisons!} search={search}/>}
{/* Context layer (Phase 13E): for every finding after the technical evidence, only while switched on; it never changes the result above. */}
<FindingContext record={record} isPolicySetting={resolvedToolId === 'group-policy'}/>
{/* Comparison Engine: this finding against timeslot A, only while a comparison of the open timeslot is loaded. */}
<FindingComparison record={record}/>
{summary.map(field => <div className={/^remediation(?: guidance| recommendation)?$/.test(normalizeLabel(field.label)) ? 'reader-remediation' : undefined} key={`${field.index}-${field.label}`}><h4>{highlightText(field.label, search)}</h4>{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'id'
? <p><code className="mono">{highlightText(field.value, search)}</code></p>
: resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'

View File

@ -7,6 +7,7 @@ import { DynamicFilterControls } from './DynamicFilterControls';
import { selectWorkFacets } from './work-facets';
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
import { COMPLIANCE_FACET_IDS, useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
import { useComparisonWorkFacets } from '../comparison/comparison-work-facets';
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
import { useWorkPackages } from '../../remediation/use-work-packages';
import { WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine';
@ -98,11 +99,12 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps
const remediation = useWorkPackages();
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
const complianceFacets = useComplianceWorkFacets();
const comparisonFacets = useComparisonWorkFacets();
const countedFacets = useMemo(
() => measureNavigationStep('filter-counts', () => selectWorkFacets(activeSheet, {
activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters, workPackages, complianceFacets
activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters, workPackages, complianceFacets, comparisonFacets
})),
[actionOnly, activeProfile, activeSheet, complianceFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter]
[actionOnly, activeProfile, activeSheet, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter]
);
const visibleFacets = useMemo(
() => countedFacets.filter(facet => !hiddenFacetLabels.includes(facet.label)),

View File

@ -14,6 +14,7 @@ import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache'
import { withContextClusterFacet, type ContextClusterIndex } from '../context-layer/context-cluster-facet';
import { withBusinessImpactFacet } from './business-impact-controls';
import { withComplianceFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets';
import { withComparisonFacets, type ComparisonWorkFacetIndex } from '../comparison/comparison-work-facets';
import { readEnginePreferences } from '../../app/engine-preferences';
import { withWorkPackageFacet, type WorkPackageIndex } from '../../remediation/work-package-index';
@ -33,6 +34,8 @@ export interface RecordFilterOptions {
workPackages?: WorkPackageIndex;
/** Compliance Engine ISO control and ISO theme facets; absent while the engine is off. */
complianceFacets?: ComplianceWorkFacetIndex;
/** Comparison Engine `Change` facet; absent while the engine is off or no comparison is loaded. */
comparisonFacets?: ComparisonWorkFacetIndex;
}
const cachedRecords = createSheetDerivationCache<FindingRecord[]>();
@ -42,7 +45,7 @@ export function recordFilterKeys(options: RecordFilterOptions): readonly unknown
return [options.activeProfile, options.dataset, options.reviews, options.search,
options.sortColumn, options.sortDirection, options.statusFilter, options.actionOnly,
options.pathFindingIds?.length ? options.pathFindingIds : undefined,
JSON.stringify(options.facetFilters ?? {}), options.contextClusters, options.workPackages, options.complianceFacets, readEnginePreferences()];
JSON.stringify(options.facetFilters ?? {}), options.contextClusters, options.workPackages, options.complianceFacets, options.comparisonFacets, readEnginePreferences()];
}
export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: RecordFilterOptions): FindingRecord[] {
@ -58,9 +61,9 @@ function deriveFilteredRecords(sheet: DatasetSheet, options: RecordFilterOptions
const normalizedSearch = options.search.trim().toLocaleLowerCase('en-US');
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
const facets = withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
const facets = withComparisonFacets(withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets);
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets), options.comparisonFacets);
const pathFindingIds = options.pathFindingIds?.length ? new Set(options.pathFindingIds) : undefined;
const filtered = sheet.analysis.records.filter(record => {
if (pathFindingIds && !pathFindingIds.has(record.id)) return false;

View File

@ -5,6 +5,7 @@ import { useContextClusterIndex } from '../context-layer/use-context-cluster-ind
import { useWorkPackages } from '../../remediation/use-work-packages';
import { useRelatedFindings } from '../related-findings/RelatedFindings';
import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
import { useComparisonWorkFacets } from '../comparison/comparison-work-facets';
type App = ReturnType<typeof useApp>;
function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: string): [App['activeSheet'] | undefined, RecordFilterOptions] {
@ -25,6 +26,7 @@ function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: str
const remediation = useWorkPackages();
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
const complianceFacets = useComplianceWorkFacets();
const comparisonFacets = useComparisonWorkFacets();
const effectiveStatusFilter = statusFilterOverride ?? statusFilter;
const options = useMemo(() => ({
activeProfile: activeProfile!,
@ -37,8 +39,8 @@ function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: str
pathFindingIds,
actionOnly,
reviews,
contextClusters, workPackages, complianceFacets
} as RecordFilterOptions), [activeProfile, actionOnly, complianceFacets, contextClusters, workPackages, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]);
contextClusters, workPackages, complianceFacets, comparisonFacets
} as RecordFilterOptions), [activeProfile, actionOnly, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]);
return [activeSheet, options];
}

View File

@ -12,6 +12,7 @@ import { withBusinessImpactFacet } from './business-impact-controls';
import { withWorkPackageFacet, workPackageCode } from '../../remediation/work-package-index';
import { comparePublishedClusters, numberedClusterLabel } from '../sidebar/context-cluster-display';
import { withComplianceFacets } from '../../compliance/ui/compliance-work-facets';
import { withComparisonFacets } from '../comparison/comparison-work-facets';
export type WorkFacetOptions = Omit<RecordFilterOptions, 'sortColumn' | 'sortDirection'>;
const cache = createSheetDerivationCache<DynamicFacet[]>();
@ -25,9 +26,9 @@ export function selectWorkFacets(sheet: DatasetSheet | undefined, options: WorkF
return cache(sheet, recordFilterKeys(query), () => {
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
const facets = withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
const facets = withComparisonFacets(withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets);
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets), options.comparisonFacets);
const records = filterAndSortRecords(sheet, { ...query, statusFilter: 'all', facetFilters: emptyFilters });
// The engine retains its complete vocabulary; the Work control offers only reachable clusters.
const selectableFacets = facets.map(facet => facet.derivedKind === 'context-cluster'

View File

@ -11,6 +11,7 @@ import { selectWorkFacets } from '../search-filter/work-facets';
import type { ContextClusterIndex } from '../context-layer/context-cluster-facet';
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
import { useComplianceWorkFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets';
import { useComparisonWorkFacets, type ComparisonWorkFacetIndex } from '../comparison/comparison-work-facets';
import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls';
import { useEnginePreferences } from '../../app/engine-preferences';
import { useWorkPackages } from '../../remediation/use-work-packages';
@ -32,6 +33,7 @@ interface SecurityDomainOptions {
contextClusters?: ContextClusterIndex;
workPackages?: WorkPackageIndex;
complianceFacets?: ComplianceWorkFacetIndex;
comparisonFacets?: ComparisonWorkFacetIndex;
}
export interface SidebarSecurityDomain {
@ -47,11 +49,11 @@ export interface SidebarSecurityDomainSummary {
/** Uses the exact shared Client Info facet, ordering and self-excluding counts. */
export function summarizeSecurityDomains(options: SecurityDomainOptions): SidebarSecurityDomainSummary | undefined {
const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets } = options;
const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets } = options;
if (!sheet) return undefined;
const facets = selectWorkFacets(sheet, {
dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets
dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets
});
const domainFacet = facets.find(facet => facet.id === SECURITY_DOMAIN_FACET_ID);
if (!domainFacet) return undefined;
@ -86,15 +88,16 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
const remediation = useWorkPackages();
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
const complianceFacets = useComplianceWorkFacets();
const comparisonFacets = useComparisonWorkFacets();
const engines = useEnginePreferences();
const impactCounts = useMemo(() => {
if (!engines.report || !engines.context || !contextClusters) return undefined;
const facet = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter: 'deviation', facetFilters,
pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets }).find(item => item.id === BUSINESS_IMPACT_FACET_ID);
pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets }).find(item => item.id === BUSINESS_IMPACT_FACET_ID);
if (!facet) return undefined;
const counts = new Map(facet.values.map(value => [value.value, value.count]));
return businessImpactControls.map(theme => ({ ...theme, count: counts.get(theme.id) ?? 0 }));
}, [engines.report, engines.context, complianceFacets, contextClusters, workPackages, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]);
}, [engines.report, engines.context, complianceFacets, comparisonFacets, contextClusters, workPackages, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]);
const summary = useMemo(() => measureNavigationStep('security-domain-summary', () => summarizeSecurityDomains({
sheet,
dataset,
@ -105,8 +108,8 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
pathFindingIds,
search,
actionOnly,
contextClusters, workPackages, complianceFacets
})), [actionOnly, activeProfile, complianceFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]);
contextClusters, workPackages, complianceFacets, comparisonFacets
})), [actionOnly, activeProfile, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]);
if (!summary) return null;
const facets = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter, facetFilters,

View File

@ -1956,7 +1956,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-engine-flow__heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
.db-engine-flow__heading h3 { margin: 0; color: var(--heading); font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.db-engine-flow__heading > span { color: var(--muted); font-size: var(--type-table-body-size); }
.db-engine-flow__map { position: relative; width: 1450px; height: 940px; margin: 0 auto; }
.db-engine-flow__map { position: relative; width: 1450px; height: 1130px; margin: 0 auto; }
.db-engine-flow__edges { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.db-engine-flow__edges > path { fill: none; stroke: var(--line-strong); stroke-width: 2; }
.db-engine-flow__edges > path.is-related { stroke: var(--marking-color); }
@ -1987,6 +1987,8 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-engine-flow__node[data-node="remediation"] { left: 1180px; top: 290px; }
.db-engine-flow__node[data-node="remediation-tables"] { left: 1180px; top: 60px; }
.db-engine-flow__node[data-node="remediation-ui"] { left: 1180px; top: 750px; }
.db-engine-flow__node[data-node="comparison"] { left: 1180px; top: 1000px; }
.db-engine-flow__node[data-node="comparison-ui"] { left: 890px; top: 1000px; }
.db-engine-flow__edge-label[data-edge="input-core"] { left: 275px; top: 312px; }
.db-engine-flow__edge-label[data-edge="core-database"] { left: 420px; top: 455px; }
.db-engine-flow__edge-label[data-edge="database-port"] { left: 565px; top: 540px; }
@ -2004,6 +2006,8 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-engine-flow__edge-label[data-edge="context-remediation"] { left: 1000px; top: 250px; }
.db-engine-flow__edge-label[data-edge="remediation-remediation-tables"] { left: 1290px; top: 225px; }
.db-engine-flow__edge-label[data-edge="remediation-remediation-ui"] { left: 1430px; top: 570px; }
.db-engine-flow__edge-label[data-edge="port-comparison"] { left: 1160px; top: 930px; }
.db-engine-flow__edge-label[data-edge="comparison-comparison-ui"] { left: 1145px; top: 1060px; }
.db-engine-flow__inspector { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-5); padding: var(--tile-inset); background: var(--tile-surface); border: var(--border-hair) solid var(--tile-border); }
.db-engine-flow__inspector h4 { margin: 0 0 var(--space-2); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.db-engine-flow__inspector p { margin: 0; font-size: var(--type-body-size); line-height: 1.5; }

View File

@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
describe('ASSESSOR version in the brand header', () => {
it('shows the centrally maintained product version and opens About from the brand', () => {
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.25.0');
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.26.0');
expect(app).toContain("'db-structure': 'Data Flow'");
expect(app).toContain("['db-structure', 'Data Flow']");
expect(app).not.toContain("'DB Structure'");

View File

@ -54,28 +54,28 @@ const allSources = () => [...sourceFiles(join(root, 'src')), ...sourceFiles(join
* Pinned Core -> Engine dependencies: the documented extension points (
* `Context Cluster` Work facet, Finding Reader `Context` section, Pathfinder and sidebar cluster
* readers; Finding Reader ISO mapping, Result DB ISO batch and `Export JSON`, Data Hub ISO corpus upload,
* the Client Info `AI Overview` tab and the Work `ISO control`/`ISO theme` facets), the
* the Client Info `AI Overview` tab and the Work `ISO control`/`ISO theme` facets; the Client Info `Compare` tab, the Work `Change` facet and the Finding Reader `Compare` section), the
* type-only API contract and client, and the server wiring of the engines.
*/
const EXTENSION_POINTS: Record<string, { engine: string | readonly string[]; typeOnly?: boolean }> = {
'src/features/related-findings/RelatedFindings.tsx': { engine: 'Remediation Engine' },
'src/features/search-filter/business-impact-controls.ts': { engine: ['Report Engine', 'Context Engine'] },
'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine', 'Compliance Engine'] },
'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/dashboard/ClientWorkPackagesPanel.tsx': { engine: ['Remediation Engine', 'Context Engine'] },
'src/api/assessor-api-contract.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'], typeOnly: true },
'src/api/assessor-api-client.ts': { engine: 'Compliance Engine', typeOnly: true },
'src/features/finding-reader/FindingReader.tsx': { engine: ['Remediation Engine', 'Context Engine', 'Compliance Engine'] },
'src/features/finding-reader/FindingReader.tsx': { engine: ['Remediation Engine', 'Context Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/results-browser/ResultsBrowser.tsx': { engine: 'Compliance Engine' },
'src/features/project-import/DataHub.tsx': { engine: 'Compliance Engine' },
'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/finding-reader/FindingContext.tsx': { engine: 'Context Engine' },
'src/features/mitre-graph/graph-filter-bridge.ts': { engine: 'Context Engine' },
'src/features/mitre-graph/ObsidianGraphView.tsx': { engine: 'Context Engine' },
'src/features/search-filter/filter-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/search-filter/filter-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'server/assessor-http-server.ts': { engine: 'any' },
'server/index.ts': { engine: ['Report Engine', 'Remediation Engine', 'Compliance Engine'] }
};

View File

@ -0,0 +1,116 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { ImportFileRecord, ScanRun, Assessment } from '../../src/domain/canonical-model';
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
import { ComparisonTab } from '../../src/features/comparison/ComparisonTab';
import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison';
// Client Info `Compare` tab content (mockup A): A/B selection with B as the open timeslot, score with a
// labelled delta, change facts that open Work and a source table. Synthetic data only.
const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'dummy-project', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt });
const run = (id: string, assessmentId: string, at: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: at });
const file = (runId: string, at: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1,
importedAt: at, lastModifiedAt: at, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord;
const dates = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z', c: '2026-03-10T10:00:00.000Z' };
const report: TimeslotComparisonReport = {
version: '1.0.0', projectId: 'dummy-project', baselineRunIds: ['run-b'], targetRunIds: ['run-c'],
sources: [{ sourceId: 'cis-cat', state: 'compared', reasons: [] }, { sourceId: 'maester', state: 'not-comparable', reasons: ['baseline-source-missing'] }],
changes: [
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-1'], counterpartIds: ['c-1'], reasons: [] },
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-3'], counterpartIds: [], reasons: [] },
{ sourceId: 'maester', change: 'not-comparable', side: 'target', recordIds: ['m-1'], counterpartIds: [], reasons: ['baseline-source-missing'] }
],
summary: { resolved: 1, unchanged: 0, 'value-changed': 0, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 1, targetNotComparable: 1 },
score: {
baselineScore: 52.4, targetScore: 55.2, change: 2.8,
sources: [
{ sourceId: 'cis-cat', weight: 1, method: 'paired-scans', reasons: [], baselineScore: 52.4, targetScore: 55.2, change: 2.8 },
{ sourceId: 'maester', weight: 1, method: 'paired-scans', reasons: ['baseline-source-missing'] }
],
excludedSources: [{ sourceId: 'maester', reasons: ['baseline-source-missing'] }]
},
scanner: {} as TimeslotComparisonReport['scanner']
};
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
resetTimeslotComparisonRequestsForTests();
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a), file('run-b', dates.b), file('run-c', dates.c)]);
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
assessments: [assessment('assessment-a', dates.a), assessment('assessment-b', dates.b), assessment('assessment-c', dates.c)],
scanRuns: [run('run-a', 'assessment-a', dates.a), run('run-b', 'assessment-b', dates.b), run('run-c', 'assessment-c', dates.c)]
} as never);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.restoreAllMocks();
});
const settle = () => act(async () => { for (let index = 0; index < 5; index++) await Promise.resolve(); });
describe('Comparison Engine Compare tab', () => {
it('compares the open timeslot with the one before it and opens Work from a change', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
const onOpenChange = vi.fn();
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']} onOpenChange={onOpenChange}/>));
await settle();
expect(request).toHaveBeenCalledWith('dummy-project',
expect.objectContaining({ assessmentIds: ['assessment-b'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] }));
const selects = [...host.querySelectorAll('select')];
expect(selects.map(select => select.value)).toEqual(['timeslot-2026-02-10', 'timeslot-2026-03-10']);
expect([...host.querySelectorAll('.comparison-score dd')].map(item => item.textContent)).toEqual(['52', '55']);
expect(host.querySelector('.comparison-tile .comparison-delta')?.textContent).toBe('▲ +3 Better');
const facts = [...host.querySelectorAll<HTMLButtonElement>('.comparison-fact')];
const fact = (label: string) => facts.find(item => item.querySelector('.comparison-fact__label')?.textContent === label)!;
expect(fact('Regressed').querySelector('.comparison-fact__value')?.className).toContain('is-worse');
expect(fact('Not comparable').textContent).toContain('1');
expect(fact('New').disabled).toBe(true);
await act(async () => fact('Resolved').click());
expect(onOpenChange).toHaveBeenCalledWith('resolved', expect.objectContaining({ id: 'timeslot-2026-02-10' }), expect.objectContaining({ id: 'timeslot-2026-03-10' }));
const maester = [...host.querySelectorAll('.comparison-table tbody tr')].find(row => row.textContent?.includes('Maester'));
expect(maester?.textContent).toContain('Not in timeslot A');
});
it('explains that the oldest timeslot has nothing before it', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison');
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-a']}/>));
await settle();
expect(host.querySelectorAll('select')[1].value).toBe('timeslot-2026-01-10');
expect(host.textContent).toContain('no earlier timeslot');
expect(request).not.toHaveBeenCalled();
});
it('offers only earlier timeslots as A and requests the new pair on change', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
const [baseline] = [...host.querySelectorAll('select')];
expect([...baseline.options].map(option => option.value)).toEqual(['timeslot-2026-01-10', 'timeslot-2026-02-10']);
await act(async () => { baseline.value = 'timeslot-2026-01-10'; baseline.dispatchEvent(new Event('change', { bubbles: true })); });
await settle();
expect(request).toHaveBeenLastCalledWith('dummy-project',
expect.objectContaining({ assessmentIds: ['assessment-a'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] }));
});
it('explains a client with a single timeslot instead of comparing', async () => {
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a)]);
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
assessments: [assessment('assessment-a', dates.a)], scanRuns: [run('run-a', 'assessment-a', dates.a)]
} as never);
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison');
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-a']}/>));
await settle();
expect(host.textContent).toContain('only one timeslot');
expect(request).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding';
import type { DynamicFacet } from '../../src/features/search-filter/dynamic-facets';
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
import {
changesWithWorkRecords,
comparisonWorkFacetIndex,
COMPARISON_CHANGE_FACET_ID,
withComparisonFacets
} from '../../src/features/comparison/comparison-work-facets';
import type { PublishedComparison } from '../../src/features/comparison/use-timeslot-comparison';
// The Work `Change` facet maps the comparison onto Work records of timeslot B. Synthetic records only.
const gpoRecordId = (identity: string) =>
`gpo-work-setting:${encodeURIComponent(JSON.stringify(['dummy-project', 'aggregation-b', 'computer', identity]))}`;
const record = (id: string, findingOccurrenceId?: string) => ({ id, ...(findingOccurrenceId ? { findingOccurrenceId } : {}) }) as FindingRecord;
const report = {
changes: [
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['a-1'], counterpartIds: ['b-1'], reasons: [] },
{ sourceId: 'cis-cat', change: 'unverified', side: 'baseline', recordIds: ['a-2'], counterpartIds: [], reasons: [] },
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['b-3'], counterpartIds: [], reasons: [] },
{ sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['ga-1'], counterpartIds: ['gb-1'],
identityKey: JSON.stringify(['computer', 'MinimumPasswordLength']), reasons: [] },
{ sourceId: 'group-policy', change: 'new', side: 'target', recordIds: ['gb-2'], counterpartIds: [],
identityKey: JSON.stringify(['computer', 'SeEnableDelegationPrivilege']), reasons: [] }
]
} as unknown as TimeslotComparisonReport;
const timeslot = (id: string, date: string) => ({ id, date, assessmentIds: [id], scanRunIds: [] });
const published: PublishedComparison = {
projectId: 'dummy-project', targetAssessmentIds: ['b'], baseline: timeslot('a', '2026-01-10'), target: timeslot('b', '2026-02-10'), report
};
describe('Comparison Engine Work Change facet', () => {
it('assigns each timeslot B record its change and leaves unrelated records without one', () => {
const index = comparisonWorkFacetIndex(published);
expect(index.changeOf(record('work-b-1', 'b-1'))).toBe('resolved');
expect(index.changeOf(record('work-b-3', 'b-3'))).toBe('regressed');
expect(index.changeOf(record(gpoRecordId('MinimumPasswordLength')))).toBe('value-changed');
expect(index.changeOf(record(gpoRecordId('SeEnableDelegationPrivilege')))).toBe('new');
expect(index.changeOf(record('work-other', 'b-9'))).toBeUndefined();
expect(index.facets[0].valuesFor?.(record('work-b-1', 'b-1'))).toEqual(['resolved']);
expect(index.facets[0]).toMatchObject({ id: COMPARISON_CHANGE_FACET_ID, label: 'Change' });
});
it('offers only changes with a Work record in timeslot B', () => {
expect([...changesWithWorkRecords(report)].sort()).toEqual(['new', 'regressed', 'resolved', 'value-changed']);
});
it('places the facet directly before the permanent final Source: Scan control', () => {
const facet = (id: string) => ({ id, label: id, source: 'derived', kind: 'category', values: [] }) as unknown as DynamicFacet;
const facets = withComparisonFacets([facet('status'), facet('provenance:source-scan')], comparisonWorkFacetIndex(published));
expect(facets.map(item => item.id)).toEqual(['status', COMPARISON_CHANGE_FACET_ID, 'provenance:source-scan']);
expect(withComparisonFacets([facet('status')], undefined).map(item => item.id)).toEqual(['status']);
});
});

View File

@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { Assessment, ImportFileRecord, ScanRun } from '../../src/domain/canonical-model';
import type { FindingRecord } from '../../src/domain/finding';
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
import { ComparisonTab } from '../../src/features/comparison/ComparisonTab';
import { FindingComparison } from '../../src/features/comparison/FindingComparison';
import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
// Finding Reader `Compare` section: present once the Compare tab loaded a comparison of the open timeslot B,
// with both states and the GPO values side by side; absent for an unrelated record or with the engine off.
vi.mock('../../src/app/AppProvider', () => ({
useOptionalApp: () => ({ targetProjectId: 'dummy-project', activeAssessmentIds: ['assessment-b'] })
}));
const at = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z' };
const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'dummy-project', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt });
const run = (id: string, assessmentId: string, time: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: time });
const file = (runId: string, time: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1,
importedAt: time, lastModifiedAt: time, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord;
const gpoRecordId = (identity: string) => `gpo-work-setting:${encodeURIComponent(JSON.stringify(['dummy-project', 'aggregation-b', 'computer', identity]))}`;
const record = (id: string, findingOccurrenceId?: string) => ({ id, fields: [], ...(findingOccurrenceId ? { findingOccurrenceId } : {}) }) as unknown as FindingRecord;
const report = {
version: '1.0.0', projectId: 'dummy-project', baselineRunIds: ['run-a'], targetRunIds: ['run-b'], sources: [],
changes: [
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['a-1'], counterpartIds: ['b-1'], reasons: [] },
{ sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['ga-1'], counterpartIds: ['gb-1'],
identityKey: JSON.stringify(['computer', 'MinimumPasswordLength']), reasons: ['same-setting-current-deviation'],
values: { baseline: ['{"kind":"number","value":7}'], target: ['{"kind":"number","value":10}'] } }
],
summary: { resolved: 1, unchanged: 0, 'value-changed': 1, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 0, targetNotComparable: 0 },
score: { sources: [], excludedSources: [] }, scanner: {}
} as unknown as TimeslotComparisonReport;
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
localStorage.clear(); resetEnginePreferencesForTests(); resetTimeslotComparisonRequestsForTests();
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', at.a), file('run-b', at.b)]);
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
assessments: [assessment('assessment-a', at.a), assessment('assessment-b', at.b)],
scanRuns: [run('run-a', 'assessment-a', at.a), run('run-b', 'assessment-b', at.b)]
} as never);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount()); host.remove();
vi.restoreAllMocks(); resetEnginePreferencesForTests();
});
const settle = () => act(async () => { for (let index = 0; index < 5; index++) await Promise.resolve(); });
it('shows the comparison of a finding and of a GPO setting after the Compare tab loaded it', async () => {
await act(async () => root.render(<>
<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-b']}/>
<div data-testid="scanner"><FindingComparison record={record('work-b-1', 'b-1')}/></div>
<div data-testid="gpo"><FindingComparison record={record(gpoRecordId('MinimumPasswordLength'))}/></div>
<div data-testid="other"><FindingComparison record={record('work-b-9', 'b-9')}/></div>
</>));
await settle();
const section = (id: string) => host.querySelector(`[data-testid="${id}"] .finding-comparison`);
expect(section('scanner')?.hasAttribute('open')).toBe(false);
expect(section('scanner')?.textContent).toContain('Resolved');
expect(section('scanner')?.querySelector('tbody tr')?.textContent).toBe('ResultDeviationPassed');
const gpo = section('gpo')!;
expect(gpo.textContent).toContain('Value changed');
expect(gpo.textContent).toContain('Still a deviation');
const valueRow = [...gpo.querySelectorAll('tbody tr')].find(row => row.textContent?.startsWith('Configured value'));
expect(valueRow?.querySelectorAll('td')[1].textContent).toContain('7');
expect(valueRow?.querySelectorAll('td')[2].textContent).toContain('10');
expect(section('other')).toBeNull();
});
it('renders nothing while the Comparison Engine is off', async () => {
setEngineEnabled('comparison', false);
await act(async () => root.render(<>
<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-b']}/>
<div data-testid="scanner"><FindingComparison record={record('work-b-1', 'b-1')}/></div>
</>));
await settle();
expect(host.querySelector('.finding-comparison')).toBeNull();
});

View File

@ -38,6 +38,10 @@ describe('GPO timeslot comparison', () => {
);
const byIdentity = Object.fromEntries(result.decisions.map(item => [item.identity, item]));
expect(byIdentity.MinimumPasswordLength).toMatchObject({ state: 'confirmed-open', valueChanged: true, targetResultIds: ['b-1'] });
// Both retained values travel as Work displays them, for the Finding Reader's side-by-side reading.
expect(byIdentity.MinimumPasswordLength).toMatchObject({
baselineValues: ['{"kind":"number","value":7}'], targetValues: ['{"kind":"number","value":10}'] });
expect(byIdentity.PasswordComplexity.targetValues).toEqual([]);
expect(byIdentity.LockoutBadCount).toMatchObject({ state: 'confirmed-open', valueChanged: false });
expect(byIdentity.NoLMHash).toMatchObject({ state: 'verified-remediated', reasons: ['same-setting-explicit-pass'] });
// A setting missing from the newer backup set is not proof of a removed configuration.

View File

@ -25,11 +25,11 @@ it.each(['dark', 'light'])('opens engine selection, locks Core and restores keyb
await act(async () => trigger.click());
const dialog = document.querySelector('[role="dialog"]')!;
const checks = [...dialog.querySelectorAll<HTMLInputElement>('input')];
expect(checks.map(input => input.checked)).toEqual([true, true, true, true, true]);
expect(checks.map(input => input.disabled)).toEqual([true, false, false, false, false]);
await act(async () => { checks[0].click(); checks[1].click(); checks[4].click(); });
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: false });
checks[4].focus();
expect(checks.map(input => input.checked)).toEqual([true, true, true, true, true, true]);
expect(checks.map(input => input.disabled)).toEqual([true, false, false, false, false, false]);
await act(async () => { checks[0].click(); checks[1].click(); checks[4].click(); checks[5].click(); });
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: false, comparison: false });
checks[5].focus();
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })));
expect(document.activeElement).toBe(dialog.querySelector('button'));
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
@ -43,22 +43,22 @@ it.each(['dark', 'light'])('opens engine selection, locks Core and restores keyb
it('persists independent choices, ignores the obsolete Context preference and cannot disable Core', () => {
localStorage.setItem('ASSESSOR.contextLayer', 'off');
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true });
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
setEngineEnabled('report', false); setEngineEnabled('core', false);
resetEnginePreferencesForTests();
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: false, remediation: true, compliance: true });
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: false, remediation: true, compliance: true, comparison: true });
localStorage.setItem(ENGINE_PREFERENCES_KEY, '{"core":false,"context":false}');
resetEnginePreferencesForTests();
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: true });
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: true, comparison: true });
});
it('defaults safely when storage is malformed or blocked and still allows session toggles', () => {
localStorage.setItem(ENGINE_PREFERENCES_KEY, 'bad json');
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true });
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
resetEnginePreferencesForTests();
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked'); });
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); });
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true });
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
expect(() => setEngineEnabled('context', false)).not.toThrow();
expect(readEnginePreferences().context).toBe(false);
});

View File

@ -27,8 +27,8 @@ describe('DB Structure data flow', () => {
try {
await act(async () => root.render(createElement(EngineDataFlow)));
const engines = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node.is-engine')];
expect(engines.map(engine => engine.dataset.node)).toEqual(['core', 'context', 'report', 'remediation', 'compliance']);
const connectionCount: Record<string, number> = { core: 3, context: 4, report: 3, remediation: 4, compliance: 4 };
expect(engines.map(engine => engine.dataset.node)).toEqual(['core', 'context', 'report', 'remediation', 'compliance', 'comparison']);
const connectionCount: Record<string, number> = { core: 3, context: 4, report: 3, remediation: 4, compliance: 4, comparison: 2 };
// Remove the initial Core selection; identity frames must remain independently.
await act(async () => engines[0].click());
const highlighted = () => container.querySelectorAll('.db-engine-flow__edges > path.is-related');
@ -60,8 +60,8 @@ describe('DB Structure data flow', () => {
const container = document.createElement('div');
const root = createRoot(container);
await act(async () => root.render(createElement(EngineDataFlow)));
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(16);
expect(container.querySelectorAll('.db-engine-flow__edges > path')).toHaveLength(17);
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(18);
expect(container.querySelectorAll('.db-engine-flow__edges > path')).toHaveLength(19);
expect(container.querySelectorAll('.db-engine-flow__edges > path[marker-start]')).toHaveLength(4);
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(3);
expect(container.querySelector('.db-engine-flow__dictionary')?.hasAttribute('open')).toBe(false);
@ -98,12 +98,19 @@ describe('DB Structure data flow', () => {
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).toContain('assessor_remediation');
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).not.toContain('Planned');
await act(async () => remediation.click());
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(16);
// The Comparison Engine reads both timeslots through the port and reaches the UI only through extension points.
const comparison = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node')]
.find(button => button.textContent?.startsWith('Comparison Engine'))!;
await act(async () => comparison.click());
expect([...container.querySelectorAll('.db-engine-flow__inspector li strong')].map(item => item.textContent)).toEqual([
'Core read-only port → Comparison Engine', 'Comparison Engine → Comparison in the UI']);
await act(async () => comparison.click());
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(18);
const dictionary = container.querySelector<HTMLDetailsElement>('.db-engine-flow__dictionary')!;
await act(async () => dictionary.querySelector('summary')!.click());
expect(dictionary.open).toBe(true);
expect(dictionary.querySelector('summary')?.textContent).toBe('All relationships (17)');
expect(dictionary.querySelectorAll('tbody tr')).toHaveLength(17);
expect(dictionary.querySelector('summary')?.textContent).toBe('All relationships (19)');
expect(dictionary.querySelectorAll('tbody tr')).toHaveLength(19);
act(() => root.unmount());
});
@ -218,9 +225,9 @@ describe('DB Structure data flow', () => {
expect(container.querySelector('.db-flow__header p')).toBeNull();
expect(container.querySelector('.db-flow .db-flow__header h3')?.textContent).toBe('Data flow');
expect([...container.querySelectorAll('.db-engine-flow__node.is-engine strong')].map(item => item.textContent))
.toEqual(['Core Engine', 'Context Engine', 'Report Engine', 'Remediation Engine', 'Compliance Engine']);
.toEqual(['Core Engine', 'Context Engine', 'Report Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine']);
const relations = container.querySelector('table[aria-label="Directed engine relationships"]');
expect(relations?.querySelectorAll('tbody tr')).toHaveLength(17);
expect(relations?.querySelectorAll('tbody tr')).toHaveLength(19);
expect(relations?.textContent).toContain('Core read-only port');
expect(relations?.textContent).not.toContain('Planned');
expect(relations?.textContent).toContain('Versioned Context publication for the Management Report');
@ -228,8 +235,8 @@ describe('DB Structure data flow', () => {
container.querySelector<HTMLButtonElement>('.db-structure-intro-actions button')!.click();
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(container.querySelectorAll('.db-engine-flow__node.is-engine')).toHaveLength(5);
expect(container.querySelectorAll('.db-engine-flow tbody tr')).toHaveLength(17);
expect(container.querySelectorAll('.db-engine-flow__node.is-engine')).toHaveLength(6);
expect(container.querySelectorAll('.db-engine-flow tbody tr')).toHaveLength(19);
expect(container.querySelector('.db-structure-intro-actions button')?.textContent).toBe('Refresh schema');
expect(container.querySelector('.db-flow__lane.is-reference')?.textContent).toContain('Input to GPO step');
const chips = [...container.querySelectorAll<HTMLButtonElement>('.db-flow__table')];

View File

@ -60,7 +60,7 @@ describe('local ASSESSOR HTTP server', () => {
const health = await fetch(`${baseUrl}/api/v1/health`);
expect(await health.json()).toEqual({
status: 'ok', application: 'ASSESSOR', productVersion: '2.25.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
status: 'ok', application: 'ASSESSOR', productVersion: '2.26.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
});
expect(health.headers.get('access-control-allow-origin')).toBeNull();
});