mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-04 20:43:03 +02:00
Testing blitz!
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
246
frontend/src/test/appFavorites.test.tsx
Normal file
246
frontend/src/test/appFavorites.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
123
frontend/src/test/settingsModal.test.tsx
Normal file
123
frontend/src/test/settingsModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user