/** * RowBinding Component Tests * ADR-024: Chat & Message Architecture */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RowBinding, BoundRow } from '../RowBinding'; // Mock apiClient vi.mock('@/shared/utils/apiClient', () => ({ apiClient: { get: vi.fn() } })); import { apiClient } from '@/shared/utils/apiClient'; const mockTables = [ { id: 10, name: 'Tasks', icon: 'πŸ“‹', display_column: 'name' }, { id: 11, name: 'Projects', icon: 'πŸ“', display_column: 'title' } ]; const mockRows = [ { id: 100, table_id: 10, data: { name: 'Task 1' } }, { id: 101, table_id: 10, data: { name: 'Task 2' } }, { id: 102, table_id: 10, data: { name: 'Task 3' } } ]; describe('RowBinding', () => { let queryClient: QueryClient; const mockOnBind = vi.fn(); const mockOnUnbind = vi.fn(); beforeEach(() => { vi.clearAllMocks(); queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); // Setup API mocks (apiClient.get as any).mockImplementation((url: string) => { if (url.includes('/tables?')) { return Promise.resolve({ success: true, data: mockTables }); } if (url.includes('/rows')) { return Promise.resolve({ success: true, data: { rows: mockRows } }); } return Promise.resolve({ success: false, data: [] }); }); }); const renderComponent = (props = {}) => { return render( ); }; it('should render placeholder when no bindings', () => { renderComponent(); expect(screen.getByText('ΠŸΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ ΠΊ записи...')).toBeInTheDocument(); }); it('should expand panel on click', async () => { renderComponent({ spaceId: 1 }); fireEvent.click(screen.getByText('ΠŸΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ ΠΊ записи...')); await waitFor(() => { expect(screen.getByText('Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ привязку')).toBeInTheDocument(); }); }); it('should show bound rows count', () => { const boundRows: BoundRow[] = [ { table_id: 10, row_id: 100, row_title: 'Task 1' }, { table_id: 10, row_id: 101, row_title: 'Task 2' } ]; renderComponent({ boundRows }); expect(screen.getByText('2 привязка(ΠΎΠΊ)')).toBeInTheDocument(); }); it('should display bound row titles', () => { const boundRows: BoundRow[] = [ { table_id: 10, row_id: 100, row_title: 'Task 1', table_name: 'Tasks' } ]; renderComponent({ boundRows }); // Expand panel to see full list fireEvent.click(screen.getByText('1 привязка(ΠΎΠΊ)')); // Multiple elements with "Task 1" is expected (header preview + list) expect(screen.getAllByText('Task 1').length).toBeGreaterThanOrEqual(1); }); it('should show table selector when expanded', async () => { renderComponent({ spaceId: 1 }); fireEvent.click(screen.getByText('ΠŸΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ ΠΊ записи...')); await waitFor(() => { expect(screen.getByText('Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ Ρ‚Π°Π±Π»ΠΈΡ†Ρƒ')).toBeInTheDocument(); }); }); it('should load tables when expanded', async () => { renderComponent({ spaceId: 1 }); fireEvent.click(screen.getByText('ΠŸΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ ΠΊ записи...')); await waitFor(() => { expect(screen.getByText('Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ Ρ‚Π°Π±Π»ΠΈΡ†Ρƒ')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ Ρ‚Π°Π±Π»ΠΈΡ†Ρƒ')); await waitFor(() => { expect(screen.getByText('Tasks')).toBeInTheDocument(); expect(screen.getByText('Projects')).toBeInTheDocument(); }); }); it('should show rows after table selected', async () => { renderComponent({ spaceId: 1, defaultTableId: 10 }); fireEvent.click(screen.getByText('ΠŸΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ ΠΊ записи...')); await waitFor(() => { expect(screen.getByPlaceholderText('Поиск записСй...')).toBeInTheDocument(); }); await waitFor(() => { expect(screen.getByText('Task 1')).toBeInTheDocument(); expect(screen.getByText('Task 2')).toBeInTheDocument(); }); }); it('should call onBind when row selected', async () => { renderComponent({ spaceId: 1, defaultTableId: 10 }); fireEvent.click(screen.getByText('ΠŸΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ ΠΊ записи...')); await waitFor(() => { expect(screen.getByText('Task 1')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Task 1')); expect(mockOnBind).toHaveBeenCalledWith(10, 100, expect.objectContaining({ tableName: 'Tasks', rowTitle: 'Task 1' })); }); it('should call onUnbind when remove clicked', async () => { const boundRows: BoundRow[] = [ { table_id: 10, row_id: 100, row_title: 'Task 1' } ]; renderComponent({ boundRows, spaceId: 1 }); fireEvent.click(screen.getByText('1 привязка(ΠΎΠΊ)')); await waitFor(() => { expect(screen.getByText('ΠŸΡ€ΠΈΠ²ΡΠ·Π°Π½Π½Ρ‹Π΅ записи')).toBeInTheDocument(); }); // Find trash button const trashButtons = screen.getAllByRole('button').filter(btn => btn.querySelector('svg.lucide-trash-2') ); if (trashButtons.length > 0) { fireEvent.click(trashButtons[0]); expect(mockOnUnbind).toHaveBeenCalledWith(10, 100); } }); it('should disable already bound rows', async () => { const boundRows: BoundRow[] = [ { table_id: 10, row_id: 100, row_title: 'Task 1' } ]; renderComponent({ boundRows, spaceId: 1, defaultTableId: 10 }); fireEvent.click(screen.getByText('1 привязка(ΠΎΠΊ)')); await waitFor(() => { const task1Buttons = screen.getAllByRole('button').filter( btn => btn.textContent?.includes('Task 1') ); // Should have at least one disabled const disabledBtn = task1Buttons.find(btn => btn.hasAttribute('disabled')); expect(disabledBtn || screen.getByText('привязано')).toBeTruthy(); }); }); it('should show max bindings message when limit reached', () => { const boundRows: BoundRow[] = [ { table_id: 10, row_id: 100, row_title: 'Task 1' }, { table_id: 10, row_id: 101, row_title: 'Task 2' } ]; renderComponent({ boundRows, maxBindings: 2, spaceId: 1 }); fireEvent.click(screen.getByText('2 привязка(ΠΎΠΊ)')); expect(screen.getByText('Достигнут Π»ΠΈΠΌΠΈΡ‚ привязок (2)')).toBeInTheDocument(); }); it('should search rows', async () => { renderComponent({ spaceId: 1, defaultTableId: 10 }); fireEvent.click(screen.getByText('ΠŸΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ ΠΊ записи...')); await waitFor(() => { expect(screen.getByPlaceholderText('Поиск записСй...')).toBeInTheDocument(); }); const searchInput = screen.getByPlaceholderText('Поиск записСй...'); fireEvent.change(searchInput, { target: { value: 'Task 2' } }); // Search is passed to API, so we just check input works expect(searchInput).toHaveValue('Task 2'); }); it('should show no space message when spaceId not provided', () => { renderComponent({ boundRows: [] }); fireEvent.click(screen.getByText('ΠŸΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ ΠΊ записи...')); expect(screen.getByText('Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ Space Π² настройках Ρ‡Π°Ρ‚Π°')).toBeInTheDocument(); }); it('should collapse panel on collapse button', async () => { renderComponent({ spaceId: 1 }); fireEvent.click(screen.getByText('ΠŸΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ ΠΊ записи...')); await waitFor(() => { expect(screen.getByText('Π‘Π²Π΅Ρ€Π½ΡƒΡ‚ΡŒ')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Π‘Π²Π΅Ρ€Π½ΡƒΡ‚ΡŒ')); await waitFor(() => { expect(screen.queryByText('Π‘Π²Π΅Ρ€Π½ΡƒΡ‚ΡŒ')).not.toBeInTheDocument(); }); }); describe('compact mode', () => { it('should show inline tags in compact mode', () => { const boundRows: BoundRow[] = [ { table_id: 10, row_id: 100, row_title: 'Task 1' }, { table_id: 10, row_id: 101, row_title: 'Task 2' } ]; renderComponent({ boundRows, compact: true }); expect(screen.getByText('Task 1')).toBeInTheDocument(); expect(screen.getByText('Task 2')).toBeInTheDocument(); }); it('should allow unbind from compact view', () => { const boundRows: BoundRow[] = [ { table_id: 10, row_id: 100, row_title: 'Task 1' } ]; renderComponent({ boundRows, compact: true }); // Find X button in tag const closeButtons = screen.getAllByRole('button').filter(btn => btn.querySelector('svg.lucide-x') ); if (closeButtons.length > 0) { fireEvent.click(closeButtons[0]); expect(mockOnUnbind).toHaveBeenCalledWith(10, 100); } }); it('should show add button in compact mode', () => { const boundRows: BoundRow[] = [ { table_id: 10, row_id: 100, row_title: 'Task 1' } ]; renderComponent({ boundRows, compact: true, maxBindings: 5 }); // Should have + button const plusButtons = screen.getAllByRole('button').filter(btn => btn.querySelector('svg.lucide-plus') ); expect(plusButtons.length).toBeGreaterThan(0); }); }); });