mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
212 lines
7.7 KiB
TypeScript
212 lines
7.7 KiB
TypeScript
/**
|
|
* 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<typeof vi.fn>;
|
|
error: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
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(
|
|
<MessageInput
|
|
onSend={onSend}
|
|
disabled={props.disabled ?? false}
|
|
conversationType={props.conversationType}
|
|
senderName={props.senderName}
|
|
placeholder="Type a message..."
|
|
/>
|
|
);
|
|
}
|
|
|
|
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.',
|
|
});
|
|
});
|
|
});
|
|
});
|