import { render, screen, fireEvent, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { NetworkGraph, type NetworkGraphControl } from './network-graph'; import type { GraphEdge, GraphNode } from './graph.types'; /** A tiny ontology: two classes and an individual. */ const NODES: GraphNode[] = [ { id: 'documento', label: 'Documento', type: 'Classe' }, { id: 'processo', label: 'Processo', type: 'Classe' }, { id: 'peticao', label: 'Petição inicial', type: 'Indivíduo' }, ]; const EDGES: GraphEdge[] = [ { source: 'peticao', target: 'documento', label: 'é um', relation: 'is-a' }, { source: 'documento', target: 'processo', label: 'pertence a', relation: 'part-of' }, ]; describe('NetworkGraph', () => { beforeEach(() => { // jsdom gives no canvas context and zero-sized elements. vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ setTransform: vi.fn(), clearRect: vi.fn(), beginPath: vi.fn(), arc: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), closePath: vi.fn(), fill: vi.fn(), stroke: vi.fn(), fillText: vi.fn(), strokeText: vi.fn(), setLineDash: vi.fn(), globalAlpha: 1, lineWidth: 1, lineCap: 'butt', strokeStyle: '', fillStyle: '', font: '', textAlign: 'start', textBaseline: 'alphabetic', } as unknown as CanvasRenderingContext2D); Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 800 }); Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 600, }); window.matchMedia = vi.fn().mockImplementation((query: string) => ({ matches: false, media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), })) as unknown as typeof window.matchMedia; }); afterEach(() => { vi.restoreAllMocks(); }); it('renders a canvas that describes the graph to assistive tech', () => { render(); expect( screen.getByRole('img', { name: /Relationship graph: 3 nodes, 2 relationships/ }) ).toBeInTheDocument(); }); it('accepts a custom accessible name', () => { render(); expect(screen.getByRole('img', { name: /Ontologia processual/ })).toBeInTheDocument(); }); it('is reachable by keyboard', () => { render(); expect(screen.getByRole('img')).toHaveAttribute('tabindex', '0'); }); it('cycles the selection with Tab', () => { const onSelect = vi.fn(); render(); fireEvent.keyDown(screen.getByRole('img'), { key: 'Tab' }); expect(onSelect).toHaveBeenCalledWith( 'documento', expect.objectContaining({ kind: 'node', id: 'documento' }) ); }); it('clears the selection with Escape', () => { const onSelect = vi.fn(); render(); const canvas = screen.getByRole('img'); fireEvent.keyDown(canvas, { key: 'Tab' }); fireEvent.keyDown(canvas, { key: 'Escape' }); expect(onSelect).toHaveBeenLastCalledWith(null, null); }); it('orbits with the arrow keys without selecting anything', () => { const onSelect = vi.fn(); render(); fireEvent.keyDown(screen.getByRole('img'), { key: 'ArrowRight' }); expect(onSelect).not.toHaveBeenCalled(); }); it('renders an empty state instead of a blank canvas', () => { render(); expect(screen.getByText('No nodes to display')).toBeInTheDocument(); expect(screen.queryByRole('img')).not.toBeInTheDocument(); }); it('accepts a custom empty state', () => { render(Sem vínculos no filtro

} />); expect(screen.getByText('Sem vínculos no filtro')).toBeInTheDocument(); }); describe('imperative control', () => { it('keeps ref as the container element and exposes the handle separately', () => { const container = React.createRef(); const control = React.createRef(); render(); expect(container.current).toBeInstanceOf(HTMLDivElement); expect(typeof control.current?.fitView).toBe('function'); }); it('focuses a node by id', () => { const onSelect = vi.fn(); const control = React.createRef(); render(); act(() => control.current!.focusNode('processo')); expect(onSelect).toHaveBeenCalledWith( 'processo', expect.objectContaining({ id: 'processo' }) ); }); it('ignores an unknown node id', () => { const onSelect = vi.fn(); const control = React.createRef(); render(); act(() => control.current!.focusNode('inexistente')); expect(onSelect).not.toHaveBeenCalled(); }); it('returns positions for persisting a hand-arranged diagram', () => { const control = React.createRef(); render(); const positions = control.current!.getPositions(); expect(Object.keys(positions).sort()).toEqual(['documento', 'peticao', 'processo']); expect(Number.isFinite(positions.documento.x)).toBe(true); }); }); it('lays out synchronously under reduced motion', () => { window.matchMedia = vi.fn().mockImplementation((query: string) => ({ matches: query.includes('reduced-motion'), media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), })) as unknown as typeof window.matchMedia; const control = React.createRef(); render(); // The diagram must appear already arranged, not visibly settling. const positions = control.current!.getPositions(); const spread = Math.max(...Object.values(positions).map(p => Math.hypot(p.x, p.y, p.z))); expect(spread).toBeGreaterThan(0); expect(Number.isFinite(spread)).toBe(true); }); });