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('keeps graph text independent of node selection and removes in-graph detail cards', () => { 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 layout = readFileSync(new URL('../../src/styles/layout.css', import.meta.url), 'utf8'); expect(app).toContain(' setShowText(current => !current)}>Text"); expect(view).toContain('const showInlineLabel = showText && nodeIsInViewport && labelIsFullyVisible;'); expect(view).toContain('togglePinnedNode(operation.nodeId);'); expect(view).toContain('setShowText(true);'); expect(view).not.toContain('setShowText(savedView.showText);'); expect(view).toContain('className="obsidian-graph-inspector-content"'); expect(view).not.toContain('>Off'); expect(view).not.toContain('>Labels'); expect(view).not.toContain('setDisplayMode'); expect(view).not.toContain('obsidian-graph-cards'); expect(view).not.toContain('obsidian-graph-card-connectors'); expect(layout).toMatch(/\.viewer\.app-area-graph\s*\{[^}]*background-image:\s*var\(--app-grid-pattern\);/s); }); });