// @vitest-environment jsdom import { act, createElement } from 'react'; import { readFileSync } from 'node:fs'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AssessorApiClient } from '../../src/api/assessor-api-client'; import type { DbStructureTable } from '../../src/domain/db-structure'; import { DbStructurePanel } from '../../src/features/db-structure/DbStructurePanel'; import { EngineDataFlow } from '../../src/features/db-structure/EngineDataFlow'; import { DATA_FLOW_RELATIONSHIPS } from '../../src/features/db-structure/DataFlowRelationships'; import { buildDbStructureFlow, dbStructureNeighbours, DB_FLOW_STEPS } from '../../src/features/db-structure/db-structure-flow'; const table = (name: string, references: string[] = [], rows: number | null = null): DbStructureTable => ({ name: `assessor_${name}`, layer: 'other', approximateRowCount: rows, columns: [{ name: 'id', dataType: 'text', nullable: false }], foreignKeys: references.map(target => ({ column: `${target}_id`, referencedTable: `assessor_${target}`, referencedColumn: 'id' })) }); describe('DB Structure data flow', () => { afterEach(() => vi.restoreAllMocks()); it.each(['dark', 'light'])('keeps every engine framed after deselection without highlighting arrows in %s', async theme => { document.documentElement.dataset.theme = theme; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const container = document.createElement('div'); const root = createRoot(container); try { await act(async () => root.render(createElement(EngineDataFlow))); const engines = [...container.querySelectorAll('.db-engine-flow__node.is-engine')]; expect(engines.map(engine => engine.dataset.node)).toEqual(['core', 'context', 'report', 'compliance']); const connectionCount: Record = { core: 3, context: 3, report: 3, compliance: 4 }; // Remove the initial Core selection; identity frames must remain independently. await act(async () => engines[0].click()); const highlighted = () => container.querySelectorAll('.db-engine-flow__edges > path.is-related'); expect(highlighted()).toHaveLength(0); for (const engine of engines) { expect(engine.classList.contains('is-engine')).toBe(true); expect(engine.getAttribute('aria-pressed')).toBe('false'); await act(async () => engine.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))); expect(highlighted()).toHaveLength(0); await act(async () => engine.click()); expect(engine.getAttribute('aria-pressed')).toBe('true'); const selectedPaths = [...highlighted()].map(path => path.getAttribute('d')); expect(selectedPaths).toHaveLength(connectionCount[engine.dataset.node!]); await act(async () => engine.click()); expect(highlighted()).toHaveLength(0); expect(engine.classList.contains('is-engine')).toBe(true); } const css = readFileSync('src/styles/datahub.css', 'utf8'); // Engine identity must contrast with ordinary related-node borders in both themes. expect(css).toMatch(/\.db-engine-flow__node\.is-engine\s*\{[^}]*outline:\s*var\(--bw-emphasis\) solid var\(--muted\)/); expect(css).toMatch(/\.db-engine-flow__node\.is-engine\s*\{[^}]*border-left:\s*var\(--marker-width\) solid var\(--muted\)/); expect(css).toMatch(/\.db-engine-flow__node\[aria-pressed="true"\]\s*\{[^}]*outline:[^;]*var\(--marking-color\)/); expect(css).toMatch(/\.db-engine-flow__edges > path\.is-related\s*\{[^}]*stroke:\s*var\(--marking-color\)/); } finally { await act(async () => root.unmount()); } }); it('selects engine modules, highlights only their directed connections and shows the implemented Context publication', async () => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const container = document.createElement('div'); const root = createRoot(container); await act(async () => root.render(createElement(EngineDataFlow))); expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(13); expect(container.querySelectorAll('.db-engine-flow__edges > path')).toHaveLength(13); expect(container.querySelectorAll('.db-engine-flow__edges > path[marker-start]')).toHaveLength(3); expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(3); expect(container.querySelector('.db-engine-flow__dictionary')?.hasAttribute('open')).toBe(false); const context = [...container.querySelectorAll('.db-engine-flow__node')] .find(button => button.textContent?.startsWith('Context Engine'))!; await act(async () => context.click()); expect(context.getAttribute('aria-pressed')).toBe('true'); expect(container.querySelector('.db-engine-flow__inspector h4')?.textContent).toBe('Context Engine'); expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(3); // The Management Report consumes the published Context results, so no relationship is drawn as planned. expect(container.querySelector('.db-engine-flow__edges > path.is-planned')).toBeNull(); expect(container.querySelector('.db-engine-flow__inspector')?.textContent).toContain('Versioned Context publication for the Management Report'); expect(container.querySelector('.db-engine-flow__inspector')?.textContent).not.toContain('Planned'); await act(async () => context.click()); expect(container.querySelector('.db-engine-flow__inspector')).toBeNull(); expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(0); // The Compliance Engine reads through the port, owns its tables and reaches the UI only through extension points. const compliance = [...container.querySelectorAll('.db-engine-flow__node')] .find(button => button.textContent?.startsWith('Compliance Engine'))!; await act(async () => compliance.click()); expect(container.querySelector('.db-engine-flow__inspector h4')?.textContent).toBe('Compliance Engine'); expect([...container.querySelectorAll('.db-engine-flow__inspector li strong')].map(item => item.textContent)).toEqual([ 'Core read-only port → Compliance Engine', 'Compliance Engine ↔ Compliance tables', 'Compliance Engine ↔ Retrieval & model', 'Compliance Engine → ISO mapping in the UI']); await act(async () => compliance.click()); expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(13); const dictionary = container.querySelector('.db-engine-flow__dictionary')!; await act(async () => dictionary.querySelector('summary')!.click()); expect(dictionary.open).toBe(true); expect(dictionary.querySelector('summary')?.textContent).toBe('All relationships (13)'); expect(dictionary.querySelectorAll('tbody tr')).toHaveLength(13); act(() => root.unmount()); }); it('assigns each table to exactly one step and never hides an unassigned table', () => { const assigned = DB_FLOW_STEPS.flatMap(step => step.tables); expect(new Set(assigned).size).toBe(assigned.length); const flow = buildDbStructureFlow([table('projects'), table('gpo_backup_instances', ['projects']), table('synthetic_new_table')]); expect(flow.steps.map(step => step.id)).toEqual(['foundation', 'gpo-inventory']); expect(flow.other.map(item => item.name)).toEqual(['assessor_synthetic_new_table']); }); it('lists outgoing and incoming relations of the selected table', () => { const tables = [table('projects'), table('assessments', ['projects']), table('reviews', ['assessments', 'projects'])]; expect(dbStructureNeighbours(tables, 'assessor_assessments')).toEqual({ outgoing: ['assessor_projects'], incoming: ['assessor_reviews'] }); }); it('keeps every arrow outside tiles after selecting a tile and resizing to a stacked flow', async () => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; vi.spyOn(AssessorApiClient.prototype, 'getDatabaseStructure').mockResolvedValue({ generatedAt: '2026-10-02T10:00:00.000Z', persistence: 'postgresql', tables: DB_FLOW_STEPS.flatMap(step => step.tables.map(name => table(name))) }); const desktop: Record = { foundation: [454, 100, 358, 100], upload: [454, 296, 358, 180], 'scanner-processing': [0, 700, 358, 200], 'scanner-mapping': [0, 1096, 358, 100], 'scanner-canonical': [0, 1392, 358, 180], 'scanner-findings': [0, 1768, 358, 220], 'gpo-inventory': [454, 700, 358, 100], 'gpo-identity': [454, 996, 358, 200], 'gpo-aggregation': [454, 1392, 358, 180], 'gpo-evaluation': [454, 1768, 358, 230], 'gpo-findings': [454, 2194, 358, 140], 'reference-baselines': [908, 700, 358, 280], 'result-materialization': [454, 2530, 358, 180], 'result-db': [227, 2906, 358, 460], review: [681, 2906, 358, 140], comparison: [454, 3562, 358, 240], other: [454, 3998, 358, 100] }; let positions = desktop; let width = 1266; const originalBounds = HTMLElement.prototype.getBoundingClientRect; vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { if (this.classList.contains('db-flow')) return new DOMRect(0, 0, width, 7000); const box = positions[this.dataset.flowId ?? '']; return box ? new DOMRect(...box) : originalBounds.call(this); }); const container = document.createElement('div'); const root = createRoot(container); await act(async () => { root.render(createElement(DbStructurePanel, { backendStatus: 'connected' })); await new Promise(resolve => setTimeout(resolve, 0)); }); const assertClear = () => { const paths = [...container.querySelectorAll('.db-flow__edges > path')]; expect(paths).toHaveLength(17); const visibleBoxes = [...container.querySelectorAll('[data-flow-id]')] .map(node => positions[node.dataset.flowId!]); for (const path of paths) { let x = 0, y = 0; for (const command of path.getAttribute('d')!.matchAll(/([MHV])(-?[\d.]+)(?:\s+(-?[\d.]+))?/g)) { const nextX = command[1] === 'V' ? x : Number(command[2]); const nextY = command[1] === 'H' ? y : Number(command[1] === 'V' ? command[2] : command[3]); if (command[1] !== 'M') for (const [left, top, boxWidth, height] of visibleBoxes) { const intersects = Math.max(x, nextX) > left && Math.min(x, nextX) < left + boxWidth && Math.max(y, nextY) > top && Math.min(y, nextY) < top + height; expect(intersects, `${path.getAttribute('data-relationship')} crosses tile at ${left},${top}`).toBe(false); } x = nextX; y = nextY; } const relation = DATA_FLOW_RELATIONSHIPS.find(([from, to]) => `${from}-${to}` === path.getAttribute('data-relationship'))!; const [left, top, boxWidth, height] = positions[relation[1]]; const distance = Math.hypot(Math.max(left - x, 0, x - left - boxWidth), Math.max(top - y, 0, y - top - height)); expect(distance).toBeCloseTo(4); } }; assertClear(); const select = container.querySelector('[data-flow-id="scanner-findings"] .db-flow__select')!; await act(async () => select.click()); let y = 100; positions = Object.fromEntries(Object.entries(desktop).map(([id, box]) => { const stacked: [number, number, number, number] = [164, y, 358, box[3]]; y += box[3] + 96; return [id, stacked]; })); width = 686; await act(async () => window.dispatchEvent(new Event('resize'))); assertClear(); expect(select.getAttribute('aria-pressed')).toBe('true'); expect(container.querySelectorAll('.db-flow__edges > path.is-related')).toHaveLength(4); expect(document.querySelector('.floating-data-window')).toBeNull(); act(() => root.unmount()); }); it('opens the selected flow table in a popup and returns to the unchanged flow on close', async () => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; vi.spyOn(AssessorApiClient.prototype, 'getDatabaseStructure').mockResolvedValue({ generatedAt: '2026-09-25T10:00:00.000Z', persistence: 'postgresql', tables: [table('projects', [], 0), table('import_artifacts', ['projects'], 12), table('scan_runs', ['import_artifacts']), table('gpo_backup_instances', ['import_artifacts']), table('microsoft_security_baselines'), table('reviews', ['projects']), table('finding_occurrences'), table('policy_finding_occurrences'), table('policy_evaluation_results'), table('result_db_rows'), table('comparisons')] }); const container = document.createElement('div'); const root = createRoot(container); await act(async () => { root.render(createElement(DbStructurePanel, { backendStatus: 'connected' })); await new Promise(resolve => setTimeout(resolve, 0)); }); expect([...container.querySelectorAll('.db-flow__lane-title')].map(item => item.textContent)).toEqual(['Scanner reports', 'GPO backups', 'Reference data']); expect(container.querySelector('.db-structure-intro-copy')).toBeNull(); expect(container.querySelector('.db-flow__header p')).toBeNull(); expect(container.querySelector('.db-flow .db-flow__header h3')?.textContent).toBe('Data flow'); expect([...container.querySelectorAll('.db-engine-flow__node.is-engine strong')].map(item => item.textContent)) .toEqual(['Core Engine', 'Context Engine', 'Report Engine', 'Compliance Engine']); const relations = container.querySelector('table[aria-label="Directed engine relationships"]'); expect(relations?.querySelectorAll('tbody tr')).toHaveLength(13); expect(relations?.textContent).toContain('Core read-only port'); expect(relations?.textContent).not.toContain('Planned'); expect(relations?.textContent).toContain('Versioned Context publication for the Management Report'); await act(async () => { container.querySelector('.db-structure-intro-actions button')!.click(); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(container.querySelectorAll('.db-engine-flow__node.is-engine')).toHaveLength(4); expect(container.querySelectorAll('.db-engine-flow tbody tr')).toHaveLength(13); expect(container.querySelector('.db-structure-intro-actions button')?.textContent).toBe('Refresh schema'); expect(container.querySelector('.db-flow__lane.is-reference')?.textContent).toContain('Input to GPO step'); const chips = [...container.querySelectorAll('.db-flow__table')]; const flowStepCount = container.querySelectorAll('.db-flow__step').length; // Permanent acceptance: missing consumer/merge/reference arrows remain visible // through table selection, popup navigation and closing the popup. const edgeIds = () => [...container.querySelectorAll('.db-flow__edges > path')] .map(edge => edge.getAttribute('data-relationship')); const requiredEdges = ['scanner-findings-result-materialization', 'gpo-findings-result-materialization', 'result-materialization-result-db', 'scanner-findings-review', 'gpo-findings-review', 'scanner-findings-comparison', 'reference-baselines-gpo-evaluation']; expect(edgeIds()).not.toContain('gpo-findings-comparison'); expect(edgeIds()).toEqual(expect.arrayContaining(requiredEdges)); const originalBounds = HTMLElement.prototype.getBoundingClientRect; vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { const id = this.dataset.flowId; const positions: Record = { 'scanner-findings': [0, 100, 358, 100], 'gpo-findings': [400, 180, 358, 200], 'result-materialization': [400, 450, 358, 150], 'review': [600, 700, 358, 150], }; const box = positions[id ?? '']; return box ? new DOMRect(...box) : originalBounds.call(this); }); await act(async () => window.dispatchEvent(new Event('resize'))); const mergePath = container.querySelector('[data-relationship="scanner-findings-result-materialization"]')!.getAttribute('d')!; // The cross-lane horizontal segment must clear the taller GPO tile. const crossLaneY = Number(mergePath.match(/V([\d.]+) H/)![1]); expect(crossLaneY).toBeGreaterThan(380); expect(crossLaneY).toBeLessThan(450); for (const edge of container.querySelectorAll('.db-flow__edges > path')) { expect(edge.getAttribute('marker-end')).toMatch(/^url\(#/); expect(edge.querySelector('title')?.textContent).toContain('→'); } // A zero estimate is not shown as a row count. expect(chips.find(chip => chip.textContent?.startsWith('projects'))?.textContent).toBe('projects'); expect(chips.find(chip => chip.textContent?.startsWith('import_artifacts'))?.textContent).toBe('import_artifacts~12'); await act(async () => chips.find(chip => chip.textContent?.startsWith('import_artifacts'))!.click()); expect(container.querySelectorAll('.db-flow__table[aria-pressed="true"]')).toHaveLength(1); expect(container.querySelector('.db-flow__edges [data-relationship="foundation-upload"]')?.classList.contains('is-related')).toBe(true); expect([...new Set([...container.querySelectorAll('.db-flow__table[data-related]')] .map(chip => `${chip.textContent}:${chip.getAttribute('data-related')}`))]) .toEqual(['projects:outgoing', 'scan_runs:incoming', 'gpo_backup_instances:incoming']); expect(container.querySelector('.db-flow__detail')).toBeNull(); const popup = document.querySelector('.floating-data-window[role="dialog"]'); expect(popup?.textContent).toContain('assessor_import_artifacts'); expect(popup?.querySelector('[aria-label="Columns in assessor_import_artifacts"]')).not.toBeNull(); expect(popup?.textContent).toContain('Referenced by'); await act(async () => [...popup!.querySelectorAll('.db-flow__links button')] .find(button => button.textContent === 'scan_runs')!.click()); expect(popup?.querySelector('h2')?.textContent).toBe('assessor_scan_runs'); await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); expect(document.querySelector('.floating-data-window')).toBeNull(); expect(container.querySelectorAll('.db-flow__table[aria-pressed="true"]')).toHaveLength(0); expect(container.querySelectorAll('.db-flow__step')).toHaveLength(flowStepCount); expect(edgeIds()).toEqual(expect.arrayContaining(requiredEdges)); expect(container.querySelectorAll('.db-flow__edges > path.is-related')).toHaveLength(0); const findingTile = container.querySelector('[data-flow-id="scanner-findings"]')!; const findingSelect = findingTile.querySelector('.db-flow__select')!; await act(async () => findingTile.querySelector('p')!.click()); expect(findingTile.dataset.selected).toBe('true'); expect(findingSelect.getAttribute('aria-pressed')).toBe('true'); const markedPaths = [...container.querySelectorAll('.db-flow__edges > path.is-related')]; expect(markedPaths.map(path => path.getAttribute('data-relationship')).sort()).toEqual([ 'scanner-findings-comparison', 'scanner-findings-result-materialization', 'scanner-findings-review' ]); for (const path of markedPaths) { const markerId = path.getAttribute('marker-end')!.slice(5, -1); expect(container.querySelector(`[id="${markerId}"] path`)?.classList.contains('is-related')).toBe(true); } // Tile selection does not open a table popup; chips keep their own action. expect(document.querySelector('.floating-data-window')).toBeNull(); await act(async () => findingTile.querySelector('.db-flow__table')!.click()); expect(document.querySelector('.floating-data-window')).not.toBeNull(); await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); expect(findingTile.dataset.selected).toBe('true'); await act(async () => findingSelect.click()); expect(findingTile.dataset.selected).toBe('false'); expect(container.querySelectorAll('.db-flow__edges > path.is-related')).toHaveLength(0); // The layered table dictionary stays available below the flow. expect(container.querySelector('details.db-structure-dictionary summary')?.textContent).toBe('Table dictionary by layer'); act(() => root.unmount()); }); }); describe('DB Structure read views', () => { it('assigns stored disclosed Result DB rows to their Core-owned materialization step', () => { const flow = buildDbStructureFlow([table('result_db_rows'), table('result_db_state')]); expect(flow.steps.map(step => step.id)).toEqual(['result-materialization']); expect(flow.other).toEqual([]); }); it('shows the Result DB as a read view over tables owned by the processing steps', async () => { const { buildDbStructureViews } = await import('../../src/features/db-structure/db-structure-flow'); const views = buildDbStructureViews([table('finding_occurrences'), table('policy_evaluation_results'), table('reviews')]); expect(views.map(view => [view.title, view.resolved.map(item => item.name)])).toEqual([ ['Result DB', ['assessor_finding_occurrences', 'assessor_policy_evaluation_results']] ]); // Owning steps stay unique: a read view never claims a table. expect(buildDbStructureFlow([table('finding_occurrences')]).other).toEqual([]); }); });