Testing blitz!

This commit is contained in:
Jack Kingsman
2026-02-10 16:47:46 -08:00
parent cf6df506d1
commit 875f197812
9 changed files with 1181 additions and 7 deletions

View File

@@ -462,9 +462,10 @@ export function MessageList({
)}
</>
)}
{msg.outgoing && (msg.acked > 0
? msg.paths && msg.paths.length > 0
? <span
{msg.outgoing &&
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<span
className="cursor-pointer hover:text-primary"
onClick={(e) => {
e.stopPropagation();
@@ -480,8 +481,12 @@ export function MessageList({
}}
title="View echo paths"
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
: `${msg.acked > 1 ? msg.acked : ''}`
: ' ?')}
) : (
`${msg.acked > 1 ? msg.acked : ''}`
)
) : (
' ?'
))}
</div>
</div>
</div>

View File

@@ -751,8 +751,8 @@ export function SettingsModal({
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Favorite contacts load first, then recent non-repeater contacts until this
limit is reached (1-1000)
Favorite contacts load first, then recent non-repeater contacts until this limit
is reached (1-1000)
</p>
</div>

View File

@@ -0,0 +1,246 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
api: {
getRadioConfig: vi.fn(),
getSettings: vi.fn(),
getUndecryptedPacketCount: vi.fn(),
getChannels: vi.fn(),
getContacts: vi.fn(),
toggleFavorite: vi.fn(),
updateSettings: vi.fn(),
getHealth: vi.fn(),
sendAdvertisement: vi.fn(),
rebootRadio: vi.fn(),
createChannel: vi.fn(),
decryptHistoricalPackets: vi.fn(),
createContact: vi.fn(),
deleteChannel: vi.fn(),
deleteContact: vi.fn(),
sendChannelMessage: vi.fn(),
sendDirectMessage: vi.fn(),
requestTrace: vi.fn(),
updateRadioConfig: vi.fn(),
setPrivateKey: vi.fn(),
migratePreferences: vi.fn(),
},
toast: {
success: vi.fn(),
error: vi.fn(),
},
hookFns: {
setMessages: vi.fn(),
fetchMessages: vi.fn(async () => {}),
fetchOlderMessages: vi.fn(async () => {}),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
incrementUnread: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
handleTelemetryRequest: vi.fn(),
handleRepeaterCommand: vi.fn(),
},
}));
vi.mock('../api', () => ({
api: mocks.api,
}));
vi.mock('../useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
vi.mock('../hooks', () => ({
useConversationMessages: () => ({
messages: [],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
setMessages: mocks.hookFns.setMessages,
fetchMessages: mocks.hookFns.fetchMessages,
fetchOlderMessages: mocks.hookFns.fetchOlderMessages,
addMessageIfNew: mocks.hookFns.addMessageIfNew,
updateMessageAck: mocks.hookFns.updateMessageAck,
}),
useUnreadCounts: () => ({
unreadCounts: {},
mentions: {},
lastMessageTimes: {},
incrementUnread: mocks.hookFns.incrementUnread,
markAllRead: mocks.hookFns.markAllRead,
trackNewMessage: mocks.hookFns.trackNewMessage,
}),
useRepeaterMode: () => ({
repeaterLoggedIn: false,
activeContactIsRepeater: false,
handleTelemetryRequest: mocks.hookFns.handleTelemetryRequest,
handleRepeaterCommand: mocks.hookFns.handleRepeaterCommand,
}),
getMessageContentKey: () => 'content-key',
}));
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />,
}));
vi.mock('../components/Sidebar', () => ({
Sidebar: () => <div data-testid="sidebar" />,
}));
vi.mock('../components/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
}));
vi.mock('../components/MessageInput', () => ({
MessageInput: React.forwardRef((_props, ref) => {
React.useImperativeHandle(ref, () => ({ appendText: vi.fn() }));
return <div data-testid="message-input" />;
}),
}));
vi.mock('../components/NewMessageModal', () => ({
NewMessageModal: () => null,
}));
vi.mock('../components/SettingsModal', () => ({
SettingsModal: () => null,
}));
vi.mock('../components/RawPacketList', () => ({
RawPacketList: () => null,
}));
vi.mock('../components/MapView', () => ({
MapView: () => null,
}));
vi.mock('../components/VisualizerView', () => ({
VisualizerView: () => null,
}));
vi.mock('../components/CrackerPanel', () => ({
CrackerPanel: () => null,
}));
vi.mock('../components/ui/sheet', () => ({
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/ui/sonner', () => ({
Toaster: () => null,
toast: mocks.toast,
}));
vi.mock('../utils/urlHash', () => ({
parseHashConversation: () => null,
updateUrlHash: vi.fn(),
getMapFocusHash: () => '#map',
}));
import { App } from '../App';
const baseConfig = {
public_key: 'aa'.repeat(32),
name: 'TestNode',
lat: 0,
lon: 0,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
};
const baseSettings = {
max_radio_contacts: 200,
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent' as const,
last_message_times: {},
preferences_migrated: false,
advert_interval: 0,
last_advert_time: 0,
bots: [],
};
const publicChannel = {
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
name: 'Public',
is_hashtag: false,
on_radio: false,
last_read_at: null,
};
describe('App favorite toggle flow', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.api.getRadioConfig.mockResolvedValue(baseConfig);
mocks.api.getSettings.mockResolvedValue({ ...baseSettings });
mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
mocks.api.getChannels.mockResolvedValue([publicChannel]);
mocks.api.getContacts.mockResolvedValue([]);
mocks.api.toggleFavorite.mockResolvedValue({
...baseSettings,
favorites: [{ type: 'channel', id: publicChannel.key }],
});
});
it('optimistically toggles favorite and persists on success', async () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTitle('Add to favorites')).toBeInTheDocument();
});
fireEvent.click(screen.getByTitle('Add to favorites'));
await waitFor(() => {
expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key);
});
await waitFor(() => {
expect(screen.getByTitle('Remove from favorites')).toBeInTheDocument();
});
});
it('rolls back favorite state by refetching settings on toggle failure', async () => {
mocks.api.toggleFavorite.mockRejectedValue(new Error('toggle failed'));
mocks.api.getSettings
.mockResolvedValueOnce({ ...baseSettings }) // initial load
.mockResolvedValueOnce({ ...baseSettings }); // rollback refetch
render(<App />);
await waitFor(() => {
expect(screen.getByTitle('Add to favorites')).toBeInTheDocument();
});
fireEvent.click(screen.getByTitle('Add to favorites'));
await waitFor(() => {
expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key);
});
await waitFor(() => {
expect(mocks.api.getSettings).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(mocks.toast.error).toHaveBeenCalledWith('Failed to update favorite');
});
await waitFor(() => {
expect(screen.getByTitle('Add to favorites')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,123 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { SettingsModal } from '../components/SettingsModal';
import type { AppSettings, HealthStatus, RadioConfig } from '../types';
const baseConfig: RadioConfig = {
public_key: 'aa'.repeat(32),
name: 'TestNode',
lat: 1,
lon: 2,
tx_power: 17,
max_tx_power: 22,
radio: {
freq: 910.525,
bw: 62.5,
sf: 7,
cr: 5,
},
};
const baseHealth: HealthStatus = {
status: 'connected',
radio_connected: true,
connection_info: 'Serial: /dev/ttyUSB0',
database_size_mb: 1.2,
oldest_undecrypted_timestamp: null,
};
const baseSettings: AppSettings = {
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {},
preferences_migrated: false,
advert_interval: 0,
bots: [],
};
function renderModal(overrides?: {
appSettings?: AppSettings;
onSaveAppSettings?: (update: { max_radio_contacts?: number }) => Promise<void>;
onRefreshAppSettings?: () => Promise<void>;
}) {
const onSaveAppSettings = overrides?.onSaveAppSettings ?? vi.fn(async () => {});
const onRefreshAppSettings = overrides?.onRefreshAppSettings ?? vi.fn(async () => {});
render(
<SettingsModal
open
config={baseConfig}
health={baseHealth}
appSettings={overrides?.appSettings ?? baseSettings}
onClose={vi.fn()}
onSave={vi.fn(async () => {})}
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={vi.fn(async () => {})}
onReboot={vi.fn(async () => {})}
onAdvertise={vi.fn(async () => {})}
onHealthRefresh={vi.fn(async () => {})}
onRefreshAppSettings={onRefreshAppSettings}
/>
);
return { onSaveAppSettings, onRefreshAppSettings };
}
function openConnectivityTab() {
const connectivityTab = screen.getByRole('tab', { name: 'Connectivity' });
fireEvent.mouseDown(connectivityTab);
fireEvent.click(connectivityTab);
}
describe('SettingsModal', () => {
it('refreshes app settings when opened', async () => {
const { onRefreshAppSettings } = renderModal();
await waitFor(() => {
expect(onRefreshAppSettings).toHaveBeenCalledTimes(1);
});
});
it('shows favorite-first contact sync helper text in connectivity tab', async () => {
renderModal();
openConnectivityTab();
expect(
screen.getByText(
/Favorite contacts load first, then recent non-repeater contacts until this\s+limit is reached/i
)
).toBeInTheDocument();
});
it('saves changed max contacts value through onSaveAppSettings', async () => {
const { onSaveAppSettings } = renderModal();
openConnectivityTab();
const maxContactsInput = screen.getByLabelText('Max Contacts on Radio');
fireEvent.change(maxContactsInput, { target: { value: '250' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(onSaveAppSettings).toHaveBeenCalledWith({ max_radio_contacts: 250 });
});
});
it('does not save max contacts when unchanged', async () => {
const { onSaveAppSettings } = renderModal({
appSettings: { ...baseSettings, max_radio_contacts: 200 },
});
openConnectivityTab();
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(onSaveAppSettings).not.toHaveBeenCalled();
});
});
});