// @vitest-environment jsdom import { act, useState } from 'react'; import { readFileSync } from 'node:fs'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { useApp } from '../../src/app/AppProvider'; import { initialAppState } from '../../src/app/app-state'; import { RelatedFindingsProvider, useRelatedViewRestore } from '../../src/features/related-findings/RelatedFindings'; import { RiskRegister } from '../../src/features/dashboard/RiskRegister'; import { appendRiskAcceptanceReview } from '../../src/domain/risk-acceptance'; import { defaultReview } from '../../src/domain/review'; import { riskCommand } from '../domain/risk-acceptance-fixture'; import { ObsidianGraphView } from '../../src/features/mitre-graph/ObsidianGraphView'; import { DatasetViewer } from '../../src/features/dataset-viewer/DatasetViewer'; import { makeSheet } from '../../src/importers/sheet-factory'; import { genericProfile } from '../../src/profiles/generic/generic-profile'; import { useWorkPackages } from '../../src/remediation/use-work-packages'; import { workRecordKey } from '../../src/remediation/work-package-index'; vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; }); vi.mock('../../src/remediation/use-work-packages', () => ({ useWorkPackages: vi.fn() })); vi.mock('../../src/features/context-layer/use-context-cluster-index', () => ({ useContextClusterIndex: () => undefined })); vi.mock('../../src/features/finding-reader/FindingContext', () => ({ FindingContext: () => null, FindingContextBadges: () => null, FindingContextSection: () => null })); vi.mock('../../src/features/search-filter/SearchFilterBar', () => ({ SearchFilterBar: () => null, SearchTextControl: () => null })); vi.mock('../../src/features/view-bookmarks/ViewBookmarks', () => ({ ViewBookmarks: () => null })); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe.each(['light', 'dark'])('same-change rings (%s)', theme => { it('focuses the exact group in Pathfinder, keeps the ring size independent of zoom, and restores graph selection and camera', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)); vi.stubGlobal('cancelAnimationFrame', clearTimeout); document.documentElement.dataset.theme = theme; const sheet = makeSheet('Synthetic graph', [['Name', 'Status', 'Severity', 'Category'], ['Declared SMB', 'Failed', 'High', 'Synthetic'], ['Observed SMB', 'Passed', 'High', 'Synthetic'], ['Unrelated', 'Failed', 'Low', 'Other']], '', 'synthetic.csv', { isProjectSheet: true }); const records = sheet.analysis.records; records.forEach((record, index) => { record.findingOccurrenceId = `synthetic-graph-${index}`; }); const pkg = { tasks: [{ id: 'same-smb', redundant: true, findingKeys: records.slice(0,2).map(workRecordKey) }] }; vi.mocked(useWorkPackages).mockReturnValue({ status: 'ready', index: { packages: [pkg], facet: { id: 'remediation:work-package', label: 'Work Package', values: [], valuesFor: () => [] } } } as never); const initial = { ...initialAppState, dataset: { sheets: [sheet], profileId: 'generic' }, activeSheet: sheet, activeSheetId: sheet.id, workScopeId: 'synthetic-graph', activeProfile: genericProfile, titleColumn: 'Name', statusFilter: 'all', selectedRecordId: records[2].id } as unknown as ReturnType; function Harness() { const [state, setState] = useState(initial); vi.mocked(useApp).mockReturnValue({ ...state, setSelectedRecord: id => setState(s => ({ ...s, selectedRecordId: id })), applyViewBookmark: saved => setState(s => ({ ...s, ...saved })), resetFilters: vi.fn(), setFacetFilter: vi.fn() }); return ; } const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); try { await act(async () => root.render()); const graph = host.querySelector('.obsidian-graph-surface')!; await act(async () => graph.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 800, clientX: 100, clientY: 100 }))); const unrelated = host.querySelector('[aria-label="Unrelated"]')!; await act(async () => unrelated.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))); const beforeCamera = graph.querySelector('g[transform]')!.getAttribute('transform'); const zoom = Number(beforeCamera!.match(/scale\(([^)]+)/)![1]); const counterZoom = Number(graph.querySelector('.graph-related-ring')!.getAttribute('transform')!.match(/scale\(([^)]+)/)![1]); expect(zoom * counterZoom).toBeCloseTo(1); expect(unrelated.getAttribute('aria-pressed')).toBe('true'); await act(async () => host.querySelector('.graph-related-ring .related-finding-ring')!.click()); expect(host.querySelectorAll('.obsidian-node-finding')).toHaveLength(2); expect(host.querySelectorAll('.graph-related-ring .related-finding-ring[aria-pressed="true"]')).toHaveLength(2); await act(async () => { host.querySelector('.graph-related-ring .related-finding-ring')!.click(); await new Promise(resolve => setTimeout(resolve, 20)); }); expect(host.querySelectorAll('.obsidian-node-finding')).toHaveLength(2); expect(graph.querySelector('g[transform]')!.getAttribute('transform')).toBe(beforeCamera); expect(host.querySelector('[aria-label="Unrelated"]')!.getAttribute('aria-pressed')).toBe('true'); } finally { await act(async () => root.unmount()); host.remove(); } }); it('opens the complete group from Accepted Risks and restores the register without changing its decision or count', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)); vi.stubGlobal('cancelAnimationFrame', clearTimeout); Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value() {} }); document.documentElement.dataset.theme = theme; const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity'], ['Declared SMB', 'Failed', 'High'], ['Observed SMB', 'Failed', 'High']], '', 'synthetic.csv', { isProjectSheet: true }); const records = sheet.analysis.records; records.forEach((record, index) => { record.findingOccurrenceId = `synthetic-occurrence-${index}`; }); const today = new Date().toISOString().slice(0,10); const risk = appendRiskAcceptanceReview(undefined, records[0].findingOccurrenceId!, riskCommand('accept', today), `${today}T10:00:00.000Z`); const reviews = { [records[0].id]: { ...defaultReview(), riskAcceptance: risk.riskAcceptance } }; const pkg = { id: 'package', code: 'WP-01', title: 'Synthetic', clusterLabel: 'Synthetic', facts: { tasks: 1, findings: 2 }, tasks: [{ id: 'same-smb', redundant: true, findingKeys: records.map(workRecordKey) }] }; vi.mocked(useWorkPackages).mockReturnValue({ status: 'ready', index: { packages: [pkg], packageFor: () => pkg, unassigned: () => false, facet: { id: 'remediation:work-package', label: 'Work Package', values: [], valuesFor: () => [] } } } as never); const app = { ...initialAppState, workScopeId: 'synthetic-risk', dataset: { sheets: [sheet], profileId: 'generic', fileName: 'Synthetic' }, activeSheet: sheet, activeSheetId: sheet.id, activeProfile: genericProfile, reviews, view: 'records', statusFilter: 'all', selectedRecordId: records[0].id, setSelectedRecord: vi.fn(), applyViewBookmark: vi.fn(), resetFilters: vi.fn(), setView: vi.fn(), updateReview: vi.fn() } as unknown as ReturnType; vi.mocked(useApp).mockReturnValue(app); const original = JSON.stringify(reviews); function Views() { const [work, setWork] = useState(false); useRelatedViewRestore(() => () => setWork(work)); return <> {work && }; } const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); try { await act(async () => root.render()); expect(host.querySelectorAll('.risk-entry-with-group')).toHaveLength(1); expect(host.querySelector('.client-risk-register-navigation header')!.textContent).toContain('1 recorded'); const details = host.querySelector('.risk-register-detail-group')!; details.open = true; await act(async () => host.querySelector('.risk-entry-with-group .related-finding-ring')!.click()); expect(host.querySelector('[data-testid="risk-view"]')!.hidden).toBe(true); expect(host.querySelectorAll('.record-card')).toHaveLength(2); await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); expect(host.querySelector('[data-testid="risk-view"]')!.hidden).toBe(false); expect(host.querySelectorAll('.risk-entry-with-group')).toHaveLength(1); expect(details.open).toBe(true); expect(JSON.stringify(reviews)).toBe(original); expect(app.updateReview).not.toHaveBeenCalled(); } finally { await act(async () => root.unmount()); host.remove(); } }); it('isolates every exact group member, fills their rings, and restores filters, selected finding, view and scroll without editing reviews', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)); vi.stubGlobal('cancelAnimationFrame', clearTimeout); vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener() {}, removeEventListener() {} })); Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: function(this: HTMLElement, options: ScrollToOptions | number = {}) { this.scrollTop = typeof options === 'number' ? options : options.top ?? 0; } }); document.documentElement.dataset.theme = theme; const sheet = makeSheet('Synthetic', [['Name','Status','Severity','Scope','Technical Identity'], ['SMB declaration','Failed','High','synthetic','smb-a'], ['SMB observation','Passed','Low','synthetic','smb-b'], ['SMB declaration','Failed','High','synthetic','unrelated'], ['Other change','Failed','Low','synthetic','other']], '', 'synthetic.csv'); sheet.isProjectSheet = true; const records = sheet.analysis.records; const sourceSheet = { ...sheet, id: 'source', isProjectSheet: false, analysis: { ...sheet.analysis, records: [{ ...records[0], id: 'source-alias' }] } }; const workPackage = { id: 'package', code: 'WP-01', clusterLabel: 'Synthetic', title: 'Synthetic', facts: { tasks: 2, findings: 3 }, tasks: [{ id: 'same-smb', redundant: true, findingKeys: records.slice(0,2).map(workRecordKey) }, { id: 'unrelated', redundant: false, findingKeys: [workRecordKey(records[2])] }], findingKeys: records.slice(0,3).map(workRecordKey) }; const index = { packages: [workPackage], packageFor: (r: typeof records[0]) => workPackage.findingKeys.includes(workRecordKey(r)) ? workPackage : undefined, unassigned: () => false, facet: { id: 'remediation:work-package', label: 'Work Package', kind: 'category', values: [], valuesFor: () => [] } }; vi.mocked(useWorkPackages).mockReturnValue({ status: 'ready', index } as never); const original = JSON.stringify(records); const style = document.createElement('style'); style.textContent = readFileSync('src/styles/components.css', 'utf8') + readFileSync('src/styles/legacy-views.css', 'utf8'); document.head.append(style); const reviews = { [records[0].id]: { include: true, decision: 'open', note: '' } }; let app: ReturnType; const initial = { ...initialAppState, theme, dataset: { sheets: [sheet, sourceSheet], fileName: 'Synthetic', profileId: 'generic' }, activeSheetId: sheet.id, activeSheet: sheet, activeProfile: genericProfile, workScopeId: 'synthetic', view: 'records', search: 'SMB declaration', statusFilter: 'all', actionOnly: true, reviews, pathFindingIds: [records[0].id], selectedRecordId: records[0].id } as unknown as ReturnType; const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); function Harness() { const [state, setState] = useState(initial); app = { ...state, setSelectedRecord: id => setState(s => ({ ...s, selectedRecordId: id })), setActiveSheet: id => setState(s => ({ ...s, activeSheetId: id, activeSheet: id === 'source' ? sourceSheet : sheet, selectedRecordId: id === 'source' ? 'source-alias' : records[0].id })), setView: view => setState(s => ({ ...s, view })), applyViewBookmark: saved => setState(s => ({ ...s, ...saved })), setFacetFilter: (id, value) => setState(s => { const facetFilters = { ...s.facetFilters }; if (value) facetFilters[id] = value; else delete facetFilters[id]; return { ...s, facetFilters }; }), updateReview: vi.fn(), resetFilters: () => setState(s => ({ ...s, search: '', actionOnly: false, pathFindingIds: [], facetFilters: {} })) }; vi.mocked(useApp).mockImplementation(() => app); return ; } try { await act(async () => root.render()); const list = host.querySelector('.record-list')!; list.scrollTop = 184; list.scrollLeft = 17; expect(host.querySelectorAll('.record-card')).toHaveLength(1); await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); expect(host.querySelectorAll('.record-card')).toHaveLength(2); expect(host.querySelectorAll('.record-list .related-finding-ring[aria-pressed="true"]')).toHaveLength(2); const activeRing = host.querySelector('.record-card.active')!.parentElement!.querySelector('.related-finding-ring')!; expect(Number(getComputedStyle(activeRing).zIndex)).toBeGreaterThan(Number(getComputedStyle(host.querySelector('.record-card.active')!).zIndex)); const rings = host.querySelectorAll('.record-list .related-finding-ring'); expect(rings[0].style.getPropertyValue('--related-color')).toBe(rings[1].style.getPropertyValue('--related-color')); await act(async () => app!.setSelectedRecord(records[1].id)); for (const ring of host.querySelectorAll('.record-list .related-finding-ring')) { expect(ring.getAttribute('aria-pressed')).toBe('true'); expect(ring.style.getPropertyValue('--related-color')).toBe(rings[0].style.getPropertyValue('--related-color')); expect(getComputedStyle(ring).zIndex).toBe('2'); } expect(host.querySelector('.record-card.active')!.parentElement!.querySelector('.related-finding-ring[aria-pressed="true"]')).not.toBeNull(); await act(async () => app!.setView('columns')); expect(host.querySelectorAll('.data-tile')).toHaveLength(2); expect(host.querySelectorAll('.data-tile .related-finding-ring[aria-pressed="true"]')).toHaveLength(2); await act(async () => app!.setView('table')); expect(host.querySelectorAll('tbody .related-finding-ring')).toHaveLength(2); await act(async () => app!.setView('raw')); expect(host.querySelectorAll('.related-raw-record .related-finding-ring')).toHaveLength(2); await act(async () => { host.querySelector('.related-finding-ring')!.click(); await new Promise(resolve => setTimeout(resolve, 20)); }); await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); expect(app!.view).toBe('records'); expect(app!.selectedRecordId).toBe(initial.selectedRecordId); expect(app!.search).toBe(initial.search); expect(app!.statusFilter).toBe(initial.statusFilter); expect(app!.actionOnly).toBe(true); expect(app!.pathFindingIds).toEqual(initial.pathFindingIds); expect(app!.facetFilters).toEqual(initial.facetFilters); expect(app!.reviews).toBe(reviews); expect(host.querySelectorAll('.record-card')).toHaveLength(1); expect(host.querySelector('.record-list')!.scrollTop).toBe(184); expect(host.querySelector('.record-list')!.scrollLeft).toBe(17); expect(JSON.stringify(records)).toBe(original); await act(async () => app!.resetFilters()); expect(host.querySelectorAll('.record-card')).toHaveLength(4); expect(host.querySelectorAll('.record-list .related-finding-ring')).toHaveLength(2); const unrelated = host.querySelector(`[data-record-id="${records[2].id}"]`)!; expect(unrelated.querySelector('.related-finding-ring')).toBeNull(); await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); expect(host.querySelectorAll('.record-card')).toHaveLength(2); await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); await act(async () => app!.setActiveSheet('source')); expect(host.querySelectorAll('.record-card')).toHaveLength(1); expect(host.querySelector('.record-list .related-finding-ring')).not.toBeNull(); await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); expect(host.querySelectorAll('.record-card')).toHaveLength(2); const peerLink = host.querySelector(`[data-work-package-finding="${records[1].id}"]`)!; expect(peerLink).not.toBeNull(); await act(async () => peerLink.click()); expect(app!.selectedRecordId).toBe(records[1].id); expect(host.querySelector('[data-work-package-hidden]')).toBeNull(); await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); expect(app!.activeSheetId).toBe('source'); expect(app!.selectedRecordId).toBe('source-alias'); expect(host.querySelectorAll('.record-card')).toHaveLength(1); await act(async () => app!.setActiveSheet(sheet.id)); await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); vi.mocked(useWorkPackages).mockReturnValue({ status: 'off' } as never); await act(async () => root.render()); expect(host.querySelector('.related-finding-ring')).toBeNull(); expect(host.querySelectorAll('.record-card')).toHaveLength(4); } finally { await act(async () => root.unmount()); host.remove(); style.remove(); } }); });