From 1ae76848fe8bfcc74e1e3b9d3831302bb40f390c Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 19 Mar 2026 17:19:35 -0700 Subject: [PATCH] Improve test coverage --- frontend/src/components/Sidebar.tsx | 8 ++- .../src/test/rawPacketDetailModal.test.tsx | 51 ++++++++++++++ frontend/src/test/settingsModal.test.tsx | 67 +++++++++++-------- frontend/src/test/sidebar.test.tsx | 39 +++++++++++ .../test/useConversationMessages.race.test.ts | 15 +++-- tests/test_rx_log_data.py | 44 +++++++++++- 6 files changed, 187 insertions(+), 37 deletions(-) create mode 100644 frontend/src/test/rawPacketDetailModal.test.tsx diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4de31d7..5c73d49 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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, ]); diff --git a/frontend/src/test/rawPacketDetailModal.test.tsx b/frontend/src/test/rawPacketDetailModal.test.tsx new file mode 100644 index 0000000..8a36404 --- /dev/null +++ b/frontend/src/test/rawPacketDetailModal.test.tsx @@ -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(); + + 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); + }); +}); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 9bada6a..e45f560 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -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( - {})} - 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( + {})} + 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(); }); diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index e889c6e..b353a69 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -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( + + ); + + 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(); + }); }); diff --git a/frontend/src/test/useConversationMessages.race.test.ts b/frontend/src/test/useConversationMessages.race.test.ts index 0e1fddb..e487d5d 100644 --- a/frontend/src/test/useConversationMessages.race.test.ts +++ b/frontend/src/test/useConversationMessages.race.test.ts @@ -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'); }); diff --git a/tests/test_rx_log_data.py b/tests/test_rx_log_data.py index 3242a0b..7d0c739 100644 --- a/tests/test_rx_log_data.py +++ b/tests/test_rx_log_data.py @@ -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, + )