299 lines
23 KiB
TypeScript
299 lines
23 KiB
TypeScript
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('<ObsidianGraphView');
|
|
expect(view).toContain('onWheel={handleWheel}');
|
|
expect(view).toContain("import { linkPingCastleSectionSheets } from '../../profiles/pingcastle/pingcastle-section-links';");
|
|
expect(view).toContain('...linkPingCastleSectionSheets(item.record, dataset.sheets)');
|
|
expect(view).toContain('onPointerDown={startPan}');
|
|
expect(view).toContain('startNodeDrag(event, node)');
|
|
expect(view).toContain('startCardDrag(event, placement)');
|
|
expect(view).not.toContain('openCategoryContextMenu(event, node)');
|
|
expect(view).toContain("'Mark all'");
|
|
expect(view).toContain('const actionSelectionNodeIds = actionBranchNodeId ? [actionBranchNodeId] : [];');
|
|
expect(view).toContain(".find(nodeId => 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}><span aria-hidden="true">○</span>Reset view</button>');
|
|
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<ExtractViewState | undefined>(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<string[]>([]);');
|
|
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<GraphDisplayMode>('off');");
|
|
expect(view).toContain("type GraphDisplayMode = 'off' | 'labels' | 'text';");
|
|
expect(view).toContain("const showCards = displayMode === 'labels';");
|
|
expect(view).toContain("onClick={() => setDisplayMode('off')}>Off</button>");
|
|
expect(view).toContain('>Labels</button>');
|
|
expect(view).toContain('>Text</button>');
|
|
expect(view).toContain("onClick={() => setDisplayMode(showCards ? 'off' : 'labels')}>Labels</button>");
|
|
expect(view).toContain("onClick={() => setDisplayMode('text')}>Text</button>");
|
|
expect(view).not.toContain("showCards ? 'On' : 'Off'");
|
|
expect(view).not.toContain("showText ? 'On' : 'Off'");
|
|
expect(view).toContain('const cardPlacements = useMemo<GraphCardPlacement[]>');
|
|
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' && <circle className=\"obsidian-node-role-ring\"");
|
|
expect(view).toContain('const graphCardScale = adaptiveGraphCardScale(');
|
|
expect(view).toContain('const minimumCardZoom = minimumReadableGraphCardZoom(pinnedNodeIds.length);');
|
|
expect(view).toContain('const graphCardsSuppressed = showCards && camera.scale < minimumCardZoom;');
|
|
expect(view).toContain('if ((!renderGraphCards && !hoverCardNodeId) || !displayedNodes.length) return [];');
|
|
expect(view).toContain('className="obsidian-graph-card-zoom-hint"');
|
|
expect(view).toContain('pathfinderMicroCardContract.screenScale.minimum');
|
|
expect(view).toContain('pathfinderMicroCardContract.screenScale.maximum');
|
|
expect(microCard).toContain('screenScale: { minimum: 0.7, maximum: 0.76 }');
|
|
expect(view).toContain('const graphFindingCardWidth = pathfinderMicroCardContract.finding.width;');
|
|
expect(view).toContain('const graphFindingCardHeight = pathfinderMicroCardContract.finding.height;');
|
|
expect(view).toContain('const graphRoleCardWidth = pathfinderMicroCardContract.relation.width;');
|
|
expect(view).toContain('const graphRoleCardHeight = pathfinderMicroCardContract.relation.height;');
|
|
expect(view).toContain('const graphCategoryCardWidth = pathfinderMicroCardContract.category.width;');
|
|
expect(view).toContain('const graphCategoryCardHeight = pathfinderMicroCardContract.category.height;');
|
|
expect(microCard).toContain('screenScale: { minimum: 0.7, maximum: 0.76 }');
|
|
expect(microCard).toContain('finding: { width: 240, height: 232 }');
|
|
expect(microCard).toContain('relation: { width: 190, height: 130 }');
|
|
expect(microCard).toContain('category: { width: 220, height: 150 }');
|
|
expect(microCard).toContain("data-pathfinder-micro-card={variant}");
|
|
expect(view).toContain('<PathfinderMicroCard variant="finding">');
|
|
expect(view).toContain('<PathfinderMicroCard variant="relation" relationKind={node.kind}>');
|
|
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('<g className="obsidian-graph-cards">{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('<TileCard');
|
|
expect(view).toContain('const graphCenterX = graphNodes.length');
|
|
expect(view).toContain('const placeOnRight = node.x <= graphCenterX;');
|
|
expect(view).toContain('const x = placeOnRight ? node.x + fixedGap : node.x - fixedGap - width;');
|
|
expect(view).toContain('const baseY = node.y - height / 2;');
|
|
expect(view).not.toContain('const viewportLeft =');
|
|
expect(view).not.toContain('const nodeScreenX =');
|
|
expect(view).not.toContain('clamp(preferredX');
|
|
expect(view).toContain('const collisionGap = extractedNodeIds ? 18 : 10;');
|
|
expect(view).toContain('const maximumOffsetRing = Math.min(8, Math.max(4, Math.ceil(Math.sqrt(orderedNodeIds.length)) + 2));');
|
|
expect(view).toContain('const laneCount = extractedNodeIds ? 4 : 3;');
|
|
expect(view).toContain('const activeEdgeSegments = graph.edges.flatMap');
|
|
expect(view).toContain('const maximumTetherDistance = graphCardMaximumTetherDistance(width, height);');
|
|
expect(view).toContain('const collisionFreeCandidates = nearbyCandidates.filter(fits);');
|
|
expect(view).toContain('const automatic = bestNearbyCardCandidate(');
|
|
expect(view).toContain('onClickCapture={event =>');
|
|
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})`}><foreignObject');
|
|
expect(microCard).toContain('pathfinder-micro-card-relation obsidian-node-card data-tile');
|
|
expect(view).toContain('className="data-tile-hex"');
|
|
expect(view).toContain('className="data-tile-badges"');
|
|
expect(view).not.toContain('outcomeForStatusValue');
|
|
expect(view).toContain('graphLabel(node.label)');
|
|
expect(graphStyles).toMatch(/\.obsidian-node-card-wrap\s*\{[^}]*pointer-events:\s*auto;/s);
|
|
expect(graphStyles).toMatch(/\.obsidian-node-card\.data-tile\s*\{[^}]*width:\s*100%;[^}]*height:\s*100%;/s);
|
|
expect(graphStyles).toMatch(/\.obsidian-node-card\.data-tile\s*\{[^}]*grid-template-columns:\s*18px minmax\(0, 1fr\);/s);
|
|
expect(graphStyles).toMatch(/\.obsidian-node-card \.data-tile-content h3\s*\{[^}]*overflow-wrap:\s*normal;[^}]*word-break:\s*normal;/s);
|
|
expect(graphStyles).toMatch(/\.obsidian-node-matrix-card \.data-tile\s*\{[^}]*width:\s*100%;/s);
|
|
expect(graphStyles).toMatch(/\.obsidian-node-matrix-card \.data-tile\s*\{[^}]*grid-template-columns:\s*18px minmax\(0, 1fr\) 18px;[^}]*padding:\s*var\(--space-3\);[^}]*overflow:\s*hidden;/s);
|
|
expect(graphStyles).toMatch(/\.obsidian-node-matrix-card \.data-tile-content h3\s*\{[^}]*font-size:\s*var\(--pathfinder-micro-title-size\);/s);
|
|
expect(graphStyles).toMatch(/\.obsidian-node-matrix-card \.data-tile-content h3\s*\{[^}]*-webkit-line-clamp:\s*5;/s);
|
|
expect(graphStyles).toMatch(/\.obsidian-node-matrix-card \.data-tile-content\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\);[^}]*overflow:\s*hidden;/s);
|
|
expect(graphStyles).toMatch(/\.obsidian-node-matrix-card \.data-tile-badges\s*\{[^}]*flex-wrap:\s*wrap;[^}]*max-height:\s*none;[^}]*align-content:\s*start;/s);
|
|
expect(graphStyles).toMatch(/\.obsidian-node-matrix-card \.data-tile-badges > \*\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('<SearchTextControl />');
|
|
expect(view).not.toContain('<SheetAndSearchControls />');
|
|
expect(view).not.toContain('<span className="obsidian-panel-label">Source</span>');
|
|
expect(view).toContain("import { hiddenPathfinderFacetLabels } from './pathfinder-filter-trail';");
|
|
expect(view).not.toContain("'Source: Scan'] as const");
|
|
expect(view).toContain('<SearchFilterBar hiddenFacetLabels={hiddenPathfinderFacetLabels} />');
|
|
expect(view).toContain('className="obsidian-graph-workspace-tools"');
|
|
expect(view).toContain('checked={actionOnly} disabled={!marked} onChange={() => toggleWorkspace()}');
|
|
expect(view).toContain('<span>My Workspace</span>');
|
|
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);
|
|
});
|
|
});
|