Improve test coverage

This commit is contained in:
Jack Kingsman
2026-03-19 17:19:35 -07:00
parent 45ed430580
commit 1ae76848fe
6 changed files with 187 additions and 37 deletions

View File

@@ -318,7 +318,11 @@ export function Sidebar({
(item: FavoriteItem) =>
item.type === 'channel'
? item.channel.name
: getContactDisplayName(item.contact.name, item.contact.public_key, item.contact.last_advert),
: getContactDisplayName(
item.contact.name,
item.contact.public_key,
item.contact.last_advert
),
[]
);
@@ -504,8 +508,6 @@ export function Sidebar({
filteredNonRepeaterContacts,
filteredRepeaters,
favorites,
getContactRecentTime,
getLastMessageTime,
sectionSortOrders.favorites,
sortFavoriteItemsByOrder,
]);

View File

@@ -0,0 +1,51 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { RawPacketDetailModal } from '../components/RawPacketDetailModal';
import type { Channel, RawPacket } from '../types';
const BOT_CHANNEL: Channel = {
key: 'eb50a1bcb3e4e5d7bf69a57c9dada211',
name: '#bot',
is_hashtag: true,
on_radio: false,
last_read_at: null,
};
const BOT_PACKET: RawPacket = {
id: 1,
observation_id: 10,
timestamp: 1_700_000_000,
data: '15833fa002860ccae0eed9ca78b9ab0775d477c1f6490a398bf4edc75240',
decrypted: false,
payload_type: 'GroupText',
rssi: -72,
snr: 5.5,
decrypted_info: null,
};
describe('RawPacketDetailModal', () => {
it('renders path hops as nowrap arrow-delimited groups and links hover state to the full packet hex', () => {
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
const pathDescription = screen.getByText(
'Historical route taken (3-byte hashes added as packet floods through network)'
);
const pathFieldBox = pathDescription.closest('[class*="rounded-lg"]');
expect(pathFieldBox).not.toBeNull();
const pathField = within(pathFieldBox as HTMLElement);
expect(pathField.getByText('3FA002 →')).toHaveClass('whitespace-nowrap');
expect(pathField.getByText('860CCA →')).toHaveClass('whitespace-nowrap');
expect(pathField.getByText('E0EED9')).toHaveClass('whitespace-nowrap');
const pathRun = screen.getByText('3F A0 02 86 0C CA E0 EE D9');
const idleClassName = pathRun.className;
fireEvent.mouseEnter(pathFieldBox as HTMLElement);
expect(pathRun.className).not.toBe(idleClassName);
fireEvent.mouseLeave(pathFieldBox as HTMLElement);
expect(pathRun.className).toBe(idleClassName);
});
});

View File

@@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingsModal } from '../components/SettingsModal';
import type {
@@ -177,6 +177,10 @@ function openDatabaseSection() {
}
describe('SettingsModal', () => {
beforeEach(() => {
vi.spyOn(api, 'getFanoutConfigs').mockResolvedValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
@@ -365,17 +369,21 @@ describe('SettingsModal', () => {
desktopSection: 'fanout',
});
await waitFor(() => {
expect(api.getFanoutConfigs).toHaveBeenCalled();
});
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Local Configuration/i })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
});
it('does not clip the fanout add-integration menu in external desktop mode', () => {
it('does not clip the fanout add-integration menu in external desktop mode', async () => {
renderModal({
externalSidebarNav: true,
desktopSection: 'fanout',
});
const addIntegrationButton = screen.getByRole('button', { name: 'Add Integration' });
const addIntegrationButton = await screen.findByRole('button', { name: 'Add Integration' });
const wrapperSection = addIntegrationButton.closest('section');
expect(wrapperSection).not.toHaveClass('overflow-hidden');
});
@@ -428,30 +436,35 @@ describe('SettingsModal', () => {
expect(screen.getByText('Save failed')).toBeInTheDocument();
});
view.rerender(
<SettingsModal
open
externalSidebarNav
desktopSection="fanout"
config={baseConfig}
health={baseHealth}
appSettings={baseSettings}
onClose={vi.fn()}
onSave={vi.fn(async () => {})}
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={vi.fn(async () => {})}
onReboot={vi.fn(async () => {})}
onDisconnect={vi.fn(async () => {})}
onReconnect={vi.fn(async () => {})}
onAdvertise={vi.fn(async () => {})}
meshDiscovery={null}
meshDiscoveryLoadingTarget={null}
onDiscoverMesh={vi.fn(async () => {})}
onHealthRefresh={vi.fn(async () => {})}
onRefreshAppSettings={vi.fn(async () => {})}
/>
);
await act(async () => {
view.rerender(
<SettingsModal
open
externalSidebarNav
desktopSection="fanout"
config={baseConfig}
health={baseHealth}
appSettings={baseSettings}
onClose={vi.fn()}
onSave={vi.fn(async () => {})}
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={vi.fn(async () => {})}
onReboot={vi.fn(async () => {})}
onDisconnect={vi.fn(async () => {})}
onReconnect={vi.fn(async () => {})}
onAdvertise={vi.fn(async () => {})}
meshDiscovery={null}
meshDiscoveryLoadingTarget={null}
onDiscoverMesh={vi.fn(async () => {})}
onHealthRefresh={vi.fn(async () => {})}
onRefreshAppSettings={vi.fn(async () => {})}
/>
);
await Promise.resolve();
});
expect(api.getFanoutConfigs).toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
expect(screen.queryByText('Save failed')).not.toBeInTheDocument();
});

View File

@@ -514,4 +514,43 @@ describe('Sidebar section summaries', () => {
expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']);
});
it('seeds favorites sort from the legacy global sort order when section prefs are missing', () => {
localStorage.setItem('remoteterm-sortOrder', 'alpha');
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
const amy = makeContact('22'.repeat(32), 'Amy');
render(
<Sidebar
contacts={[zed, amy]}
channels={[publicChannel]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={{
[getStateKey('contact', zed.public_key)]: 200,
}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[
{ type: 'contact', id: zed.public_key },
{ type: 'contact', id: amy.public_key },
]}
/>
);
const favoriteRows = screen
.getAllByText(/^(Amy|Zed)$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
expect(favoriteRows).toEqual(['Amy', 'Zed']);
expect(screen.getByRole('button', { name: 'Sort Favorites by recent' })).toBeInTheDocument();
});
});

View File

@@ -217,7 +217,9 @@ describe('useConversationMessages conversation switch', () => {
// Switch to conv B while older-messages fetch is still pending
mockGetMessages.mockResolvedValueOnce([createMessage({ id: 999, conversation_key: 'conv_b' })]);
rerender({ conv: convB });
await act(async () => {
rerender({ conv: convB });
});
// loadingOlder must reset immediately — no phantom spinner in conv B
await waitFor(() => expect(result.current.loadingOlder).toBe(false));
@@ -226,12 +228,13 @@ describe('useConversationMessages conversation switch', () => {
expect(result.current.messages[0].conversation_key).toBe('conv_b');
// Resolve the stale older-messages fetch — should not affect conv B's state
olderDeferred.resolve([
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
]);
await act(async () => {
olderDeferred.resolve([
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
]);
await Promise.resolve();
});
// Give the stale response time to be processed (it should be discarded)
await new Promise((r) => setTimeout(r, 50));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].conversation_key).toBe('conv_b');
});

View File

@@ -4,9 +4,12 @@ Verifies that the primary RF packet entry point correctly extracts hex payload,
SNR, and RSSI from MeshCore events and passes them to process_raw_packet.
"""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from meshcore import EventType
from meshcore.packets import PacketType
from meshcore.reader import MessageReader
class TestOnRxLogData:
@@ -90,3 +93,42 @@ class TestOnRxLogData:
with pytest.raises(ValueError):
await on_rx_log_data(MockEvent())
@pytest.mark.asyncio
async def test_real_meshcore_reader_forwards_3byte_log_data_to_handler(self):
"""The meshcore reader emits usable RX_LOG_DATA for 3-byte-hop packets."""
from app.event_handlers import on_rx_log_data
payload_hex = "15833fa002860ccae0eed9ca78b9ab0775d477c1f6490a398bf4edc75240"
dispatcher = MagicMock()
dispatcher.dispatch = AsyncMock()
reader = MessageReader(dispatcher)
frame = bytes(
[
PacketType.LOG_DATA.value,
int(7.5 * 4),
(-85) & 0xFF,
]
) + bytes.fromhex(payload_hex)
await reader.handle_rx(bytearray(frame))
dispatcher.dispatch.assert_awaited_once()
event = dispatcher.dispatch.await_args.args[0]
assert event.type == EventType.RX_LOG_DATA
assert event.payload["payload"] == payload_hex.lower()
assert event.payload["path_hash_size"] == 3
assert event.payload["path_len"] == 3
assert event.payload["path"] == "3fa002860ccae0eed9"
assert event.payload["snr"] == 7.5
assert event.payload["rssi"] == -85
with patch("app.event_handlers.process_raw_packet", new_callable=AsyncMock) as mock_process:
await on_rx_log_data(event)
mock_process.assert_called_once_with(
raw_bytes=bytes.fromhex(payload_hex),
snr=7.5,
rssi=-85,
)