/** * 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(); expect(screen.getByText('subject')).toBeInTheDocument(); expect(screen.getByText('predicate')).toBeInTheDocument(); expect(screen.getByText('object')).toBeInTheDocument(); }); it('should render all result rows', () => { render(); expect(screen.getByText(/subject1/)).toBeInTheDocument(); expect(screen.getByText(/subject2/)).toBeInTheDocument(); expect(screen.getByText('_:b1')).toBeInTheDocument(); }); it('should display results summary', () => { const { container } = render(); 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(); expect(screen.getByText('No results found')).toBeInTheDocument(); }); }); describe('cell rendering', () => { it('should render URI cells as links', () => { render(); 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(); expect(screen.getByText('Object 2')).toBeInTheDocument(); expect(screen.getByText('@en')).toBeInTheDocument(); }); it('should render literal cells with datatype', () => { render(); expect(screen.getByText('42')).toBeInTheDocument(); expect(screen.getByText(/integer/)).toBeInTheDocument(); }); it('should render blank nodes', () => { render(); 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(); expect(screen.getByText('—')).toBeInTheDocument(); }); }); describe('sorting', () => { it('should sort ascending when clicking column header', () => { render(); 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(); 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(); 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(); expect(screen.getByText('Next →')).toBeInTheDocument(); expect(screen.getByText('← Previous')).toBeInTheDocument(); }); it('should navigate to next page', () => { render(); const nextButton = screen.getByText('Next →'); fireEvent.click(nextButton); expect(screen.getByText(/Page 2 of 2/)).toBeInTheDocument(); }); it('should navigate to previous page', () => { render(); // 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(); const prevButton = screen.getByText('← Previous'); expect(prevButton).toBeDisabled(); }); it('should disable next button on last page', () => { render(); // 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(); 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(); 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(); 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(); 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(); 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(); expect(screen.queryByText('Export CSV')).not.toBeInTheDocument(); }); }); describe('custom max height', () => { it('should apply custom max height', () => { const { container } = render( ); const wrapper = container.querySelector('.results-table-wrapper'); expect(wrapper).toHaveStyle({ maxHeight: '400px' }); }); }); });