/** * Tests for MessageInput component. * * Verifies character/byte limit calculation, warning states, and send button * behavior for both DM and channel conversations. */ import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { MessageInput } from '../components/MessageInput'; import { toast } from '../components/ui/sonner'; // Mock sonner (toast) vi.mock('../components/ui/sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); const mockToast = toast as unknown as { success: ReturnType; error: ReturnType; }; const textEncoder = new TextEncoder(); function byteLen(s: string): number { return textEncoder.encode(s).length; } describe('MessageInput', () => { const onSend = vi.fn().mockResolvedValue(undefined); beforeEach(() => { vi.clearAllMocks(); }); function renderInput(props: { conversationType?: 'contact' | 'channel' | 'raw'; senderName?: string; disabled?: boolean; }) { return render( ); } function getInput() { return screen.getByPlaceholderText('Type a message...') as HTMLInputElement; } function getSendButton() { return screen.getByRole('button', { name: /send/i }) as HTMLButtonElement; } describe('send button state', () => { it('is disabled when text is empty', () => { renderInput({ conversationType: 'contact' }); expect(getSendButton()).toBeDisabled(); }); it('is enabled when text is entered', () => { renderInput({ conversationType: 'contact' }); fireEvent.change(getInput(), { target: { value: 'Hello' } }); expect(getSendButton()).toBeEnabled(); }); it('is disabled when whitespace-only', () => { renderInput({ conversationType: 'contact' }); fireEvent.change(getInput(), { target: { value: ' ' } }); expect(getSendButton()).toBeDisabled(); }); it('is disabled when disabled prop is true', () => { renderInput({ conversationType: 'contact', disabled: true }); fireEvent.change(getInput(), { target: { value: 'Hello' } }); expect(getSendButton()).toBeDisabled(); }); }); describe('byte counter display', () => { it('shows byte counter for DM conversations', () => { renderInput({ conversationType: 'contact' }); fireEvent.change(getInput(), { target: { value: 'Hello' } }); // Should show "5/156" somewhere (DM hard limit = 156) expect(screen.getByText(/5\/156/)).toBeTruthy(); }); it('shows byte counter for channel conversations', () => { renderInput({ conversationType: 'channel', senderName: 'MyNode' }); fireEvent.change(getInput(), { target: { value: 'Hello' } }); // Channel hard limit = 156 - byteLen("MyNode") - 2 = 156 - 6 - 2 = 148 expect(screen.getByText(/5\/148/)).toBeTruthy(); }); it('does not show byte counter for raw conversations', () => { renderInput({ conversationType: 'raw' }); fireEvent.change(getInput(), { target: { value: 'Hello' } }); // No counter should be visible expect(screen.queryByText(/\/\d+/)).toBeNull(); }); it('accounts for multi-byte characters in byte count', () => { renderInput({ conversationType: 'contact' }); // Emoji: "🥝" is 4 bytes in UTF-8 fireEvent.change(getInput(), { target: { value: '🥝' } }); const bytes = byteLen('🥝'); // Should be 4 expect(bytes).toBe(4); expect(screen.getByText(new RegExp(`${bytes}/156`))).toBeTruthy(); }); }); describe('channel limit adjusts for sender name', () => { it('reduces limit based on sender name byte length', () => { // Sender name "LongNodeName" = 12 bytes + 2 for ": " = 14 overhead // Hard limit = 156 - 14 = 142 renderInput({ conversationType: 'channel', senderName: 'LongNodeName' }); fireEvent.change(getInput(), { target: { value: 'x' } }); expect(screen.getByText(/1\/142/)).toBeTruthy(); }); it('uses default 10-byte name when sender name is absent', () => { // Default: 10 bytes + 2 = 12 overhead. Hard limit = 156 - 12 = 144 renderInput({ conversationType: 'channel' }); fireEvent.change(getInput(), { target: { value: 'x' } }); expect(screen.getByText(/1\/144/)).toBeTruthy(); }); it('handles multi-byte sender names correctly', () => { // "🥝Node" = 4 + 4 = 8 bytes name + 2 separator = 10 overhead // Hard limit = 156 - 10 = 146 const senderName = '🥝Node'; const nameBytes = byteLen(senderName); const expectedLimit = 156 - nameBytes - 2; renderInput({ conversationType: 'channel', senderName }); fireEvent.change(getInput(), { target: { value: 'x' } }); expect(screen.getByText(new RegExp(`1/${expectedLimit}`))).toBeTruthy(); }); }); describe('warning states', () => { it('shows warning text when exceeding DM warning threshold', () => { renderInput({ conversationType: 'contact' }); // DM warning threshold = 140 bytes const text = 'x'.repeat(141); fireEvent.change(getInput(), { target: { value: text } }); // Rendered in both desktop and mobile variants expect(screen.getAllByText(/may impact multi-repeater hop delivery/).length).toBeGreaterThan( 0 ); }); it('shows truncation warning when exceeding DM hard limit', () => { renderInput({ conversationType: 'contact' }); // DM hard limit = 156 bytes const text = 'x'.repeat(157); fireEvent.change(getInput(), { target: { value: text } }); // Rendered in both desktop and mobile variants expect(screen.getAllByText(/likely truncated by radio/).length).toBeGreaterThan(0); }); it('shows no warning for short messages', () => { renderInput({ conversationType: 'contact' }); fireEvent.change(getInput(), { target: { value: 'Hello' } }); expect(screen.queryByText(/truncated/)).toBeNull(); expect(screen.queryByText(/may impact/)).toBeNull(); }); }); describe('send button remains enabled past hard limit (current behavior)', () => { it('does not disable send button when over hard limit', () => { // NOTE: This documents the current behavior where canSubmit only checks // text.trim().length > 0, NOT the limit state. This is related to // hitlist item 1.1 — the send button stays enabled even over the limit. renderInput({ conversationType: 'contact' }); const text = 'x'.repeat(200); // Well over 156 byte limit fireEvent.change(getInput(), { target: { value: text } }); // Button is still enabled — canSubmit only checks non-empty text expect(getSendButton()).toBeEnabled(); }); }); describe('send failure toasts', () => { it('shows the radio no-response toast when the send outcome is unknown', async () => { onSend.mockRejectedValueOnce( new Error( 'Send command was issued to the radio, but no response was heard back. The message may or may not have sent successfully.' ) ); renderInput({ conversationType: 'contact' }); fireEvent.change(getInput(), { target: { value: 'Hello' } }); fireEvent.click(getSendButton()); expect(await screen.findByDisplayValue('Hello')).toBeTruthy(); expect(mockToast.error).toHaveBeenCalledWith('Radio did not confirm send', { description: 'Send command was issued to the radio, but no response was heard back. The message may or may not have sent successfully.', }); }); }); });