From 01dabc9c3a9547caa657ab80bf6470a508ce4480 Mon Sep 17 00:00:00 2001 From: Binh Date: Wed, 14 Oct 2026 13:10:02 +0200 Subject: [PATCH] fix(client-info): keep repeat visits read-free after the Security tab rebase The Overview (current source) and Security (no source filter) analyses of the same sheet overwrote each other's single cache slot, so every Client Info visit recomputed both. Keep up to three source-filter entries per sheet and cache the Security view and its sorted groups by identity. Align tests with Michael's base: DB Structure cards may use the shared --tile-radius token, and the Client Info action role covers the HTML Security Export button. --- docs/IMPLEMENTATION_PLAN.md | 6 +++ .../dashboard/ClientSecurityPanel.tsx | 10 ++++- src/features/dashboard/DashboardPanel.tsx | 38 +++++++++++++++---- .../client-info-action-colors.test.tsx | 5 ++- tests/features/db-structure-layout.test.ts | 3 +- 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 57b605d..7459184 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1,5 +1,11 @@ # ASSESSOR – Umsetzungsplan +## Rebase onto Michael 7471afc: Client Info repeat-visit cache and test alignment (2026-10-14, Phase 7 Presentation) + +Current user consent: Presentation, Core Tests. After rebasing the branch onto Michael's `7471afc` (Security export, Work Packages tab), the Client Info repeat-visit check read 43 record fields instead of 0: the Overview (current source) and Security (no source filter) analyses of the same sheet overwrote each other's single cache slot, and the Security view, its GPO gap counts and its group sort were recomputed on every remount. The analysis cache now keeps up to three source-filter entries per sheet, and the Security view and its sorted groups are cached by their analysis/record identity; counts, verdicts and drilldowns are unchanged. Test alignment by user decision: DB Structure cards may use `border-radius: 0` or the shared `--tile-radius` token (the AGENTS tile-radius wording is unchanged), and the Client Info action test expects Management Report, Technical Report and HTML Security Export in the Engines-blue role. `ClientInfoTabs` keeps `lazy` (unmount while hidden) and adds `retain` for the Work Packages tab. No version change; verified with `npm run test:imports`, all 2561 vitest tests and both production builds. Open: `tests/design-language-check.mjs` rejects the inline bar widths in `src/features/comparison/ComparisonTab.tsx` from 2.27.0. + +Change log 2026-10-14: rebased onto Michael 7471afc; restored zero-read Client Info repeat visits; aligned DB Structure radius and Client Info action tests. + ## Comparison Engine: decision and staging (planned 2026-10-13, Phase 8 timeslot comparison as an engine) Status: **steps 1, 2, 3a, 3b and the Security Domain deltas done (2026-10-13); step 4 (engine storage) 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). diff --git a/src/features/dashboard/ClientSecurityPanel.tsx b/src/features/dashboard/ClientSecurityPanel.tsx index 31993d6..1b099d3 100644 --- a/src/features/dashboard/ClientSecurityPanel.tsx +++ b/src/features/dashboard/ClientSecurityPanel.tsx @@ -34,6 +34,8 @@ const flowStyle = (tone: string, strength?: number): CSSProperties => ({ '--doma const shareStyle = (share: number): CSSProperties => ({ '--share': `${share}%` } as CSSProperties); const overviewStyle = (groups: number): CSSProperties => ({ '--security-map-height': `${Math.max(1, groups) * 28 + 24}px` } as CSSProperties); const severityOrder = ['critical', 'high', 'medium', 'low', 'info', 'unknown']; +/** Group record arrays come from the cached Security view, so their sorted order survives a Client Info remount. */ +const sortedGroupRecords = new WeakMap(); const severityOf = (record: FindingRecord) => normalizeSeverity(record.fields.find(field => /^(severity|criticality)$/i.test(field.label))?.value); const label = (value: string) => value === 'unknown' ? 'Undetermined' : value.charAt(0).toUpperCase() + value.slice(1); const percent = (part: number, total: number) => !total ? '—' : part > 0 && part / total < .005 ? '<1%' : `${Math.round(100 * part / total)}%`; @@ -177,7 +179,13 @@ function FindingGroup({ group, navigation, open, onToggle, ...props }: { group: toggle.current?.focus({ preventScroll: true }); section.current?.scrollIntoView?.({ block: 'start', behavior: 'instant' }); }, [navigation, group.id]); - const records = useMemo(() => [...group.records].sort((left, right) => severityOrder.indexOf(severityOf(left)) - severityOrder.indexOf(severityOf(right)) || left.title.localeCompare(right.title, 'en') || left.id.localeCompare(right.id)), [group.records]); + const records = useMemo(() => { + const cached = sortedGroupRecords.get(group.records); + if (cached) return cached; + const sorted = [...group.records].sort((left, right) => severityOrder.indexOf(severityOf(left)) - severityOrder.indexOf(severityOf(right)) || left.title.localeCompare(right.title, 'en') || left.id.localeCompare(right.id)); + sortedGroupRecords.set(group.records, sorted); + return sorted; + }, [group.records]); const share = props.total ? 100 * records.length / props.total : 0; const heading = <> {group.number && {group.number} · }{group.label} diff --git a/src/features/dashboard/DashboardPanel.tsx b/src/features/dashboard/DashboardPanel.tsx index cba6efc..6b56029 100644 --- a/src/features/dashboard/DashboardPanel.tsx +++ b/src/features/dashboard/DashboardPanel.tsx @@ -1043,12 +1043,23 @@ type DashboardAnalysis = ReturnType; * Zurueckkehren neu aufgebaut, sodass dieselbe Rechnung sonst bei jedem Besuch * erneut liefe. */ -const analysisCache = new WeakMap; sourceFilter?: DynamicFacetFilter; value: DashboardAnalysis; +}>>(); +/** Overview (current source) and Security (no source filter) read the same sheet; keep both plus one recent source. */ +const ANALYSIS_CACHE_ENTRIES_PER_SHEET = 3; + +/** + * The Security view is derived from the cached Security analysis and survives a Client Info remount + * like the analysis itself; any other profile, tool, context state or report switch recomputes it. + */ +const securityViewCache = new WeakMap; total: number; gpoGaps: ReturnType }; }>(); /** @@ -1067,13 +1078,19 @@ function useDashboardAnalysis( sourceFilter?: DynamicFacetFilter ) { return useMemo(() => { - const cached = activeSheet && dataset ? analysisCache.get(activeSheet) : undefined; - if (cached && cached.dataset === dataset && cached.profile === activeProfile && cached.reviews === reviews && cached.sourceFilter === sourceFilter) { + const entries = activeSheet && dataset ? analysisCache.get(activeSheet) ?? [] : []; + const cached = entries.find(entry => entry.dataset === dataset && entry.profile === activeProfile && entry.reviews === reviews && entry.sourceFilter === sourceFilter); + if (cached) { + if (entries[0] !== cached) analysisCache.set(activeSheet!, [cached, ...entries.filter(entry => entry !== cached)]); return cached.value; } const facets = activeSheet ? deriveDynamicFacets(activeSheet, dataset, activeProfile, reviews) : []; const value = dashboardAnalysis(activeSheet, activeProfile, reviews, facets, sourceFilter, dataset?.securityScoring); - if (activeSheet && dataset) analysisCache.set(activeSheet, { dataset, profile: activeProfile, reviews, sourceFilter, value }); + if (activeSheet && dataset) { + // Entries for another dataset, profile or review state can never match again. + const current = entries.filter(entry => entry.dataset === dataset && entry.profile === activeProfile && entry.reviews === reviews); + analysisCache.set(activeSheet, [{ dataset, profile: activeProfile, reviews, sourceFilter, value }, ...current].slice(0, ANALYSIS_CACHE_ENTRIES_PER_SHEET)); + } return value; }, [activeProfile, activeSheet, dataset, reviews, sourceFilter]); } @@ -1182,7 +1199,11 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { const securityAnalysis = useDashboardAnalysis(securitySheet, dataset, securityProfile, reviews); const engines = useEnginePreferences(); const impactContext = useAssessmentContext(Boolean(dataset), app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, dataset); + const securityContextKey: unknown = impactContext.status === 'ready' ? impactContext.context : impactContext.status; const securityView = useMemo(() => { + const cached = securityViewCache.get(securityAnalysis); + if (cached && cached.profile === securityProfile && cached.toolId === securitySheet?.toolId + && cached.context === securityContextKey && cached.report === engines.report) return cached.value; const records = securityAnalysis.records.filter(record => dashboardResultState(record, securityProfile) === 'deviation'); const buckets = dashboardDomainAssignment(records, securitySheet?.toolId); const domainCounts = securityDomainCatalog.map(domain => ({ id: domain.id, label: domain.label, count: buckets.get(domain.id)?.length ?? 0 })); @@ -1203,8 +1224,11 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { if (remaining.length) groups.push({ id: 'unassigned', label: impactContext.status === 'ready' ? 'Unassigned findings' : 'All findings', severity: 'unknown', situation: impactContext.status === 'ready' ? 'These findings have no matching published category assignment in the current scope.' : undefined, records: remaining, domains: countsFor(remaining) }); - return { groups, domains: domainCounts, total: records.length }; - }, [securityAnalysis, securityProfile, securitySheet?.toolId, impactContext.status === 'ready' ? impactContext.context : impactContext.status, engines.report]); + const value = { groups, domains: domainCounts, total: records.length, + gpoGaps: dashboardGpoGapCounts(securityAnalysis.records, securityProfile, securitySheet?.toolId ?? dataset?.profileId) }; + securityViewCache.set(securityAnalysis, { profile: securityProfile, toolId: securitySheet?.toolId, context: securityContextKey, report: engines.report, value }); + return value; + }, [securityAnalysis, securityProfile, securitySheet?.toolId, securityContextKey, engines.report, dataset?.profileId]); const impactDistribution = useMemo(() => { if (!engines.report || impactContext.status !== 'ready') return undefined; const keys = managementWorkspaceDeviationKeys(analysis.records, record => dashboardResultState(record, activeProfile) === 'deviation'); @@ -1439,7 +1463,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { ['Base severity Critical categories', engines.context && impactContext.status === 'ready' ? securityView.groups.filter(group => group.records.length && group.severity === 'critical').length : undefined], ['Accepted risks (valid)', riskRegisterEntries(dataset, reviews, undefined, app.scopedRiskAcceptances).filter(entry => entry.state === 'Accepted').length] ].map(([label, value]) => {label}{typeof value === 'number' ? value.toLocaleString('en-US') : value ?? '—'})} - + } overview={
diff --git a/tests/features/client-info-action-colors.test.tsx b/tests/features/client-info-action-colors.test.tsx index 2a2a6ad..ee48f30 100644 --- a/tests/features/client-info-action-colors.test.tsx +++ b/tests/features/client-info-action-colors.test.tsx @@ -39,7 +39,10 @@ it.each(['dark', 'light'])('removes only the Client Information button and keeps expect(host.querySelector('.client-overview-header > .client-report-actions')).toBeNull(); expect(host.querySelector('[aria-label="Open Client Information results"]')).toBeNull(); expect(actions.every(button => button.classList.contains('client-information-action'))).toBe(true); - expect(host.querySelectorAll('.client-information-action')).toHaveLength(2); + // Both reports plus the Security tab's HTML Security Export share the Engines-blue action role. + expect([...host.querySelectorAll('.client-information-action')].map(button => button.textContent)) + .toEqual(['Management Report', 'Technical Report', 'HTML Security Export']); + actions.push(host.querySelector('.client-security-panel .client-information-action')!); await act(async () => setEngineEnabled('report', false)); expect(actions.every(button => button.disabled && button.classList.contains('client-information-action'))).toBe(true); const css = readFileSync('src/styles/components.css', 'utf8'); diff --git a/tests/features/db-structure-layout.test.ts b/tests/features/db-structure-layout.test.ts index 10961a0..79ef7ae 100644 --- a/tests/features/db-structure-layout.test.ts +++ b/tests/features/db-structure-layout.test.ts @@ -52,7 +52,8 @@ describe('DB Structure information design', () => { it('uses the shared flat segment and tile grammar without legacy card decoration', () => { expect(styles).toMatch(/\.db-structure-summary\s*\{[^}]*padding:\s*var\(--segment-inset\);[^}]*background:\s*var\(--segment-surface\);[^}]*box-shadow:\s*none;/s); expect(styles).toMatch(/\.db-structure-layer\s*\{[^}]*padding:\s*var\(--segment-inset\);[^}]*border:\s*0;[^}]*background:\s*var\(--segment-surface\);[^}]*box-shadow:\s*none;/s); - expect(styles).toMatch(/\.db-structure-card\s*\{[^}]*border:\s*var\(--border-hair\) solid var\(--tile-border\);[^}]*border-radius:\s*0;[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*none;/s); + // Square tiles or the shared tile radius token; never a local radius value. + expect(styles).toMatch(/\.db-structure-card\s*\{[^}]*border:\s*var\(--border-hair\) solid var\(--tile-border\);[^}]*border-radius:\s*(?:0|var\(--tile-radius\));[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*none;/s); expect(styles).toMatch(/\.db-structure-card-header:hover\s*\{[^}]*background:\s*var\(--tile-surface-hover\);/s); expect(styles).toMatch(/\.db-structure-card-copy\s*>\s*strong\s*\{[^}]*font-size:\s*var\(--type-h4-size\);[^}]*font-weight:\s*var\(--fw-semibold\);/s); expect(styles).not.toContain('.db-structure-columns code');