Patch up some missing tests and fix+test channel add not clearing on channel submission without add-another checked

This commit is contained in:
Jack Kingsman
2026-03-02 18:02:53 -08:00
parent d8294a8383
commit f335fc56cc
11 changed files with 1174 additions and 8 deletions
+28 -4
View File
@@ -48,6 +48,15 @@ export function NewMessageModal({
const [loading, setLoading] = useState(false);
const hashtagInputRef = useRef<HTMLInputElement>(null);
const resetForm = () => {
setName('');
setContactKey('');
setRoomKey('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
};
const handleCreate = async () => {
setError('');
setLoading(true);
@@ -77,6 +86,7 @@ export function NewMessageModal({
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
}
resetForm();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create');
@@ -121,7 +131,15 @@ export function NewMessageModal({
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
resetForm();
onClose();
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
@@ -137,8 +155,7 @@ export function NewMessageModal({
value={tab}
onValueChange={(v) => {
setTab(v as Tab);
setName('');
setError('');
resetForm();
}}
className="w-full"
>
@@ -164,6 +181,7 @@ export function NewMessageModal({
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
});
resetForm();
onClose();
}}
>
@@ -294,7 +312,13 @@ export function NewMessageModal({
{error && <div className="text-sm text-destructive">{error}</div>}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<Button
variant="outline"
onClick={() => {
resetForm();
onClose();
}}
>
Cancel
</Button>
{tab === 'hashtag' && (
@@ -113,8 +113,8 @@ export function SettingsRadioSection({
const parsedCr = parseInt(cr, 10);
if (
[parsedLat, parsedLon, parsedTxPower, parsedFreq, parsedBw, parsedSf, parsedCr].some(
(v) => isNaN(v)
[parsedLat, parsedLon, parsedTxPower, parsedFreq, parsedBw, parsedSf, parsedCr].some((v) =>
isNaN(v)
)
) {
setError('All numeric fields must have valid values');
+185
View File
@@ -0,0 +1,185 @@
/**
* 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';
// Mock sonner (toast)
vi.mock('../components/ui/sonner', () => ({
toast: { success: vi.fn(), error: 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();
});
});
});
+194
View File
@@ -0,0 +1,194 @@
/**
* Tests for NewMessageModal form state reset.
*
* Verifies that form fields are cleared when the modal closes (via Create,
* Cancel, or Dialog dismiss) and when switching tabs.
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NewMessageModal } from '../components/NewMessageModal';
import type { Contact } from '../types';
// Mock sonner (toast)
vi.mock('../components/ui/sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const mockContact: Contact = {
public_key: 'aa'.repeat(32),
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
describe('NewMessageModal form reset', () => {
const onClose = vi.fn();
const onSelectConversation = vi.fn();
const onCreateContact = vi.fn().mockResolvedValue(undefined);
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.clearAllMocks();
});
function renderModal(open = true) {
return render(
<NewMessageModal
open={open}
contacts={[mockContact]}
undecryptedCount={5}
onClose={onClose}
onSelectConversation={onSelectConversation}
onCreateContact={onCreateContact}
onCreateChannel={onCreateChannel}
onCreateHashtagChannel={onCreateHashtagChannel}
/>
);
}
async function switchToTab(user: ReturnType<typeof userEvent.setup>, name: string) {
await user.click(screen.getByRole('tab', { name }));
}
describe('hashtag tab', () => {
it('clears name after successful Create', async () => {
const user = userEvent.setup();
const { unmount } = renderModal();
await switchToTab(user, 'Hashtag');
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
await user.type(input, 'testchan');
expect(input.value).toBe('testchan');
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(onCreateHashtagChannel).toHaveBeenCalledWith('#testchan', false);
});
expect(onClose).toHaveBeenCalled();
unmount();
// Re-render to simulate reopening — state should be reset
renderModal();
await switchToTab(user, 'Hashtag');
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
});
it('clears name when Cancel is clicked', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Hashtag');
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
await user.type(input, 'mychannel');
expect(input.value).toBe('mychannel');
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalled();
});
});
describe('new-contact tab', () => {
it('clears name and key after successful Create', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Contact');
await user.type(screen.getByPlaceholderText('Contact name'), 'Bob');
await user.type(screen.getByPlaceholderText('64-character hex public key'), 'bb'.repeat(32));
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false);
});
expect(onClose).toHaveBeenCalled();
});
});
describe('new-room tab', () => {
it('clears name and key after successful Create', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Room');
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(onCreateChannel).toHaveBeenCalledWith('MyRoom', 'cc'.repeat(16), false);
});
expect(onClose).toHaveBeenCalled();
});
});
describe('tab switching resets form', () => {
it('clears contact fields when switching to room tab', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Contact');
await user.type(screen.getByPlaceholderText('Contact name'), 'Bob');
await user.type(screen.getByPlaceholderText('64-character hex public key'), 'deadbeef');
// Switch to Room tab — fields should reset
await switchToTab(user, 'Room');
expect((screen.getByPlaceholderText('Room name') as HTMLInputElement).value).toBe('');
expect((screen.getByPlaceholderText('Pre-shared key (hex)') as HTMLInputElement).value).toBe(
''
);
});
it('clears room fields when switching to hashtag tab', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Room');
await user.type(screen.getByPlaceholderText('Room name'), 'SecretRoom');
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
await switchToTab(user, 'Hashtag');
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
});
});
describe('tryHistorical checkbox resets', () => {
it('resets tryHistorical when switching tabs', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Hashtag');
// Check the "Try decrypting" checkbox
const checkbox = screen.getByRole('checkbox', { name: /Try decrypting/ });
await user.click(checkbox);
// The streaming message should appear
expect(screen.getByText(/Messages will stream in/)).toBeTruthy();
// Switch tab and come back
await switchToTab(user, 'Contact');
await switchToTab(user, 'Hashtag');
// The streaming message should be gone (tryHistorical was reset)
expect(screen.queryByText(/Messages will stream in/)).toBeNull();
});
});
});
@@ -0,0 +1,176 @@
/**
* Tests for useContactsAndChannels hook.
*
* Focuses on pagination logic in fetchAllContacts (which fetches 1000 items
* per page and continues until a page returns fewer than pageSize results).
*/
import { act, renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
import type { Contact } from '../types';
// Mock api module
vi.mock('../api', () => ({
api: {
getContacts: vi.fn(),
getChannels: vi.fn(),
createContact: vi.fn(),
createChannel: vi.fn(),
deleteContact: vi.fn(),
deleteChannel: vi.fn(),
decryptHistoricalPackets: vi.fn(),
getUndecryptedPacketCount: vi.fn(),
},
}));
// Mock prefetch — takePrefetchOrFetch calls the fetcher directly
vi.mock('../prefetch', () => ({
takePrefetchOrFetch: vi.fn((_key: string, fetcher: () => Promise<unknown>) => fetcher()),
}));
// Mock sonner
vi.mock('../components/ui/sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// Mock messageCache
vi.mock('../messageCache', () => ({
remove: vi.fn(),
}));
function makeContact(suffix: string): Contact {
const key = suffix.padStart(64, '0');
return {
public_key: key,
name: `Contact-${suffix}`,
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
}
function makeContacts(count: number, startIndex = 0): Contact[] {
return Array.from({ length: count }, (_, i) =>
makeContact(String(startIndex + i).padStart(4, '0'))
);
}
describe('useContactsAndChannels', () => {
const setActiveConversation = vi.fn();
const pendingDeleteFallbackRef = { current: false };
const hasSetDefaultConversation = { current: false };
beforeEach(() => {
vi.clearAllMocks();
pendingDeleteFallbackRef.current = false;
hasSetDefaultConversation.current = false;
});
afterEach(() => {
vi.restoreAllMocks();
});
function renderUseContactsAndChannels() {
return renderHook(() =>
useContactsAndChannels({
setActiveConversation,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
})
);
}
describe('fetchAllContacts pagination', () => {
it('returns contacts directly when fewer than page size', async () => {
const { api } = await import('../api');
const contacts = makeContacts(50);
vi.mocked(api.getContacts).mockResolvedValueOnce(contacts);
const { result } = renderUseContactsAndChannels();
let fetched: Contact[] = [];
await act(async () => {
fetched = await result.current.fetchAllContacts();
});
expect(fetched).toHaveLength(50);
// Should only call once (no pagination needed)
expect(api.getContacts).toHaveBeenCalledTimes(1);
expect(api.getContacts).toHaveBeenCalledWith(1000, 0);
});
it('paginates when first page returns exactly page size', async () => {
const { api } = await import('../api');
const page1 = makeContacts(1000, 0);
const page2 = makeContacts(200, 1000);
vi.mocked(api.getContacts)
.mockResolvedValueOnce(page1) // First page: full
.mockResolvedValueOnce(page2); // Second page: partial (done)
const { result } = renderUseContactsAndChannels();
let fetched: Contact[] = [];
await act(async () => {
fetched = await result.current.fetchAllContacts();
});
expect(fetched).toHaveLength(1200);
expect(api.getContacts).toHaveBeenCalledTimes(2);
expect(api.getContacts).toHaveBeenNthCalledWith(1, 1000, 0);
expect(api.getContacts).toHaveBeenNthCalledWith(2, 1000, 1000);
});
it('paginates through multiple full pages', async () => {
const { api } = await import('../api');
const page1 = makeContacts(1000, 0);
const page2 = makeContacts(1000, 1000);
const page3 = makeContacts(500, 2000);
vi.mocked(api.getContacts)
.mockResolvedValueOnce(page1)
.mockResolvedValueOnce(page2)
.mockResolvedValueOnce(page3);
const { result } = renderUseContactsAndChannels();
let fetched: Contact[] = [];
await act(async () => {
fetched = await result.current.fetchAllContacts();
});
expect(fetched).toHaveLength(2500);
expect(api.getContacts).toHaveBeenCalledTimes(3);
expect(api.getContacts).toHaveBeenNthCalledWith(3, 1000, 2000);
});
it('handles exactly page size total (boundary case)', async () => {
const { api } = await import('../api');
const page1 = makeContacts(1000, 0);
const page2: Contact[] = []; // Empty second page
vi.mocked(api.getContacts).mockResolvedValueOnce(page1).mockResolvedValueOnce(page2);
const { result } = renderUseContactsAndChannels();
let fetched: Contact[] = [];
await act(async () => {
fetched = await result.current.fetchAllContacts();
});
expect(fetched).toHaveLength(1000);
expect(api.getContacts).toHaveBeenCalledTimes(2);
});
});
});