fix(compliance): fit Compliance tab tiles to content; mapping refresh and hit cap
Compliance tab layout (user corrections 2026-10-12): - Overview row is only as tall as its taller tile; Mapping coverage is no longer forced square and both tiles share the row height. - Annex A and BSI Kernel footprint cells grow with the tile up to 64px (5:3, 14px numbers); each Annex A theme column is as wide as its six cells and the columns spread across the tile. BSI requirements fill an auto-fill grid of the same cell size, also in the Show all window. - Kernel footprint tile shows only the practice with the most findings. - BSI Grundschutz++ framework tile shows 4 to 10 practices, as many as its square fits (container queries on the inner width). Fixes from 2026-10-09: - Compliance tab signals changed coverage or mapping history so Work reloads its assignments; `Mapping failed` no longer opens Work without its filter value. - Hybrid ISO retrieval counts hits in rank order against the 8,000 character cap per control and always keeps the best hit, so large mappings no longer fail with regulatory_mapping_prompt_too_large. Governance (Core-Freigabe: Governance): AGENTS.md BSI tile rule updated to the content-fitted practice count. Engine code only otherwise.
This commit is contained in:
parent
d26b672e2c
commit
f4eb533e0e
|
|
@ -2685,6 +2685,8 @@ Nach Freigabe eines lokalen, nicht versionierten Mockups mit synthetischen Daten
|
||||||
- Nutzerauftrag 2026-10-08, spaeter am Tag: Zwei Schaltflaechen neben der Ueberschrift (`ISO 27001/27002`, `BSI Grundschutz++`, Client-Info-Tabknoepfe mit `aria-pressed`) schalten nur die Uebersichtszeile um; die Frameworkkacheln bleiben unveraendert, Standard ist ISO. Die BSI-Ansicht (Nutzerwahl) zeigt `Mapping coverage` mit fuenf Zustaenden (mit BSI-Anforderung, ISO-Kontrolle ohne BSI-Anforderung, zugeordnet ohne akzeptierte Kontrolle, noch nicht zugeordnet, fehlgeschlagen; die ersten beiden teilen `accepted`) und `Kernel footprint` mit allen 901 Kernel-Anforderungen als unbeschriftete Zellen je Praktik in Katalogreihenfolge. Die 629 Anforderungen, die die BSI-Zuordnung von keiner ISO-Kontrolle erreicht, tragen nur eine kleine neutrale Markierung, damit eine leere Zelle nicht als Luecke dieses Clients gelesen wird. Hover oder Fokus nennen ID, Praktik, Titel und ISO-Weg abgeleiteter Anforderungen und die Befundzahl; nur Anforderungen und Praktiken mit Treffern oeffnen Work (`BSI requirement`, `BSI practice`). Nutzerkorrekturen derselben Sitzung: Jede Anforderung ist ein Quadrat mit nur ihrer Nummer innerhalb der Praktik (zum Beispiel `10.5`; das Praefix nennt die Praktikueberschrift), auch ohne Befund oder ohne ISO-Erreichbarkeit (gestrichelter Rand). Die Kachel zeigt nur die zwei Praktiken mit den meisten Befunden (absteigend, Gleichstand in Katalogreihenfolge), damit die Quadrate lesbar bleiben; `Show all` oeffnet alle Praktiken mit groesseren Quadraten (14px-Datenrolle) im gemeinsamen Results-Fenster, ein Work-Sprung dort schliesst das Fenster. Ohne abgeleitete Anforderung nennt die Kachel das ausdruecklich. Im `Show all`-Fenster bleiben Detailzeile und Legende unten angeheftet, waehrend das Raster scrollt; die Detailzeile nennt fuer jede Anforderung den veroeffentlichten Titel, auch ohne Zuordnung oder ISO-Erreichbarkeit. Die Kontrolluebersicht liefert dafuer `bsiFootprint` (IDs, Titel unter CC BY-SA 4.0 und Erreichbarkeit aus dem aktiven Katalog, keine Anforderungstexte); die Work-Filterzuordnungen tragen ihn nicht.
|
- Nutzerauftrag 2026-10-08, spaeter am Tag: Zwei Schaltflaechen neben der Ueberschrift (`ISO 27001/27002`, `BSI Grundschutz++`, Client-Info-Tabknoepfe mit `aria-pressed`) schalten nur die Uebersichtszeile um; die Frameworkkacheln bleiben unveraendert, Standard ist ISO. Die BSI-Ansicht (Nutzerwahl) zeigt `Mapping coverage` mit fuenf Zustaenden (mit BSI-Anforderung, ISO-Kontrolle ohne BSI-Anforderung, zugeordnet ohne akzeptierte Kontrolle, noch nicht zugeordnet, fehlgeschlagen; die ersten beiden teilen `accepted`) und `Kernel footprint` mit allen 901 Kernel-Anforderungen als unbeschriftete Zellen je Praktik in Katalogreihenfolge. Die 629 Anforderungen, die die BSI-Zuordnung von keiner ISO-Kontrolle erreicht, tragen nur eine kleine neutrale Markierung, damit eine leere Zelle nicht als Luecke dieses Clients gelesen wird. Hover oder Fokus nennen ID, Praktik, Titel und ISO-Weg abgeleiteter Anforderungen und die Befundzahl; nur Anforderungen und Praktiken mit Treffern oeffnen Work (`BSI requirement`, `BSI practice`). Nutzerkorrekturen derselben Sitzung: Jede Anforderung ist ein Quadrat mit nur ihrer Nummer innerhalb der Praktik (zum Beispiel `10.5`; das Praefix nennt die Praktikueberschrift), auch ohne Befund oder ohne ISO-Erreichbarkeit (gestrichelter Rand). Die Kachel zeigt nur die zwei Praktiken mit den meisten Befunden (absteigend, Gleichstand in Katalogreihenfolge), damit die Quadrate lesbar bleiben; `Show all` oeffnet alle Praktiken mit groesseren Quadraten (14px-Datenrolle) im gemeinsamen Results-Fenster, ein Work-Sprung dort schliesst das Fenster. Ohne abgeleitete Anforderung nennt die Kachel das ausdruecklich. Im `Show all`-Fenster bleiben Detailzeile und Legende unten angeheftet, waehrend das Raster scrollt; die Detailzeile nennt fuer jede Anforderung den veroeffentlichten Titel, auch ohne Zuordnung oder ISO-Erreichbarkeit. Die Kontrolluebersicht liefert dafuer `bsiFootprint` (IDs, Titel unter CC BY-SA 4.0 und Erreichbarkeit aus dem aktiven Katalog, keine Anforderungstexte); die Work-Filterzuordnungen tragen ihn nicht.
|
||||||
- Nutzerauftrag 2026-10-08 (Core-Freigabe Presentation): Die Legendenzeile `Mapping failed` der `Mapping coverage` (ISO- und BSI-Ansicht) ist bei mindestens einem Befund ueber die ganze Zeile klickbar und oeffnet Work mit dem Status `Deviation` und der neuen Compliance-Engine-Facette `ISO mapping` = `Mapping failed`; dort laesst sich jeder Befund ueber `Map ISO controls` im Finding Reader neu zuordnen. Die Facette steht nach `ISO theme`, erscheint nur, solange eine Zuordnung fehlgeschlagen ist, und matcht wie die Kontrollfacetten (Scannerbefunde nach Vorkommen, GPO-Einstellungen nach Scope und technischer Identitaet). Grundlage ist `failedFindings` der Work-Zuordnungen: zuordenbare Befunde, deren Regel keine akzeptierte Kontrolle hat und deren juengster Lauf fehlschlug, also dieselbe Zustandsregel wie die Abdeckungszahl. Core-Aenderung nur der neue Drilldown in `DashboardPanel.tsx` neben den bestehenden ISO-/BSI-Drilldowns.
|
- Nutzerauftrag 2026-10-08 (Core-Freigabe Presentation): Die Legendenzeile `Mapping failed` der `Mapping coverage` (ISO- und BSI-Ansicht) ist bei mindestens einem Befund ueber die ganze Zeile klickbar und oeffnet Work mit dem Status `Deviation` und der neuen Compliance-Engine-Facette `ISO mapping` = `Mapping failed`; dort laesst sich jeder Befund ueber `Map ISO controls` im Finding Reader neu zuordnen. Die Facette steht nach `ISO theme`, erscheint nur, solange eine Zuordnung fehlgeschlagen ist, und matcht wie die Kontrollfacetten (Scannerbefunde nach Vorkommen, GPO-Einstellungen nach Scope und technischer Identitaet). Grundlage ist `failedFindings` der Work-Zuordnungen: zuordenbare Befunde, deren Regel keine akzeptierte Kontrolle hat und deren juengster Lauf fehlschlug, also dieselbe Zustandsregel wie die Abdeckungszahl. Core-Aenderung nur der neue Drilldown in `DashboardPanel.tsx` neben den bestehenden ISO-/BSI-Drilldowns.
|
||||||
- Nutzerauftrag 2026-10-08, spaeter (Core-Freigabe Presentation): Auch `ISO control, no BSI requirement` der BSI-Abdeckung oeffnet Work, ueber den zweiten Wert gleichen Namens der Facette `ISO mapping` (Befunde mit akzeptierter ISO-Kontrolle, von denen keine eine BSI-Anforderung erreicht; im Browser aus den Work-Zuordnungen berechnet). Der Drilldown in `DashboardPanel.tsx` ist dafuer allgemein `onOpenMappingState(state)`. Der Tab `Compliance` zeigt unter der Tableiste dieselbe Kontextzeile wie die Overview mit `Client` und `Assessment` (Timeslot-Wechsel), ohne `Source`, weil der Tab immer den ganzen Timeslot abdeckt (Nutzerzustimmung zum Vorschlag).
|
- Nutzerauftrag 2026-10-08, spaeter (Core-Freigabe Presentation): Auch `ISO control, no BSI requirement` der BSI-Abdeckung oeffnet Work, ueber den zweiten Wert gleichen Namens der Facette `ISO mapping` (Befunde mit akzeptierter ISO-Kontrolle, von denen keine eine BSI-Anforderung erreicht; im Browser aus den Work-Zuordnungen berechnet). Der Drilldown in `DashboardPanel.tsx` ist dafuer allgemein `onOpenMappingState(state)`. Der Tab `Compliance` zeigt unter der Tableiste dieselbe Kontextzeile wie die Overview mit `Client` und `Assessment` (Timeslot-Wechsel), ohne `Source`, weil der Tab immer den ganzen Timeslot abdeckt (Nutzerzustimmung zum Vorschlag).
|
||||||
|
- Nutzerkorrektur 2026-10-12 (ersetzt nur die quadratische `Mapping coverage` und die zwei Praktiken der Kernel-Kachel): Die Uebersichtszeile ist nur so hoch, wie der hoehere Inhalt braucht, beide Kacheln sind gleich hoch. Die Zellen beider Footprints wachsen mit der Kachelbreite von der bisherigen Groesse bis hoechstens 64px (Seitenverhaeltnis 5:3, Nummern in der 14px-Datenrolle); jede Annex-A-Themenspalte ist nur so breit wie ihre sechs Zellen, die Spalten verteilen sich ueber die Kachel. Die BSI-Anforderungen fuellen ein Raster mit so vielen Spalten dieser Groesse, wie passen. Die Kernel-Kachel zeigt nur noch die eine Praktik mit den meisten Befunden (Gleichstand in Katalogreihenfolge); `Show all` bleibt unveraendert. Nur Engine-Code; synthetische UI-Tests angepasst.
|
||||||
|
- Nutzerkorrektur 2026-10-12, spaeter (ersetzt nur die feste Zahl von vier Praktiken der Frameworkkachel `BSI Grundschutz++`): Die Kachel zeigt nach `Requirements` und `Findings` mindestens vier und hoechstens zehn Praktiken mit den meisten Befunden, so viele, wie ihr Quadrat fasst (Containerabfragen auf die Innenbreite: sechs ab 17rem, acht ab 20.5rem, zehn ab 23.5rem); `Results` listet weiterhin alle. Die Formulierung in `AGENTS.md` ist mit `Core-Freigabe: Governance` entsprechend angepasst.
|
||||||
- Waehrend des Ladens Gedankenstrich, nichts klickbar; bei ausgeschalteter Engine nichts. Verifiziert durch synthetische UI- und Diensttests, `npm test` und beide Builds; Sichtpruefung beider Themes in der laufenden App mit gewaehltem Client steht aus (der laufende Server muss fuer `coverage` neu gestartet werden).
|
- Waehrend des Ladens Gedankenstrich, nichts klickbar; bei ausgeschalteter Engine nichts. Verifiziert durch synthetische UI- und Diensttests, `npm test` und beide Builds; Sichtpruefung beider Themes in der laufenden App mit gewaehltem Client steht aus (der laufende Server muss fuer `coverage` neu gestartet werden).
|
||||||
|
|
||||||
## 8. Aktuell empfohlene Umsetzungsetappe
|
## 8. Aktuell empfohlene Umsetzungsetappe
|
||||||
|
|
@ -3769,6 +3771,7 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V
|
||||||
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
|
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
|
||||||
|
|
||||||
| Datum | Änderung |
|
| Datum | Änderung |
|
||||||
|
| 2026-10-09 | Phase 13F, Korrekturen nach 2.23.0 (nur Engine-Code, Version unveraendert, da unveroeffentlicht): (1) `Mapping failed` im Tab `Compliance` oeffnete Work ohne Filter, wenn eine Zuordnung nach dem Laden der Work-Zuordnungen fehlschlug, weil ein Fehlschlag keine Entscheidung ist und Work den alten Stand behielt. Der Tab meldet jetzt jede Aenderung von Abdeckung oder Zuordnungshistorie; Work laedt dann neu und bietet bis dahin keinen veralteten Stand an, sodass der gewaehlte Filter erhalten bleibt (Regressionstests fuer Tab und Work). (2) Die Hybridsuche lieferte viele Treffer je Kontrolle, die alle ueber die Obergrenze hinaus behalten wurden; eine Purple-Knight-Zuordnung scheiterte mit `regulatory_mapping_prompt_too_large`. Treffer zaehlen jetzt in Rangfolge zur Obergrenze von 8.000 Zeichen je Kontrolle, der beste bleibt immer (Regressionstest). |
|
||||||
| 2026-10-09 | Governance (Core-Freigabe Governance): AGENTS.md uebernimmt die Entscheidungen des Tages, BSI Stufe 2 mit Migration 029, Konsistenzpruefung und Kachel `ISO ↔ BSI review`, Hybridsuche, volle ISO-27002-Kontrolltexte, `Compliance mapping` statt `Map all ISO controls`, `Mapping history` mit ISO und BSI, BSI-Suchindex in der Data-Hub-Kachel und die automatische Annahme nur starker Vorschlaege. |
|
| 2026-10-09 | Governance (Core-Freigabe Governance): AGENTS.md uebernimmt die Entscheidungen des Tages, BSI Stufe 2 mit Migration 029, Konsistenzpruefung und Kachel `ISO ↔ BSI review`, Hybridsuche, volle ISO-27002-Kontrolltexte, `Compliance mapping` statt `Map all ISO controls`, `Mapping history` mit ISO und BSI, BSI-Suchindex in der Data-Hub-Kachel und die automatische Annahme nur starker Vorschlaege. |
|
||||||
| 2026-10-09 | Phase 13F, in Produktversion 2.23.0 enthalten (unveroeffentlicht; Core-Freigabe Presentation, Platform), Nutzerauftrag: Die Result-DB-Aktion `Map all ISO controls` heisst `Compliance mapping` (Fenster `Compliance mapping`, Aktivitaeten `Starting/Loading compliance mapping batch`), weil sie ISO und BSI startet. Die Data-Hub-Kachel `BSI GRUNDSCHUTZ++ KERNEL` zeigt den Suchindex des aktiven Katalogs (`Built`, `Building` mit Fortschritt, `Not built`, `Outdated`, `Build failed`, `Unavailable`) und bietet `Build search index` beziehungsweise `Rebuild search index`; neue Routen `GET /reference-data/bsi-grundschutz/search-index` und `POST .../search-index/build` (202, `force` fuer den Neuaufbau). Eine BSI-Zuordnung baut einen fehlenden Index weiterhin selbst. Mit Datenbankbereinigung: auf Nutzerwunsch wurden alle Zuordnungsdaten (242 Laeufe, 355 Entscheidungen, 5 Batches) nach verifizierter Sicherung `backups/assessor-pre-clear-mappings-20261009.dump` geloescht; neu zugeordnet wird durch den Nutzer. |
|
| 2026-10-09 | Phase 13F, in Produktversion 2.23.0 enthalten (unveroeffentlicht; Core-Freigabe Presentation, Platform), Nutzerauftrag: Die Result-DB-Aktion `Map all ISO controls` heisst `Compliance mapping` (Fenster `Compliance mapping`, Aktivitaeten `Starting/Loading compliance mapping batch`), weil sie ISO und BSI startet. Die Data-Hub-Kachel `BSI GRUNDSCHUTZ++ KERNEL` zeigt den Suchindex des aktiven Katalogs (`Built`, `Building` mit Fortschritt, `Not built`, `Outdated`, `Build failed`, `Unavailable`) und bietet `Build search index` beziehungsweise `Rebuild search index`; neue Routen `GET /reference-data/bsi-grundschutz/search-index` und `POST .../search-index/build` (202, `force` fuer den Neuaufbau). Eine BSI-Zuordnung baut einen fehlenden Index weiterhin selbst. Mit Datenbankbereinigung: auf Nutzerwunsch wurden alle Zuordnungsdaten (242 Laeufe, 355 Entscheidungen, 5 Batches) nach verifizierter Sicherung `backups/assessor-pre-clear-mappings-20261009.dump` geloescht; neu zugeordnet wird durch den Nutzer. |
|
||||||
| 2026-10-09 | Phase 13F, in Produktversion 2.23.0 enthalten (unveroeffentlicht, nur Engine-Code), Nutzerentscheidung (ersetzt die vorlaeufige Annahme der ersten zwei Vorschlaege vom 2026-10-05 fuer ISO und BSI): automatisch angenommen werden hoechstens die ersten zwei starken Vorschlaege in Modellreihenfolge, also `primary` mit Konfidenz `high` oder `medium`; schwaechere Vorschlaege warten auf eine Person, ohne starken Vorschlag wird nichts angenommen. Bestehende Entscheidungen bleiben unveraendert. Auf den 20 Purple-Knight-Validierungslaeufen: ISO 21 von 25 automatischen Annahmen in der Referenz (vorher 29 von 39), BSI 16 von 26 (vorher 18 von 37). AGENTS.md beschreibt noch die alte Regel; die Anpassung braucht eine Governance-Freigabe. |
|
| 2026-10-09 | Phase 13F, in Produktversion 2.23.0 enthalten (unveroeffentlicht, nur Engine-Code), Nutzerentscheidung (ersetzt die vorlaeufige Annahme der ersten zwei Vorschlaege vom 2026-10-05 fuer ISO und BSI): automatisch angenommen werden hoechstens die ersten zwei starken Vorschlaege in Modellreihenfolge, also `primary` mit Konfidenz `high` oder `medium`; schwaechere Vorschlaege warten auf eine Person, ohne starken Vorschlag wird nichts angenommen. Bestehende Entscheidungen bleiben unveraendert. Auf den 20 Purple-Knight-Validierungslaeufen: ISO 21 von 25 automatischen Annahmen in der Referenz (vorher 29 von 39), BSI 16 von 26 (vorher 18 von 37). AGENTS.md beschreibt noch die alte Regel; die Anpassung braucht eine Governance-Freigabe. |
|
||||||
|
|
|
||||||
|
|
@ -995,9 +995,16 @@ export class RegulatoryMappingService {
|
||||||
const byId = new Map([...listed, ...matched].filter(usable).map(chunk => [chunk.chunkId, chunk]));
|
const byId = new Map([...listed, ...matched].filter(usable).map(chunk => [chunk.chunkId, chunk]));
|
||||||
const ordered = [...byId.values()].sort((left, right) => SECTION_ORDER.indexOf(left.sectionKind!) - SECTION_ORDER.indexOf(right.sectionKind!)
|
const ordered = [...byId.values()].sort((left, right) => SECTION_ORDER.indexOf(left.sectionKind!) - SECTION_ORDER.indexOf(right.sectionKind!)
|
||||||
|| left.documentId.localeCompare(right.documentId) || left.chunkIndex - right.chunkIndex);
|
|| left.documentId.localeCompare(right.documentId) || left.chunkIndex - right.chunkIndex);
|
||||||
const kept = new Set(matched.filter(usable).map(chunk => chunk.chunkId));
|
// Matched passages first in rank order, the best one always; the hybrid search can match many, so they count
|
||||||
let length = [...kept].reduce((total, id) => total + byId.get(id)!.text.length, 0);
|
// toward the cap like every other passage.
|
||||||
// Core sections first, other information only with the space left.
|
const kept = new Set<string>();
|
||||||
|
let length = 0;
|
||||||
|
for (const chunk of matched.filter(usable)) {
|
||||||
|
if (kept.has(chunk.chunkId) || kept.size > 0 && length + chunk.text.length > MAXIMUM_CONTROL_TEXT_CHARACTERS) continue;
|
||||||
|
kept.add(chunk.chunkId);
|
||||||
|
length += chunk.text.length;
|
||||||
|
}
|
||||||
|
// Core sections next, other information only with the space left.
|
||||||
for (const pass of [CORE_SECTIONS, ['other-information']] as const) {
|
for (const pass of [CORE_SECTIONS, ['other-information']] as const) {
|
||||||
for (const chunk of ordered) {
|
for (const chunk of ordered) {
|
||||||
if (kept.has(chunk.chunkId) || !pass.includes(chunk.sectionKind as never)) continue;
|
if (kept.has(chunk.chunkId) || !pass.includes(chunk.sectionKind as never)) continue;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useEnginePreferences } from '../../app/engine-preferences';
|
||||||
import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts';
|
import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts';
|
||||||
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
|
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
|
||||||
import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping';
|
import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping';
|
||||||
import { useRegulatoryDecisionRevision } from './decision-revision';
|
import { markRegulatoryMappingsChanged, useRegulatoryDecisionRevision } from './decision-revision';
|
||||||
import { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz';
|
import { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz';
|
||||||
import { ComplianceFrameworkSwitch, ComplianceOverview, type ComplianceFramework } from './ComplianceOverview';
|
import { ComplianceFrameworkSwitch, ComplianceOverview, type ComplianceFramework } from './ComplianceOverview';
|
||||||
import type { ComplianceMappingState } from './compliance-work-facets';
|
import type { ComplianceMappingState } from './compliance-work-facets';
|
||||||
|
|
@ -95,6 +95,16 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [enabled, revision, scopeKey, poll]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [enabled, revision, scopeKey, poll]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
useEffect(() => { setPoll(0); }, [enabled, revision, scopeKey]);
|
useEffect(() => { setPoll(0); }, [enabled, revision, scopeKey]);
|
||||||
|
// A mapping that finished or failed since the last overview changes what Work can filter, so Work reloads its
|
||||||
|
// assignments; otherwise `Mapping failed` would open Work without its value.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status !== 'ready') return;
|
||||||
|
const signature = mappingSignature(state.overview);
|
||||||
|
const previous = mappingSignatures.get(scopeKey);
|
||||||
|
mappingSignatures.set(scopeKey, signature);
|
||||||
|
while (mappingSignatures.size > OVERVIEW_CACHE_LIMIT) mappingSignatures.delete(mappingSignatures.keys().next().value!);
|
||||||
|
if (previous !== undefined && previous !== signature) markRegulatoryMappingsChanged();
|
||||||
|
}, [state, scopeKey]);
|
||||||
|
|
||||||
const history = state.status === 'ready' ? state.overview.mappingHistory ?? [] : [];
|
const history = state.status === 'ready' ? state.overview.mappingHistory ?? [] : [];
|
||||||
const mappingActive = history.some(entry => entry.status === 'queued' || entry.status === 'running');
|
const mappingActive = history.some(entry => entry.status === 'queued' || entry.status === 'running');
|
||||||
|
|
@ -216,8 +226,13 @@ function rememberOverview(scopeKey: string, overview: RegulatoryControlOverviewR
|
||||||
overviewCache.set(scopeKey, overview);
|
overviewCache.set(scopeKey, overview);
|
||||||
while (overviewCache.size > OVERVIEW_CACHE_LIMIT) overviewCache.delete(overviewCache.keys().next().value!);
|
while (overviewCache.size > OVERVIEW_CACHE_LIMIT) overviewCache.delete(overviewCache.keys().next().value!);
|
||||||
}
|
}
|
||||||
|
// The mapping state last seen per timeslot: coverage and the state of every listed mapping.
|
||||||
|
const mappingSignatures = new Map<string, string>();
|
||||||
|
function mappingSignature(overview: RegulatoryControlOverviewResponse): string {
|
||||||
|
return JSON.stringify([overview.coverage ?? null, (overview.mappingHistory ?? []).map(entry => [entry.id, entry.status, entry.counts.succeeded, entry.counts.failed])]);
|
||||||
|
}
|
||||||
/** Test support. */
|
/** Test support. */
|
||||||
export function resetClientComplianceCacheForTests(): void { overviewCache.clear(); overviewRequests.clear(); }
|
export function resetClientComplianceCacheForTests(): void { overviewCache.clear(); overviewRequests.clear(); mappingSignatures.clear(); }
|
||||||
|
|
||||||
const MAPPING_POLL_MS = 5_000;
|
const MAPPING_POLL_MS = 5_000;
|
||||||
const triggerLabel = (entry: RegulatoryMappingHistoryEntry) =>
|
const triggerLabel = (entry: RegulatoryMappingHistoryEntry) =>
|
||||||
|
|
@ -260,7 +275,8 @@ function LastMapping({ history }: { history: RegulatoryMappingHistoryEntry[] })
|
||||||
</EnvironmentResultsButton>;
|
</EnvironmentResultsButton>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BSI_TILE_PRACTICES = 4;
|
/** At most this many practices; the tile CSS shows as many as its square fits, at least four. */
|
||||||
|
const BSI_TILE_PRACTICES = 10;
|
||||||
|
|
||||||
interface BsiPracticeGroup { id: string; title: string; requirements: Array<BsiOverview['requirements'][number] & { findings: number }>; findings: number }
|
interface BsiPracticeGroup { id: string; title: string; requirements: Array<BsiOverview['requirements'][number] & { findings: number }>; findings: number }
|
||||||
|
|
||||||
|
|
@ -284,7 +300,7 @@ function bsiPracticeGroups(overview: RegulatoryControlOverviewResponse): { group
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BSI Grundschutz++ tile (Phase 13F, stage 1): Kernel requirements derived from the accepted ISO controls through
|
* BSI Grundschutz++ tile (Phase 13F, stage 1): Kernel requirements derived from the accepted ISO controls through
|
||||||
* the BSI mapping. The four practices with the most findings follow the totals; the Results window lists all.
|
* the BSI mapping. The practices with the most findings follow the totals, as many as the tile fits; the Results window lists all.
|
||||||
*/
|
*/
|
||||||
function BsiTile({ state, onOpenRequirement, onOpenPractice }: { state: OverviewState; onOpenRequirement(id: string): void; onOpenPractice(id: string): void }) {
|
function BsiTile({ state, onOpenRequirement, onOpenPractice }: { state: OverviewState; onOpenRequirement(id: string): void; onOpenPractice(id: string): void }) {
|
||||||
const ready = state.status === 'ready';
|
const ready = state.status === 'ready';
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ export const COVERAGE_TILE_TITLE = 'Mapping coverage';
|
||||||
export const FOOTPRINT_TILE_TITLE = 'Annex A footprint';
|
export const FOOTPRINT_TILE_TITLE = 'Annex A footprint';
|
||||||
export const BSI_FOOTPRINT_TILE_TITLE = 'Kernel footprint';
|
export const BSI_FOOTPRINT_TILE_TITLE = 'Kernel footprint';
|
||||||
export const BSI_FOOTPRINT_WINDOW_TITLE = 'BSI Grundschutz++ Kernel footprint';
|
export const BSI_FOOTPRINT_WINDOW_TITLE = 'BSI Grundschutz++ Kernel footprint';
|
||||||
/** Practices the BSI footprint tile shows; `Show all` shows every practice. */
|
/** Practices the BSI footprint tile shows (the one with the most findings); `Show all` shows every practice. */
|
||||||
const BSI_TILE_PRACTICES = 2;
|
const BSI_TILE_PRACTICES = 1;
|
||||||
const FRAMEWORKS: Array<[ComplianceFramework, string]> = [['iso', 'ISO 27001/27002'], ['bsi', 'BSI Grundschutz++']];
|
const FRAMEWORKS: Array<[ComplianceFramework, string]> = [['iso', 'ISO 27001/27002'], ['bsi', 'BSI Grundschutz++']];
|
||||||
|
|
||||||
const number = (value: number) => value.toLocaleString('en-US');
|
const number = (value: number) => value.toLocaleString('en-US');
|
||||||
|
|
@ -216,7 +216,7 @@ interface BsiGridProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BSI Kernel requirements by practice in catalog order, each a square labelled with its number inside the practice.
|
* BSI Kernel requirements by practice in catalog order, each a square labelled with its number inside the practice.
|
||||||
* `hits` (the tile) shows the two practices with the most findings; `all` (the Show all window) shows every practice, larger. A
|
* `hits` (the tile) shows the practice with the most findings; `all` (the Show all window) shows every practice, larger. A
|
||||||
* requirement the BSI mapping cannot reach from any ISO control is marked, so an empty square never reads as a gap of this client.
|
* requirement the BSI mapping cannot reach from any ISO control is marked, so an empty square never reads as a gap of this client.
|
||||||
*/
|
*/
|
||||||
function BsiRequirementGrid({ mode, practices, findingsOf, derived, onOpenRequirement, onOpenPractice }: BsiGridProps & { mode: 'hits' | 'all' }) {
|
function BsiRequirementGrid({ mode, practices, findingsOf, derived, onOpenRequirement, onOpenPractice }: BsiGridProps & { mode: 'hits' | 'all' }) {
|
||||||
|
|
@ -230,7 +230,7 @@ function BsiRequirementGrid({ mode, practices, findingsOf, derived, onOpenRequir
|
||||||
});
|
});
|
||||||
return { practice, findings: findings.size, cells: practice.requirements, hits: hits.length };
|
return { practice, findings: findings.size, cells: practice.requirements, hits: hits.length };
|
||||||
});
|
});
|
||||||
// The tile keeps the practices with the most findings, like the BSI tile; ties stay in catalog order.
|
// The tile keeps the practice with the most findings; a tie keeps catalog order.
|
||||||
if (mode === 'hits') blocks.splice(0, blocks.length, ...blocks.filter(block => block.hits > 0)
|
if (mode === 'hits') blocks.splice(0, blocks.length, ...blocks.filter(block => block.hits > 0)
|
||||||
.sort((left, right) => right.findings - left.findings).slice(0, BSI_TILE_PRACTICES));
|
.sort((left, right) => right.findings - left.findings).slice(0, BSI_TILE_PRACTICES));
|
||||||
if (!blocks.length) return <p className="compliance-footprint__pending">No BSI requirements derived from accepted ISO controls in this timeslot.</p>;
|
if (!blocks.length) return <p className="compliance-footprint__pending">No BSI requirements derived from accepted ISO controls in this timeslot.</p>;
|
||||||
|
|
@ -269,7 +269,7 @@ function BsiRequirementGrid({ mode, practices, findingsOf, derived, onOpenRequir
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tile shows the two practices with the most findings; `Show all` opens every practice in a window. */
|
/** The tile shows the practice with the most findings; `Show all` opens every practice in a window. */
|
||||||
function BsiFootprintTile({ controls, bsi, bsiFootprint, onOpenBsiRequirement, onOpenBsiPractice }: Props) {
|
function BsiFootprintTile({ controls, bsi, bsiFootprint, onOpenBsiRequirement, onOpenBsiPractice }: Props) {
|
||||||
const findingsOf = controls ? bsiRequirementFindings(controls, bsi) : new Map<string, RegulatoryControlOverviewItem['findings']>();
|
const findingsOf = controls ? bsiRequirementFindings(controls, bsi) : new Map<string, RegulatoryControlOverviewItem['findings']>();
|
||||||
const derived = new Map((bsi?.requirements ?? []).map(requirement => [requirement.requirementId, requirement]));
|
const derived = new Map((bsi?.requirements ?? []).map(requirement => [requirement.requirementId, requirement]));
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { FindingRecord } from '../../domain/finding';
|
||||||
import { gpoWorkSettingIdentity } from '../../features/results-browser/work-record-lookup';
|
import { gpoWorkSettingIdentity } from '../../features/results-browser/work-record-lookup';
|
||||||
import type { DynamicFacet } from '../../features/search-filter/dynamic-facets';
|
import type { DynamicFacet } from '../../features/search-filter/dynamic-facets';
|
||||||
import type { RegulatoryControlFinding, RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
|
import type { RegulatoryControlFinding, RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
|
||||||
import { useRegulatoryDecisionRevision } from './decision-revision';
|
import { useRegulatoryDecisionRevision, useRegulatoryMappingRevision } from './decision-revision';
|
||||||
|
|
||||||
/** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */
|
/** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */
|
||||||
export const ISO_CONTROL_FACET_ID = 'compliance:iso-control';
|
export const ISO_CONTROL_FACET_ID = 'compliance:iso-control';
|
||||||
|
|
@ -133,21 +133,22 @@ export function withComplianceFacets(facets: DynamicFacet[], index: ComplianceWo
|
||||||
}
|
}
|
||||||
|
|
||||||
const assessorApi = new AssessorApiClient();
|
const assessorApi = new AssessorApiClient();
|
||||||
// One request per timeslot and decision revision, shared by every Work consumer.
|
// One request per timeslot, decision revision and mapping revision, shared by every Work consumer.
|
||||||
const requests = new Map<string, Promise<RegulatoryControlOverviewResponse>>();
|
const requests = new Map<string, Promise<RegulatoryControlOverviewResponse>>();
|
||||||
const ready = new Map<string, RegulatoryControlOverviewResponse>();
|
const ready = new Map<string, RegulatoryControlOverviewResponse>();
|
||||||
const lastReadyByScope = new Map<string, RegulatoryControlOverviewResponse>();
|
const lastReadyByScope = new Map<string, { response: RegulatoryControlOverviewResponse; mappingRevision: number }>();
|
||||||
|
|
||||||
/** The ISO facet index for Work, or undefined while the engine is off or nothing is loaded yet. */
|
/** The ISO facet index for Work, or undefined while the engine is off or nothing is loaded yet. */
|
||||||
export function useComplianceWorkFacets(): ComplianceWorkFacetIndex | undefined {
|
export function useComplianceWorkFacets(): ComplianceWorkFacetIndex | undefined {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
const available = useEnginePreferences().compliance;
|
const available = useEnginePreferences().compliance;
|
||||||
const revision = useRegulatoryDecisionRevision();
|
const revision = useRegulatoryDecisionRevision();
|
||||||
|
const mappingRevision = useRegulatoryMappingRevision();
|
||||||
const assessmentIds = [...(app.activeAssessmentIds ?? [])].sort();
|
const assessmentIds = [...(app.activeAssessmentIds ?? [])].sort();
|
||||||
const scanRunIds = [...(app.activeScanRunIds ?? [])].sort();
|
const scanRunIds = [...(app.activeScanRunIds ?? [])].sort();
|
||||||
const projectId = app.targetProjectId;
|
const projectId = app.targetProjectId;
|
||||||
const scope = JSON.stringify([projectId, assessmentIds, scanRunIds]);
|
const scope = JSON.stringify([projectId, assessmentIds, scanRunIds]);
|
||||||
const key = `${scope}\u0000${revision}`;
|
const key = `${scope}\u0000${revision}\u0000${mappingRevision}`;
|
||||||
const enabled = available && app.backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0);
|
const enabled = available && app.backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0);
|
||||||
const [, setLoaded] = useState(0);
|
const [, setLoaded] = useState(0);
|
||||||
|
|
||||||
|
|
@ -160,7 +161,7 @@ export function useComplianceWorkFacets(): ComplianceWorkFacetIndex | undefined
|
||||||
requests.set(key, request);
|
requests.set(key, request);
|
||||||
request.then(response => {
|
request.then(response => {
|
||||||
ready.set(key, response);
|
ready.set(key, response);
|
||||||
lastReadyByScope.set(scope, response);
|
lastReadyByScope.set(scope, { response, mappingRevision });
|
||||||
while (ready.size > 8) ready.delete(ready.keys().next().value!);
|
while (ready.size > 8) ready.delete(ready.keys().next().value!);
|
||||||
while (lastReadyByScope.size > 4) lastReadyByScope.delete(lastReadyByScope.keys().next().value!);
|
while (lastReadyByScope.size > 4) lastReadyByScope.delete(lastReadyByScope.keys().next().value!);
|
||||||
}, () => undefined).finally(() => requests.delete(key));
|
}, () => undefined).finally(() => requests.delete(key));
|
||||||
|
|
@ -170,8 +171,11 @@ export function useComplianceWorkFacets(): ComplianceWorkFacetIndex | undefined
|
||||||
}, [enabled, key]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [enabled, key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (!enabled) return undefined;
|
if (!enabled) return undefined;
|
||||||
// While a new decision revision of the same timeslot loads, the last loaded state keeps an active filter valid.
|
// While a new decision revision of the same timeslot loads, the last loaded state keeps an active filter valid. After a
|
||||||
const response = ready.get(key) ?? lastReadyByScope.get(scope);
|
// mapping change it may lack the value just selected (a mapping that failed meanwhile), so nothing is offered until the
|
||||||
|
// fresh state arrives; Work keeps a selected ISO filter while no assignments are loaded.
|
||||||
|
const fallback = lastReadyByScope.get(scope);
|
||||||
|
const response = ready.get(key) ?? (fallback?.mappingRevision === mappingRevision ? fallback.response : undefined);
|
||||||
return response ? complianceWorkFacetIndex(response) : undefined;
|
return response ? complianceWorkFacetIndex(response) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,19 @@ div.finding-bsi-requirements__item strong { font-weight: var(--fw-semibold); fon
|
||||||
.iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); }
|
.iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); }
|
||||||
/* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */
|
/* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */
|
||||||
.client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; }
|
.client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; }
|
||||||
|
/* The BSI tile always shows its totals and four practices, and one more row of two practices for each further
|
||||||
|
fact row its square fits. A fact row is about 50px; the thresholds measure the inner width (tile minus padding)
|
||||||
|
with room to spare, so a wrapped practice name scrolls inside the tile instead of being cut off. */
|
||||||
|
.client-compliance-grid [data-framework="bsi-grundschutz-plus-plus"] .client-env-facts > div:nth-child(n + 7) { display: none; }
|
||||||
|
@container environment-tile (min-width: 17rem) {
|
||||||
|
.client-compliance-grid [data-framework="bsi-grundschutz-plus-plus"] .client-env-facts > div:nth-child(-n + 8) { display: grid; }
|
||||||
|
}
|
||||||
|
@container environment-tile (min-width: 20.5rem) {
|
||||||
|
.client-compliance-grid [data-framework="bsi-grundschutz-plus-plus"] .client-env-facts > div:nth-child(-n + 10) { display: grid; }
|
||||||
|
}
|
||||||
|
@container environment-tile (min-width: 23.5rem) {
|
||||||
|
.client-compliance-grid [data-framework="bsi-grundschutz-plus-plus"] .client-env-facts > div:nth-child(-n + 12) { display: grid; }
|
||||||
|
}
|
||||||
/* A theme fact with hits is one click target over its whole cell; hover changes the surface only. */
|
/* A theme fact with hits is one click target over its whole cell; hover changes the surface only. */
|
||||||
.client-compliance-grid .iso-tile-theme.is-actionable { position: relative; cursor: pointer; }
|
.client-compliance-grid .iso-tile-theme.is-actionable { position: relative; cursor: pointer; }
|
||||||
.client-compliance-grid .iso-tile-theme.is-actionable:hover { background: var(--tile-surface-hover); }
|
.client-compliance-grid .iso-tile-theme.is-actionable:hover { background: var(--tile-surface-hover); }
|
||||||
|
|
@ -85,11 +98,12 @@ button.iso-controls__theme:hover .iso-controls__theme-name { text-decoration: un
|
||||||
.client-information-table.client-compliance-history td:nth-child(2) { font-size: var(--type-table-body-size); }
|
.client-information-table.client-compliance-history td:nth-child(2) { font-size: var(--type-table-body-size); }
|
||||||
|
|
||||||
/* Client Info `Compliance` overview row: same six tracks as the framework row below, so column starts align.
|
/* Client Info `Compliance` overview row: same six tracks as the framework row below, so column starts align.
|
||||||
Mapping coverage is a square over two tracks; the Annex A footprint takes the other four at the same height. */
|
Mapping coverage takes two tracks and the footprint the other four; the row is as tall as the taller content
|
||||||
|
needs, and both tiles stretch to that same height. */
|
||||||
.compliance-overview { display: grid; grid-template-columns: repeat(6, minmax(var(--tile-environment-min-width), 1fr)); gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; }
|
.compliance-overview { display: grid; grid-template-columns: repeat(6, minmax(var(--tile-environment-min-width), 1fr)); gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; }
|
||||||
.compliance-overview > .client-env-group { display: flex; flex-direction: column; min-width: 0; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
.compliance-overview > .client-env-group { display: flex; flex-direction: column; min-width: 0; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
||||||
.compliance-overview .client-env-group-title { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
|
.compliance-overview .client-env-group-title { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
|
||||||
.compliance-coverage { grid-column: span 2; aspect-ratio: 1; }
|
.compliance-coverage { grid-column: span 2; }
|
||||||
.compliance-footprint { grid-column: span 4; }
|
.compliance-footprint { grid-column: span 4; }
|
||||||
|
|
||||||
/* Coverage: the shared Client Info donut; accepted, mapped-only and failed are arcs, not mapped is the track. */
|
/* Coverage: the shared Client Info donut; accepted, mapped-only and failed are arcs, not mapped is the track. */
|
||||||
|
|
@ -111,7 +125,15 @@ button.iso-controls__theme:hover .iso-controls__theme-name { text-decoration: un
|
||||||
.compliance-coverage__note { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
.compliance-coverage__note { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
||||||
|
|
||||||
/* Footprint: all 93 Annex A controls; one neutral chart tone in four steps, never a status colour. */
|
/* Footprint: all 93 Annex A controls; one neutral chart tone in four steps, never a status colour. */
|
||||||
.compliance-footprint__themes { display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--space-6); margin-block: var(--space-4); }
|
/* Footprint cells grow with the tile up to a cap, so a wide tile is filled without oversized squares;
|
||||||
|
each Annex A theme column is only as wide as its six cells, the columns spread across the tile and wrap below
|
||||||
|
six minimum cells. */
|
||||||
|
.compliance-footprint {
|
||||||
|
--footprint-cell-min: calc(var(--space-8) + var(--space-1) + var(--bw-emphasis));
|
||||||
|
--footprint-cell-max: calc(var(--space-8) * 2);
|
||||||
|
--footprint-cell-gap: calc(var(--space-1) - var(--bw-hairline));
|
||||||
|
}
|
||||||
|
.compliance-footprint__themes { display: grid; grid-template-columns: repeat(auto-fit, minmax(calc(var(--footprint-cell-min) * 6 + var(--footprint-cell-gap) * 5), calc(var(--footprint-cell-max) * 6 + var(--footprint-cell-gap) * 5))); justify-content: space-between; align-items: start; gap: var(--space-6); margin-block: var(--space-4); }
|
||||||
.compliance-footprint__column { display: grid; gap: var(--space-4); }
|
.compliance-footprint__column { display: grid; gap: var(--space-4); }
|
||||||
.compliance-footprint__theme { display: grid; align-content: start; gap: var(--space-2); }
|
.compliance-footprint__theme { display: grid; align-content: start; gap: var(--space-2); }
|
||||||
.compliance-footprint__theme-head { display: grid; justify-items: start; padding: 0 0 var(--space-1); border: 0; border-bottom: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: var(--text-primary); font-family: inherit; text-align: left; }
|
.compliance-footprint__theme-head { display: grid; justify-items: start; padding: 0 0 var(--space-1); border: 0; border-bottom: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: var(--text-primary); font-family: inherit; text-align: left; }
|
||||||
|
|
@ -120,7 +142,8 @@ button.compliance-footprint__theme-head:hover .compliance-footprint__theme-name
|
||||||
button.compliance-footprint__theme-head:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
|
button.compliance-footprint__theme-head:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
|
||||||
.compliance-footprint__theme-name { font-size: var(--type-body-size); font-weight: var(--fw-semibold); }
|
.compliance-footprint__theme-name { font-size: var(--type-body-size); font-weight: var(--fw-semibold); }
|
||||||
.compliance-footprint__theme-meta { color: var(--text-secondary); font-size: var(--type-table-body-size); font-variant-numeric: tabular-nums; }
|
.compliance-footprint__theme-meta { color: var(--text-secondary); font-size: var(--type-table-body-size); font-variant-numeric: tabular-nums; }
|
||||||
.compliance-footprint__cells { display: grid; grid-template-columns: repeat(6, calc(var(--space-8) + var(--space-1) + var(--bw-emphasis))); gap: calc(var(--space-1) - var(--bw-hairline)); }
|
.compliance-footprint__cells { display: grid; grid-template-columns: repeat(6, minmax(var(--footprint-cell-min), var(--footprint-cell-max))); gap: var(--footprint-cell-gap); }
|
||||||
|
.compliance-footprint__cells > .compliance-footprint__cell, .compliance-footprint__requirements > .compliance-footprint__cell { height: auto; min-height: calc(var(--space-6) + var(--space-1) + var(--bw-emphasis)); aspect-ratio: 5 / 3; font-size: var(--type-table-body-size); }
|
||||||
.compliance-footprint__cell { display: grid; place-items: center; height: calc(var(--space-6) + var(--space-1) + var(--bw-emphasis)); padding: 0; border: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: var(--text-secondary); font-family: inherit; font-size: var(--type-meta-size); font-variant-numeric: tabular-nums; }
|
.compliance-footprint__cell { display: grid; place-items: center; height: calc(var(--space-6) + var(--space-1) + var(--bw-emphasis)); padding: 0; border: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: var(--text-secondary); font-family: inherit; font-size: var(--type-meta-size); font-variant-numeric: tabular-nums; }
|
||||||
.compliance-footprint__cell[data-step] { border-color: transparent; color: var(--text-primary); }
|
.compliance-footprint__cell[data-step] { border-color: transparent; color: var(--text-primary); }
|
||||||
button.compliance-footprint__cell { cursor: pointer; }
|
button.compliance-footprint__cell { cursor: pointer; }
|
||||||
|
|
@ -142,14 +165,15 @@ button.compliance-footprint__cell:focus-visible { outline: var(--bw-emphasis) so
|
||||||
the practices stack at full width so their squares wrap across the tile. A requirement the ISO mapping cannot
|
the practices stack at full width so their squares wrap across the tile. A requirement the ISO mapping cannot
|
||||||
reach has a dashed outline, a reachable one without findings the solid outline of an empty Annex A cell. */
|
reach has a dashed outline, a reachable one without findings the solid outline of an empty Annex A cell. */
|
||||||
.compliance-footprint__practices { display: grid; gap: var(--space-4); margin-block: var(--space-4); }
|
.compliance-footprint__practices { display: grid; gap: var(--space-4); margin-block: var(--space-4); }
|
||||||
.compliance-footprint__requirements { display: flex; flex-wrap: wrap; gap: calc(var(--space-1) - var(--bw-hairline)); }
|
/* As many capped columns as fit, so the squares fill each row at the size of the Annex A cells. */
|
||||||
.compliance-footprint__requirements > .compliance-footprint__cell { min-width: calc(var(--space-8) + var(--space-1) + var(--bw-emphasis)); height: calc(var(--space-6) + var(--space-1) + var(--bw-emphasis)); padding: 0 var(--space-1); white-space: nowrap; }
|
.compliance-footprint__requirements { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--footprint-cell-min), var(--footprint-cell-max))); gap: var(--footprint-cell-gap); }
|
||||||
|
.compliance-footprint__requirements > .compliance-footprint__cell { padding: 0 var(--space-1); white-space: nowrap; }
|
||||||
.compliance-footprint__cell.is-unreachable { border-style: dashed; }
|
.compliance-footprint__cell.is-unreachable { border-style: dashed; }
|
||||||
.compliance-footprint__pending { margin: var(--space-4) 0; color: var(--text-secondary); font-size: var(--type-body-size); }
|
.compliance-footprint__pending { margin: var(--space-4) 0; color: var(--text-secondary); font-size: var(--type-body-size); }
|
||||||
/* Show all window: every practice, with larger squares and the 14px data role for the numbers. */
|
/* Show all window: every practice, with larger squares and the 14px data role for the numbers. */
|
||||||
.compliance-footprint--window { min-width: 0; }
|
.compliance-footprint--window { min-width: 0; }
|
||||||
.compliance-footprint__practices.is-window .compliance-footprint__requirements { gap: var(--space-1); }
|
.compliance-footprint__practices.is-window { --footprint-cell-min: calc(var(--space-8) + var(--space-4)); --footprint-cell-gap: var(--space-1); }
|
||||||
.compliance-footprint__practices.is-window .compliance-footprint__requirements > .compliance-footprint__cell { min-width: calc(var(--space-8) + var(--space-4)); height: calc(var(--space-8) + var(--space-1)); padding: 0 var(--space-2); font-size: var(--type-table-body-size); }
|
.compliance-footprint__practices.is-window .compliance-footprint__requirements > .compliance-footprint__cell { min-height: calc(var(--space-8) + var(--space-1)); padding: 0 var(--space-2); }
|
||||||
.compliance-footprint__footer { margin-top: auto; }
|
.compliance-footprint__footer { margin-top: auto; }
|
||||||
/* Pinned to the bottom of the window body (its 20px inset included), on the window surface, while the grid scrolls. */
|
/* Pinned to the bottom of the window body (its 20px inset included), on the window surface, while the grid scrolls. */
|
||||||
.compliance-footprint--window .compliance-footprint__footer { position: sticky; bottom: calc(var(--panel-inset) * -1); padding-bottom: var(--panel-inset); background: var(--bg); }
|
.compliance-footprint--window .compliance-footprint__footer { position: sticky; bottom: calc(var(--panel-inset) * -1); padding-bottom: var(--panel-inset); background: var(--bg); }
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,23 @@ function subscribe(listener: () => void) {
|
||||||
export function useRegulatoryDecisionRevision(): number {
|
export function useRegulatoryDecisionRevision(): number {
|
||||||
return useSyncExternalStore(subscribe, () => revision, () => 0);
|
return useSyncExternalStore(subscribe, () => revision, () => 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Counts changes of the mapping state another view noticed, such as a mapping that finished or failed while the
|
||||||
|
// Client Info Compliance tab watched a running batch. Work reloads its ISO assignments on it, because a failed
|
||||||
|
// mapping is no decision and would otherwise stay invisible to the `ISO mapping` filter.
|
||||||
|
let mappingRevision = 0;
|
||||||
|
const mappingListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function markRegulatoryMappingsChanged(): void {
|
||||||
|
mappingRevision += 1;
|
||||||
|
mappingListeners.forEach(listener => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeMappings(listener: () => void) {
|
||||||
|
mappingListeners.add(listener);
|
||||||
|
return () => { mappingListeners.delete(listener); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegulatoryMappingRevision(): number {
|
||||||
|
return useSyncExternalStore(subscribeMappings, () => mappingRevision, () => 0);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { requestActivity } from '../../src/api/request-activity';
|
||||||
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
||||||
import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping';
|
import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping';
|
||||||
import { ClientIsoControls, preloadClientComplianceOverview, resetClientComplianceCacheForTests } from '../../src/compliance/ui/ClientIsoControls';
|
import { ClientIsoControls, preloadClientComplianceOverview, resetClientComplianceCacheForTests } from '../../src/compliance/ui/ClientIsoControls';
|
||||||
import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-revision';
|
import { markRegulatoryDecisionSaved, useRegulatoryMappingRevision } from '../../src/compliance/ui/decision-revision';
|
||||||
import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
|
import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
|
||||||
import { makeSheet } from '../../src/importers/sheet-factory';
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
||||||
|
|
||||||
|
|
@ -233,6 +233,34 @@ describe('Client Info ISO controls', () => {
|
||||||
expect(lastMapping()?.textContent).toMatch(/· Single finding · Other client · Running$/);
|
expect(lastMapping()?.textContent).toMatch(/· Single finding · Other client · Running$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tells Work to reload its assignments when a refreshed overview shows another mapping state, and only then', async () => {
|
||||||
|
const revisions: number[] = [];
|
||||||
|
function Probe() { revisions.push(useRegulatoryMappingRevision()); return null; }
|
||||||
|
const running = { id: 'dummy-batch', kind: 'batch' as const, scope: 'timeslot' as const, trigger: 'automatic' as const, status: 'running' as const, createdAt: '2026-10-09T09:12:00Z', counts };
|
||||||
|
const coverage = { deviations: 4, accepted: 3, mappedNoneAccepted: 0, failed: 0, notMapped: 1 };
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview')
|
||||||
|
.mockResolvedValueOnce({ ...overview, coverage, mappingHistory: [running] });
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<><ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]} backendStatus="connected"
|
||||||
|
onOpenControl={onOpenControl} onOpenTheme={onOpenTheme} onOpenBsiRequirement={onOpenBsiRequirement} onOpenBsiPractice={onOpenBsiPractice}
|
||||||
|
onOpenMappingState={onOpenMappingState}/><Probe/></>);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
});
|
||||||
|
const first = revisions.at(-1)!;
|
||||||
|
// Same state on the next poll: nothing to reload.
|
||||||
|
request.mockResolvedValueOnce({ ...overview, coverage, mappingHistory: [running] });
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
|
||||||
|
expect(revisions.at(-1)).toBe(first);
|
||||||
|
// The mapping failed meanwhile: Work reloads.
|
||||||
|
request.mockResolvedValue({ ...overview, coverage: { ...coverage, failed: 1, notMapped: 0 },
|
||||||
|
mappingHistory: [{ ...running, status: 'completed', counts: { ...counts, failed: 1 } }] });
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
|
||||||
|
expect(revisions.at(-1)).toBe(first + 1);
|
||||||
|
} finally { vi.useRealTimers(); }
|
||||||
|
});
|
||||||
|
|
||||||
it('says when the timeslot was never mapped, and refreshes while a mapping runs', async () => {
|
it('says when the timeslot was never mapped, and refreshes while a mapping runs', async () => {
|
||||||
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValueOnce({ ...overview, mappingHistory: [] });
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValueOnce({ ...overview, mappingHistory: [] });
|
||||||
await render();
|
await render();
|
||||||
|
|
@ -373,20 +401,21 @@ describe('Client Info Compliance overview', () => {
|
||||||
await act(async () => isoOnly.click());
|
await act(async () => isoOnly.click());
|
||||||
expect(onOpenMappingState).toHaveBeenLastCalledWith('no-bsi');
|
expect(onOpenMappingState).toHaveBeenLastCalledWith('no-bsi');
|
||||||
expect(host.querySelectorAll('.compliance-coverage__legend button')).toHaveLength(2);
|
expect(host.querySelectorAll('.compliance-coverage__legend button')).toHaveLength(2);
|
||||||
// The tile shows the practices with the most findings first, each with all of its squares labelled by number.
|
// The tile shows only the practice with the most findings, with all of its squares labelled by number.
|
||||||
expect([...host.querySelectorAll('.compliance-footprint__requirements .compliance-footprint__cell')].map(cell => cell.textContent))
|
expect([...host.querySelectorAll('.compliance-footprint__requirements .compliance-footprint__cell')].map(cell => cell.textContent))
|
||||||
.toEqual(['1.1', '2.1', '3.1', '1.1', '9.9']);
|
.toEqual(['1.1', '2.1', '3.1']);
|
||||||
expect(practiceHead('DET')).toBeNull();
|
expect(practiceHead('DET')).toBeNull();
|
||||||
expect(practiceHead('KONF')?.textContent).toBe('KONF · Konfiguration2 of 3 requirements · 3 findings');
|
expect(practiceHead('KONF')?.textContent).toBe('KONF · Konfiguration2 of 3 requirements · 3 findings');
|
||||||
// BER has the fewest findings and stays out of the tile; Show all lists it below.
|
// ARCH and BER have fewer findings and stay out of the tile; Show all lists them.
|
||||||
|
expect(practiceHead('ARCH')).toBeNull();
|
||||||
expect(practiceHead('BER')).toBeNull();
|
expect(practiceHead('BER')).toBeNull();
|
||||||
expect(requirement('KONF.1.1')?.tagName).toBe('BUTTON');
|
expect(requirement('KONF.1.1')?.tagName).toBe('BUTTON');
|
||||||
expect(requirement('KONF.1.1')?.dataset.step).toBe('2');
|
expect(requirement('KONF.1.1')?.dataset.step).toBe('2');
|
||||||
expect(requirement('ARCH.9.9')?.classList.contains('is-unreachable')).toBe(true);
|
expect(requirement('KONF.3.1')?.classList.contains('is-unreachable')).toBe(true);
|
||||||
await act(async () => requirement('KONF.1.1')!.click());
|
await act(async () => requirement('KONF.1.1')!.click());
|
||||||
expect(onOpenBsiRequirement).toHaveBeenCalledWith('KONF.1.1');
|
expect(onOpenBsiRequirement).toHaveBeenCalledWith('KONF.1.1');
|
||||||
await act(async () => practiceHead('ARCH')!.click());
|
await act(async () => practiceHead('KONF')!.click());
|
||||||
expect(onOpenBsiPractice).toHaveBeenCalledWith('ARCH');
|
expect(onOpenBsiPractice).toHaveBeenCalledWith('KONF');
|
||||||
const detail = () => host.querySelector('.compliance-footprint__detail')?.textContent;
|
const detail = () => host.querySelector('.compliance-footprint__detail')?.textContent;
|
||||||
await act(async () => requirement('KONF.1.1')!.focus());
|
await act(async () => requirement('KONF.1.1')!.focus());
|
||||||
expect(detail()).toBe('KONF.1.1 · KONF Konfiguration · via A.8.20, A.8.9Synthetische Konfiguration3 findings');
|
expect(detail()).toBe('KONF.1.1 · KONF Konfiguration · via A.8.20, A.8.9Synthetische Konfiguration3 findings');
|
||||||
|
|
@ -420,7 +449,7 @@ describe('Client Info Compliance overview', () => {
|
||||||
expect(host.querySelectorAll('.compliance-footprint__themes .compliance-footprint__cell')).toHaveLength(93);
|
expect(host.querySelectorAll('.compliance-footprint__themes .compliance-footprint__cell')).toHaveLength(93);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows only the two BSI practices with the most findings in the tile, and every practice in Show all', async () => {
|
it('shows only the BSI practice with the most findings in the tile, and every practice in Show all', async () => {
|
||||||
// Two more synthetic practices with hits: DET via 8.9 (3 findings) and NOT via 5.15 (1 finding).
|
// Two more synthetic practices with hits: DET via 8.9 (3 findings) and NOT via 5.15 (1 finding).
|
||||||
const wider = { ...bsi, practices: [...bsi.practices, { id: 'NOT', title: 'Notfallplanung' }], requirements: [...bsi.requirements,
|
const wider = { ...bsi, practices: [...bsi.practices, { id: 'NOT', title: 'Notfallplanung' }], requirements: [...bsi.requirements,
|
||||||
{ requirementId: 'DET.1.1', title: 'Synthetische Detektion', practice: 'DET', statement: 'Synthetisch.', via: [{ isoControlId: '8.9', relationship: 'subset-of' as const }] },
|
{ requirementId: 'DET.1.1', title: 'Synthetische Detektion', practice: 'DET', statement: 'Synthetisch.', via: [{ isoControlId: '8.9', relationship: 'subset-of' as const }] },
|
||||||
|
|
@ -433,7 +462,7 @@ describe('Client Info Compliance overview', () => {
|
||||||
await act(async () => switchButton('bsi')!.click());
|
await act(async () => switchButton('bsi')!.click());
|
||||||
// KONF and DET have 3, ARCH 2, BER and NOT 1 each; the tie between KONF and DET keeps catalog order.
|
// KONF and DET have 3, ARCH 2, BER and NOT 1 each; the tie between KONF and DET keeps catalog order.
|
||||||
const shown = () => [...host.querySelectorAll<HTMLElement>('.compliance-overview [data-bsi-practice]')].map(block => block.dataset.bsiPractice);
|
const shown = () => [...host.querySelectorAll<HTMLElement>('.compliance-overview [data-bsi-practice]')].map(block => block.dataset.bsiPractice);
|
||||||
expect(shown()).toEqual(['KONF', 'DET']);
|
expect(shown()).toEqual(['KONF']);
|
||||||
await act(async () => host.querySelector<HTMLButtonElement>('.compliance-footprint .client-environment-results-button')!.click());
|
await act(async () => host.querySelector<HTMLButtonElement>('.compliance-footprint .client-environment-results-button')!.click());
|
||||||
expect([...document.querySelectorAll<HTMLElement>('.compliance-footprint--window [data-bsi-practice]')].map(block => block.dataset.bsiPractice))
|
expect([...document.querySelectorAll<HTMLElement>('.compliance-footprint--window [data-bsi-practice]')].map(block => block.dataset.bsiPractice))
|
||||||
.toEqual(['BER', 'ARCH', 'KONF', 'DET', 'NOT']);
|
.toEqual(['BER', 'ARCH', 'KONF', 'DET', 'NOT']);
|
||||||
|
|
|
||||||
|
|
@ -82,4 +82,18 @@ describe('ISO full control text (prompt 1.3.0)', () => {
|
||||||
expect(ids.slice(0, 2)).toEqual(['27002-8.2-control:0', '27002-8.2-guidance:0']);
|
expect(ids.slice(0, 2)).toEqual(['27002-8.2-control:0', '27002-8.2-guidance:0']);
|
||||||
expect(ids.at(-1)).toBe('27002-8.2-guidance:11');
|
expect(ids.at(-1)).toBe('27002-8.2-guidance:11');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression 2026-10-09: the hybrid search matched many long passages of one control, which were all kept beyond the
|
||||||
|
// cap, and a Purple Knight mapping failed with regulatory_mapping_prompt_too_large.
|
||||||
|
it('counts matched passages toward the cap, keeping the best one first, so many matches cannot overflow the prompt', async () => {
|
||||||
|
const guidance = Array.from({ length: 12 }, (_, index) => chunk('ISO/IEC 27002', 'guidance', index, 1_500));
|
||||||
|
const corpus = [chunk('ISO/IEC 27001', 'control', 0), chunk('ISO/IEC 27002', 'control', 0), ...guidance];
|
||||||
|
const { prompt, response } = await promptFor(corpus, [...guidance].reverse());
|
||||||
|
const evidence = (prompt.candidates[0].evidence as Array<{ chunkId: string; sourceStandard: string; text: string }>)
|
||||||
|
.filter(item => item.sourceStandard === 'ISO/IEC 27002');
|
||||||
|
expect(response.run?.status).toBe('succeeded');
|
||||||
|
expect(evidence.reduce((total, item) => total + item.text.length, 0)).toBeLessThanOrEqual(8_000);
|
||||||
|
// The best-ranked match (guidance 11, first in the hybrid ranking) is always kept.
|
||||||
|
expect(evidence.map(item => item.chunkId)).toContain('27002-8.2-guidance:11');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
resetComplianceWorkFacetsForTests,
|
resetComplianceWorkFacetsForTests,
|
||||||
useComplianceWorkFacets
|
useComplianceWorkFacets
|
||||||
} from '../../src/compliance/ui/compliance-work-facets';
|
} from '../../src/compliance/ui/compliance-work-facets';
|
||||||
import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-revision';
|
import { markRegulatoryDecisionSaved, markRegulatoryMappingsChanged } from '../../src/compliance/ui/decision-revision';
|
||||||
import { filterAndSortRecords } from '../../src/features/search-filter/filter-records';
|
import { filterAndSortRecords } from '../../src/features/search-filter/filter-records';
|
||||||
import { selectWorkFacets } from '../../src/features/search-filter/work-facets';
|
import { selectWorkFacets } from '../../src/features/search-filter/work-facets';
|
||||||
import { makeSheet } from '../../src/importers/sheet-factory';
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
||||||
|
|
@ -222,6 +222,38 @@ describe('Work ISO facet loading and the engine switch', () => {
|
||||||
expect(request).toHaveBeenCalledTimes(2);
|
expect(request).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression 2026-10-09: `Mapping failed` in the Compliance tab opened Work without filtering when the mapping failed
|
||||||
|
// after Work had loaded its assignments, because a failed mapping is no decision and Work kept the outdated state.
|
||||||
|
it('reloads the assignments after a mapping change and offers no outdated state meanwhile, so Mapping failed stays selected', async () => {
|
||||||
|
const { loadCanonicalProjectWorkspace } = await import('../../src/features/project-import/load-canonical-project-workspace');
|
||||||
|
vi.mocked(loadCanonicalProjectWorkspace).mockResolvedValue({ assessmentIds: ['dummy-assessment'], models: [],
|
||||||
|
dataset: { fileName: 'Dummy', scanTool: 'Generic', sheets: [workSheet()] } } as never);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue({} as never);
|
||||||
|
let resolveFresh!: (value: RegulatoryControlOverviewResponse) => void;
|
||||||
|
const withFailure: RegulatoryControlOverviewResponse = { ...assignments, failedFindings: [
|
||||||
|
{ resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' }] };
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlAssignments')
|
||||||
|
.mockResolvedValueOnce(assignments)
|
||||||
|
.mockImplementationOnce(() => new Promise(resolve => { resolveFresh = resolve; }));
|
||||||
|
await act(async () => root.render(<AppProvider><Harness withScope/></AppProvider>));
|
||||||
|
await act(async () => { host.querySelector('button')!.click(); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||||
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||||
|
expect(host.querySelector('[data-iso-facets]')?.textContent).toBe('2,2,1');
|
||||||
|
// The Compliance tab saw a mapping fail: Work drops the outdated state while it loads the fresh one ...
|
||||||
|
await act(async () => { markRegulatoryMappingsChanged(); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||||
|
expect(request).toHaveBeenCalledTimes(2);
|
||||||
|
expect(host.querySelector('[data-iso-facets]')?.textContent).toBe('none');
|
||||||
|
// ... and then offers ISO mapping with Mapping failed, matching exactly that finding.
|
||||||
|
await act(async () => { resolveFresh(withFailure); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||||
|
expect(host.querySelector('[data-iso-facets]')?.textContent).toBe('2,2,2');
|
||||||
|
const index = complianceWorkFacetIndex(withFailure);
|
||||||
|
const mapping = index.facets.find(facet => facet.id === ISO_MAPPING_FACET_ID)!;
|
||||||
|
expect(mapping.values.map(value => value.value)).toEqual([ISO_MAPPING_FAILED, ISO_MAPPING_NO_BSI]);
|
||||||
|
expect(filterAndSortRecords(workSheet(), { ...options('all', { [ISO_MAPPING_FACET_ID]: { values: [ISO_MAPPING_FAILED] } }), complianceFacets: index,
|
||||||
|
sortColumn: '', sortDirection: 'asc' })
|
||||||
|
.map(record => record.findingOccurrenceId)).toEqual(['dummy-a']);
|
||||||
|
});
|
||||||
|
|
||||||
it('loads nothing and clears an active ISO filter when the Compliance Engine is switched off', async () => {
|
it('loads nothing and clears an active ISO filter when the Compliance Engine is switched off', async () => {
|
||||||
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlAssignments').mockResolvedValue(assignments);
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlAssignments').mockResolvedValue(assignments);
|
||||||
await act(async () => root.render(<AppProvider><Harness withScope={false}/></AppProvider>));
|
await act(async () => root.render(<AppProvider><Harness withScope={false}/></AppProvider>));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue