- Implemented `owl_to_mermaid.py` to convert OWL/Turtle files into Mermaid class diagrams. - Implemented `owl_to_plantuml.py` to convert OWL/Turtle files into PlantUML class diagrams. - Added two new PlantUML files for custodian multi-aspect diagrams.
294 lines
9.3 KiB
TypeScript
294 lines
9.3 KiB
TypeScript
/**
|
|
* Tests for Results Table Component
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { ResultsTable } from '../../src/components/query/ResultsTable';
|
|
import type { SelectResults } from '../../src/lib/sparql/client';
|
|
|
|
describe('ResultsTable', () => {
|
|
const mockResults: SelectResults = {
|
|
head: { vars: ['subject', 'predicate', 'object'] },
|
|
results: {
|
|
bindings: [
|
|
{
|
|
subject: { type: 'uri', value: 'http://example.org/subject1' },
|
|
predicate: { type: 'uri', value: 'http://example.org/pred1' },
|
|
object: { type: 'literal', value: 'Object 1' },
|
|
},
|
|
{
|
|
subject: { type: 'uri', value: 'http://example.org/subject2' },
|
|
predicate: { type: 'uri', value: 'http://example.org/pred2' },
|
|
object: { type: 'literal', value: 'Object 2', 'xml:lang': 'en' },
|
|
},
|
|
{
|
|
subject: { type: 'bnode', value: '_:b1' },
|
|
predicate: { type: 'uri', value: 'http://example.org/pred3' },
|
|
object: {
|
|
type: 'literal',
|
|
value: '42',
|
|
datatype: 'http://www.w3.org/2001/XMLSchema#integer',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
describe('rendering', () => {
|
|
it('should render table with headers', () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
expect(screen.getByText('subject')).toBeInTheDocument();
|
|
expect(screen.getByText('predicate')).toBeInTheDocument();
|
|
expect(screen.getByText('object')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render all result rows', () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
expect(screen.getByText(/subject1/)).toBeInTheDocument();
|
|
expect(screen.getByText(/subject2/)).toBeInTheDocument();
|
|
expect(screen.getByText('_:b1')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display results summary', () => {
|
|
const { container } = render(<ResultsTable results={mockResults} />);
|
|
|
|
const summary = container.querySelector('.results-summary');
|
|
expect(summary).toHaveTextContent('3');
|
|
expect(summary).toHaveTextContent('result');
|
|
expect(summary).toHaveTextContent('3 columns');
|
|
});
|
|
|
|
it('should render empty state when no results', () => {
|
|
const emptyResults: SelectResults = {
|
|
head: { vars: [] },
|
|
results: { bindings: [] },
|
|
};
|
|
|
|
render(<ResultsTable results={emptyResults} />);
|
|
|
|
expect(screen.getByText('No results found')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('cell rendering', () => {
|
|
it('should render URI cells as links', () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
const uriLinks = screen.getAllByRole('link');
|
|
expect(uriLinks.length).toBeGreaterThan(0);
|
|
expect(uriLinks[0]).toHaveAttribute('target', '_blank');
|
|
});
|
|
|
|
it('should render literal cells with language tag', () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
expect(screen.getByText('Object 2')).toBeInTheDocument();
|
|
expect(screen.getByText('@en')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render literal cells with datatype', () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
expect(screen.getByText('42')).toBeInTheDocument();
|
|
expect(screen.getByText(/integer/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render blank nodes', () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
expect(screen.getByText('_:b1')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render empty cells with placeholder', () => {
|
|
const resultsWithEmpty: SelectResults = {
|
|
head: { vars: ['a', 'b'] },
|
|
results: {
|
|
bindings: [
|
|
{ a: { type: 'uri', value: 'http://test' } },
|
|
// b is missing
|
|
],
|
|
},
|
|
};
|
|
|
|
render(<ResultsTable results={resultsWithEmpty} />);
|
|
expect(screen.getByText('—')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('sorting', () => {
|
|
it('should sort ascending when clicking column header', () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
const objectHeader = screen.getByText('object').closest('th')!;
|
|
fireEvent.click(objectHeader);
|
|
|
|
// Check for ascending indicator
|
|
expect(screen.getByText('↑')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should sort descending on second click', () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
const objectHeader = screen.getByText('object').closest('th')!;
|
|
fireEvent.click(objectHeader);
|
|
fireEvent.click(objectHeader);
|
|
|
|
// Check for descending indicator
|
|
expect(screen.getByText('↓')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should clear sort on third click', () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
const objectHeader = screen.getByText('object').closest('th')!;
|
|
fireEvent.click(objectHeader);
|
|
fireEvent.click(objectHeader);
|
|
fireEvent.click(objectHeader);
|
|
|
|
// No sort indicator should be visible
|
|
expect(screen.queryByText('↑')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('↓')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('pagination', () => {
|
|
// Create large dataset (150 rows, should paginate)
|
|
const largeResults: SelectResults = {
|
|
head: { vars: ['id'] },
|
|
results: {
|
|
bindings: Array.from({ length: 150 }, (_, i) => ({
|
|
id: { type: 'literal', value: `Item ${i + 1}` },
|
|
})),
|
|
},
|
|
};
|
|
|
|
it('should show pagination controls for large datasets', () => {
|
|
render(<ResultsTable results={largeResults} />);
|
|
|
|
expect(screen.getByText('Next →')).toBeInTheDocument();
|
|
expect(screen.getByText('← Previous')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should navigate to next page', () => {
|
|
render(<ResultsTable results={largeResults} />);
|
|
|
|
const nextButton = screen.getByText('Next →');
|
|
fireEvent.click(nextButton);
|
|
|
|
expect(screen.getByText(/Page 2 of 2/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should navigate to previous page', () => {
|
|
render(<ResultsTable results={largeResults} />);
|
|
|
|
// Go to page 2 first
|
|
fireEvent.click(screen.getByText('Next →'));
|
|
|
|
// Then go back
|
|
fireEvent.click(screen.getByText('← Previous'));
|
|
|
|
expect(screen.getByText(/Page 1 of 2/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should disable previous button on first page', () => {
|
|
render(<ResultsTable results={largeResults} />);
|
|
|
|
const prevButton = screen.getByText('← Previous');
|
|
expect(prevButton).toBeDisabled();
|
|
});
|
|
|
|
it('should disable next button on last page', () => {
|
|
render(<ResultsTable results={largeResults} />);
|
|
|
|
// Navigate to last page
|
|
fireEvent.click(screen.getByText('Next →'));
|
|
|
|
const nextButton = screen.getByText('Next →');
|
|
expect(nextButton).toBeDisabled();
|
|
});
|
|
|
|
it('should not show pagination for small datasets', () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
expect(screen.queryByText('Next →')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('copy functionality', () => {
|
|
// Mock clipboard API
|
|
beforeEach(() => {
|
|
Object.assign(navigator, {
|
|
clipboard: {
|
|
writeText: vi.fn(() => Promise.resolve()),
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should copy cell value to clipboard', async () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
const copyButtons = screen.getAllByLabelText('Copy value');
|
|
fireEvent.click(copyButtons[0]);
|
|
|
|
await waitFor(() => {
|
|
expect(navigator.clipboard.writeText).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should show copied indicator after copy', async () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
const copyButtons = screen.getAllByLabelText('Copy value');
|
|
fireEvent.click(copyButtons[0]);
|
|
|
|
await waitFor(() => {
|
|
expect(copyButtons[0]).toHaveTextContent('✓');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('export functionality', () => {
|
|
it('should render export buttons when onExport provided', () => {
|
|
const onExport = vi.fn();
|
|
render(<ResultsTable results={mockResults} onExport={onExport} />);
|
|
|
|
expect(screen.getByText('Export CSV')).toBeInTheDocument();
|
|
expect(screen.getByText('Export JSON')).toBeInTheDocument();
|
|
expect(screen.getByText('Export JSON-LD')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should call onExport with correct format', () => {
|
|
const onExport = vi.fn();
|
|
render(<ResultsTable results={mockResults} onExport={onExport} />);
|
|
|
|
fireEvent.click(screen.getByText('Export CSV'));
|
|
expect(onExport).toHaveBeenCalledWith('csv');
|
|
|
|
fireEvent.click(screen.getByText('Export JSON'));
|
|
expect(onExport).toHaveBeenCalledWith('json');
|
|
|
|
fireEvent.click(screen.getByText('Export JSON-LD'));
|
|
expect(onExport).toHaveBeenCalledWith('jsonld');
|
|
});
|
|
|
|
it('should not render export buttons when onExport not provided', () => {
|
|
render(<ResultsTable results={mockResults} />);
|
|
|
|
expect(screen.queryByText('Export CSV')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('custom max height', () => {
|
|
it('should apply custom max height', () => {
|
|
const { container } = render(
|
|
<ResultsTable results={mockResults} maxHeight="400px" />
|
|
);
|
|
|
|
const wrapper = container.querySelector('.results-table-wrapper');
|
|
expect(wrapper).toHaveStyle({ maxHeight: '400px' });
|
|
});
|
|
});
|
|
});
|