import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import type { FindingPathItem, MitreGraphData } from '../../src/features/mitre-graph/mitre-graph'; import { buildObsidianGraph } from '../../src/features/mitre-graph/obsidian-graph'; const graphData: MitreGraphData = { version: 'synthetic', tacticOrder: ['Initial Access', 'Execution'], techniques: [ { id: 'T1001', name: 'Synthetic Entry', tactics: ['Initial Access'], platforms: [], parent: null, url: '', sourceCount: 0 }, { id: 'T1002', name: 'Synthetic Run', tactics: ['Execution'], platforms: [], parent: null, url: '', sourceCount: 0 } ], entities: [] }; const item = (id: string, techniqueId: string, tactic: string): FindingPathItem => ({ id, record: { id, index: 0, title: `Finding ${id}`, sourceSheet: 'Synthetic', fields: [], filled: [], meta: [] }, title: `Finding ${id}`, category: 'Synthetic', categories: ['Synthetic'], defenses: ['Synthetic defense'], targets: ['DC-01'], tactics: [tactic], primaryTactic: tactic, techniqueIds: [techniqueId], severity: 'high', weight: 1, score: 1, risk: 410, mappingOrigin: 'native' }); describe('Obsidian-style interactive graph', () => { it('builds a deterministic finding-technique-tactic network', () => { const findings = [item('one', 'T1001', 'Initial Access'), item('two', 'T1002', 'Execution')]; const first = buildObsidianGraph(findings, graphData); const second = buildObsidianGraph(findings, graphData); expect(first).toEqual(second); expect(first.nodes.filter(node => node.kind === 'finding')).toHaveLength(2); expect(first.nodes.filter(node => node.kind === 'category')).toHaveLength(1); expect(first.nodes.filter(node => node.kind === 'defense')).toHaveLength(1); expect(first.nodes.filter(node => node.kind === 'target')).toHaveLength(1); expect(first.nodes.filter(node => node.kind === 'technique')).toHaveLength(2); expect(first.nodes.filter(node => node.kind === 'tactic')).toHaveLength(2); expect(Math.max(...first.nodes.map(node => Math.hypot(node.x, node.y)))).toBeGreaterThan(300); expect(first.nodes.find(node => node.kind === 'tactic')?.radius).toBe(10); expect(first.nodes.find(node => node.kind === 'technique')?.radius).toBe(7); expect(first.edges).toEqual(expect.arrayContaining([ expect.objectContaining({ source: 'finding:one', target: 'technique:T1001' }), expect.objectContaining({ source: 'finding:one', target: 'category:Synthetic', kind: 'category' }), expect.objectContaining({ source: 'finding:one', target: 'defense:Synthetic defense', kind: 'defense' }), expect.objectContaining({ source: 'finding:one', target: 'target:DC-01', kind: 'target' }), expect.objectContaining({ source: 'technique:T1001', target: 'tactic:Initial Access', kind: 'technique' }) ])); }); it('connects tactic-only findings to every declared tactic without orphan hubs', () => { const finding = { ...item('multi', '', 'Initial Access'), techniqueIds: [], tactics: ['Initial Access', 'Execution'] }; const graph = buildObsidianGraph([finding], graphData); expect(graph.edges).toEqual(expect.arrayContaining([ expect.objectContaining({ source: 'finding:multi', target: 'tactic:Initial Access', kind: 'tactic' }), expect.objectContaining({ source: 'finding:multi', target: 'tactic:Execution', kind: 'tactic' }) ])); expect(graph.nodes.filter(node => node.kind === 'tactic').every(node => graph.edges.some(edge => edge.source === node.id || edge.target === node.id))).toBe(true); }); it('wires the graph view and confines the dotted background to Graph', () => { const app = readFileSync(new URL('../../src/app/App.tsx', import.meta.url), 'utf8'); const view = readFileSync(new URL('../../src/features/mitre-graph/ObsidianGraphView.tsx', import.meta.url), 'utf8'); const microCard = readFileSync(new URL('../../src/features/mitre-graph/PathfinderMicroCard.tsx', import.meta.url), 'utf8'); const layout = readFileSync(new URL('../../src/styles/layout.css', import.meta.url), 'utf8'); const graphStyles = readFileSync(new URL('../../src/styles/obsidian-graph.css', import.meta.url), 'utf8'); const dynamicFilterStyles = readFileSync(new URL('../../src/styles/dynamic-filters.css', import.meta.url), 'utf8'); const tokens = readFileSync(new URL('../../src/styles/ui-tokens.css', import.meta.url), 'utf8'); expect(app).toContain(' nodeLookup.get(nodeId)?.kind !== 'finding')"); expect(view).toContain('graphFacetSelectionForNode(node, graphFacets)'); expect(view).toContain('setFacetFilter(selection.facetId, { values: [selection.value] });'); expect(view).toContain('setFacetFilter(previous.facetId, null);'); expect(view).toContain('setFacetFilter(selection.facetId, null);'); expect(view).not.toContain("setSearch(node.label"); expect(view).toContain('relationNodeIdForFacetFilters(nodes, graphFacets, facetFilters)'); expect(view).toContain('className="obsidian-graph-branch-actions"'); expect(view).toContain("? 'Unmark all' : 'Mark all'"); expect(view).toContain("? 'Exit extract' : 'Extract'"); expect(view).toContain('onClick={resetGraphView}>Reset view'); expect(view).toContain('setPinnedNodeIds([]);'); expect(view).toContain('setMarkedSelectionIds([]);'); expect(view).toContain('setExtractedSelectionIds([]);'); expect(view).toContain('setNodeOverrides({});'); expect(view).toContain('setResetViewRevision(current => current + 1);'); expect(view).toContain('const animateCameraTo = useCallback((target: CameraState, duration = 620) =>'); expect(view).toContain('progress * progress * progress * (progress * (progress * 6 - 15) + 10)'); expect(view).toContain('const logarithmicScale = Math.exp('); expect(view).not.toContain('cameraMorphing'); expect(view).toContain("globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches"); expect(view).toContain('animateCameraTo(fittedCameraFor(displayedNodes, dimensions.width, dimensions.height))'); expect(view).toContain('animateCameraTo({ ...current, scale: clamp(current.scale * factor, 0.08, 4) }, 280);'); expect(view).toContain('() => animateCameraTo(fittedCameraFor(displayedNodes, dimensions.width, dimensions.height), 520)'); expect(view).toContain('const extractViewBefore = useRef(undefined);'); expect(view).toContain('const fittedExtractSignature = useRef(\'\');'); expect(view).toContain('if (fittedExtractSignature.current === extractSignature) return;'); expect(view).toContain('graphSelectionUnionNodeIds(graph, actionSelectionNodeIds)'); expect(view).toContain('setPinnedNodeIds(expandedNodeIds);'); expect(view).toContain('animateCameraTo(savedView.camera, 520);'); expect(view).toContain("extractedSelectionIds.length ? 'Exit extract' : 'Extract'"); expect(view).toContain('onClick={extractedSelectionIds.length ? exitSelectionExtract : extractSelection}'); expect(view).not.toContain('obsidian-node-card-actions'); expect(view).toContain('hasActiveGraphFilter && nodes.length'); expect(view).toContain("? FILTERED_GRAPH_SELECTION_ID : ''"); expect(view).toContain("kind: 'pan' | 'node' | 'card'"); expect(view).toContain('const [cardOverrides, setCardOverrides]'); expect(view).toContain('const [pinnedNodeIds, setPinnedNodeIds] = useState([]);'); expect(view).toContain('const lastFittedGraphSignature = useRef(\'\');'); expect(view).toContain("? [...current.filter(id => nodeLookup.get(id)?.kind === 'finding'), nodeId]"); expect(view).toContain('togglePinnedNode(operation.nodeId);'); expect(view).toContain('const isSelected = pinnedNodeIds.includes(node.id);'); expect(view).toContain('aria-pressed={isSelected}'); expect(view).toContain("const [displayMode, setDisplayMode] = useState('off');"); expect(view).toContain("type GraphDisplayMode = 'off' | 'labels' | 'text';"); expect(view).toContain("const showCards = displayMode === 'labels';"); expect(view).toContain("onClick={() => setDisplayMode('off')}>Off"); expect(view).toContain('>Labels'); expect(view).toContain('>Text'); expect(view).toContain("onClick={() => setDisplayMode(showCards ? 'off' : 'labels')}>Labels"); expect(view).toContain("onClick={() => setDisplayMode('text')}>Text"); expect(view).not.toContain("showCards ? 'On' : 'Off'"); expect(view).not.toContain("showText ? 'On' : 'Off'"); expect(view).toContain('const cardPlacements = useMemo'); expect(view).toContain("const hoverCardNodeId = hoveredNodeId && displayedNodeIdSet.has(hoveredNodeId) ? hoveredNodeId : '';"); expect(view).toContain('if ((!renderGraphCards && !hoverCardNodeId) || !displayedNodes.length) return [];'); expect(view).toContain('...(hoverCardNodeId ? [hoverCardNodeId] : [])'); expect(view).toContain('...(renderGraphCards ? pinnedNodeIds.filter(nodeId => displayedNodeIdSet.has(nodeId)) : [])'); expect(view).toContain("if (displayMode !== 'labels')"); expect(view).toContain("setDisplayMode('labels');"); expect(view).toContain("if (isPinned && !next.length) setDisplayMode('off');"); expect(view).toContain("isSelectionMuted ? ' is-selection-muted' : ''"); expect(view).toContain('const nodeIsInViewport = labelNodeScreenX >= viewportInset'); expect(view).toContain('const labelIsFullyVisible = labelLeft >= viewportInset'); expect(view).toContain('const showInlineLabel = showText && nodeIsInViewport && labelIsFullyVisible;'); expect(view).toContain('const activePathNodeIds = hoveredNodeId ? [hoveredNodeId] : pinnedNodeIds;'); expect(view).toContain("isDirectPath ? ' is-direct-path' : ''"); expect(view).toContain('className="obsidian-node-inline-label"'); expect(view).toContain("node.kind !== 'finding' && '); expect(view).toContain(''); expect(view).not.toContain('--graph-card-content-scale'); expect(view).not.toContain('cardContentStyle'); expect(view).toContain('Math.hypot(dx, dy) > 3'); expect(view).toContain('transform={`scale(${1 / camera.scale})`}'); expect(view).toContain('{cardPlacements.map'); expect(view).toContain("className={`obsidian-node-card-wrap${finding ? ' obsidian-node-matrix-card-wrap' : ''}`}"); expect(microCard).toContain('pathfinder-micro-card-finding obsidian-node-matrix-card'); expect(view).toContain(''); expect(view).toContain('resultColor={resultWindowFor(finding.id)?.color}'); expect(view).not.toContain('badgeLimit='); expect(view).toContain('onBookmark={() => updateReview(finding.id'); expect(view.indexOf('className="obsidian-graph-cards"')).toBeGreaterThan(view.indexOf('className="obsidian-graph-nodes"')); expect(view).not.toContain('scale(${1 / camera.scale})`}> \*\s*\{[^}]*flex:\s*0 1 auto;[^}]*max-width:\s*100%;[^}]*height:\s*auto;[^}]*min-height:\s*20px;[^}]*font-size:\s*var\(--pathfinder-micro-badge-size\);[^}]*white-space:\s*normal;/s); expect(graphStyles).not.toMatch(/\.obsidian-node-matrix-card \.data-tile-badges > \*\s*\{[^}]*text-overflow:\s*ellipsis;/s); expect(graphStyles).toMatch(/\.obsidian-graph-card\.is-selected \.obsidian-node-card,[\s\S]*?\.obsidian-graph-card\.is-selected \.obsidian-node-matrix-card \.data-tile\s*\{/s); expect(graphStyles).toMatch(/\.obsidian-node-matrix-card\s*\{[^}]*width:\s*100%;[^}]*height:\s*100%;/s); expect(graphStyles).not.toContain('--graph-card-content-scale'); expect(graphStyles).not.toContain('box-shadow'); expect(graphStyles).not.toContain('translateY(-2px)'); expect(graphStyles).toMatch(/\.obsidian-node-card \.data-tile-content h3\s*\{[^}]*font-size:\s*var\(--pathfinder-micro-title-size\);/s); expect(graphStyles).toMatch(/\.obsidian-node-inline-label\s*\{[^}]*font-size:\s*var\(--fs-lg\);/s); expect(graphStyles).toMatch(/\.obsidian-node-card \.reader-severity\s*\{[^}]*font-size:\s*var\(--pathfinder-micro-relation-badge-size\);/s); expect(graphStyles).not.toContain('.obsidian-node-card-actions'); expect(tokens).toContain('--pathfinder-micro-title-size: 23px;'); expect(tokens).toContain('--pathfinder-micro-badge-size: 14px;'); expect(tokens).toContain('--pathfinder-micro-relation-badge-size: 16px;'); expect(tokens).toContain('--pathfinder-micro-action-size: 14px;'); expect(tokens).toContain('--pathfinder-micro-action-group-radius: 8px;'); expect(tokens).toContain('--pathfinder-micro-action-radius: 6px;'); expect(graphStyles).not.toMatch(/#(?:000|fff)\b/i); expect(graphStyles).toMatch(/\.obsidian-node-role-ring\s*\{[^}]*fill:\s*none;[^}]*stroke:\s*currentColor;[^}]*stroke-width:\s*1\.5;/s); expect(graphStyles).toMatch(/\.obsidian-node-selection-ring\s*\{[^}]*stroke:\s*var\(--graph-selected\);[^}]*stroke-width:\s*3;/s); expect(graphStyles).toMatch(/\.obsidian-node-selection-ring\s*\{[^}]*filter:\s*none;/s); expect(graphStyles).toMatch(/\.obsidian-card-connector\s*\{[^}]*stroke:\s*var\(--graph-selected\);[^}]*stroke-width:\s*2;/s); expect(graphStyles).toMatch(/\.obsidian-graph-edges line\.is-branch-path\s*\{[^}]*stroke:\s*var\(--graph-selected\);/s); expect(graphStyles).not.toContain('.obsidian-category-context-menu'); expect(graphStyles).not.toContain('!important'); expect(graphStyles).toMatch(/\.obsidian-node-category,[\s\S]*\.obsidian-node-tactic\s*\{\s*color:\s*var\(--graph-relation\);/); expect(graphStyles).toMatch(/\.obsidian-node-card-technique \.data-tile-hex,[\s\S]*\.obsidian-node-card-tactic \.data-tile-hex\s*\{\s*background:\s*var\(--graph-relation\);/); expect(tokens.match(/--graph-relation:\s*#9333EA;/g)).toHaveLength(2); expect(graphStyles).toMatch(/\.obsidian-node-finding\.severity-critical\s*\{[^}]*color:\s*var\(--critical\);/s); expect(graphStyles).toMatch(/\.obsidian-node-finding\.severity-unknown\s*\{[^}]*color:\s*var\(--graph-unknown\);/s); expect(graphStyles).toMatch(/line\.is-direct-path\s*\{[^}]*stroke:\s*var\(--graph-alert\);[^}]*stroke-width:\s*2;/s); expect(view).not.toContain('className="obsidian-node-alert"'); expect(view).toContain('className="obsidian-graph-layout"'); expect(view).toContain('className="obsidian-graph-filter-panel"'); expect(view).toContain('className="obsidian-graph-stage"'); expect(graphStyles).not.toContain('camera-morph-glow'); expect(view).not.toContain('className="obsidian-graph-legend"'); expect(graphStyles).not.toContain('.obsidian-graph-legend'); expect(view).toContain('className={`obsidian-graph-inspector'); expect(graphStyles).toMatch(/\.obsidian-graph-layout\s*\{[^}]*grid-template-columns:\s*clamp\(220px, 17vw, 280px\) minmax\(0, 1fr\) clamp\(270px, 21vw, 340px\);/s); expect(view).toContain(''); expect(view).not.toContain(''); expect(view).not.toContain('Source'); expect(view).toContain("import { hiddenPathfinderFacetLabels } from './pathfinder-filter-trail';"); expect(view).not.toContain("'Source: Scan'] as const"); expect(view).toContain(''); expect(view).toContain('className="obsidian-graph-workspace-tools"'); expect(view).toContain('checked={actionOnly} disabled={!marked} onChange={() => toggleWorkspace()}'); expect(view).toContain('My Workspace'); expect(view).toContain('mode="path"'); expect(view).toContain('path={pathSnapshot}'); expect(view).toContain("activeNodeIds: [...pinnedNodeIds]"); expect(view).toContain('pinnedNodeIds.includes(edge.source) || pinnedNodeIds.includes(edge.target)'); expect(view).toContain('const isDirectPath = activePathNodeIds.includes(edge.source) || activePathNodeIds.includes(edge.target);'); expect(app).toContain('onApplyPathSnapshot={(bookmark: ViewBookmark) =>'); expect(app).toContain('app.setPathFindingIds(pathSnapshotFindingIds(bookmark.path));'); expect(app).toContain('hidden={activeArea !== \'graph\'}'); expect(app).toContain('active={activeArea === \'graph\'}'); expect(view).toContain('if (!active || lastFittedGraphSignature.current === graphSignature) return;'); expect(view).toContain('lastFittedGraphSignature.current = graphSignature;'); expect(dynamicFilterStyles).toMatch(/\.view-bookmark-item\.is-path\s*\{[^}]*outline:\s*2px solid var\(--graph-alert\);/s); expect(graphStyles).toMatch(/\.obsidian-graph-workspace-tools\s*\{[^}]*border-top:\s*var\(--bw-hairline\) solid var\(--border-default\);/s); expect(graphStyles).toMatch(/\.obsidian-graph-workspace-tools \.view-bookmark-bar,[^{]*\{[^}]*flex-direction:\s*column;/s); expect(view).toContain("const visibleRecords = useFilteredRecords('deviation');"); expect(view).toContain('buildFindingPathItems(visibleRecords'); expect(view).not.toContain('const [category, setCategory]'); expect(view).not.toContain('const [severity, setSeverity]'); expect(view).not.toContain('const [originFilter, setOriginFilter]'); expect(view).not.toContain('const [search, setSearch]'); expect(layout).toMatch(/\.viewer\.app-area-graph\s*\{[^}]*background-image:\s*var\(--app-grid-pattern\);/s); expect(layout).not.toMatch(/\.app-shell:not\(\.app-shell-work\) \.viewer\.app-area\s*\{[^}]*background-image:/s); expect(layout).toContain('.app-shell.sidebar-collapsed > main > aside { visibility: hidden; }'); expect(layout).toContain('.app-shell > main > aside { visibility: hidden; }'); expect(layout).not.toContain('.sidebar-collapsed aside { visibility: hidden; }'); expect(tokens.match(/--app-grid-pattern:/g)).toHaveLength(2); }); });