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,
+ )