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);
});
});