mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 19:12:57 +02:00
868 lines
27 KiB
TypeScript
868 lines
27 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { SettingsModal } from '../components/SettingsModal';
|
|
import type {
|
|
AppSettings,
|
|
AppSettingsUpdate,
|
|
Contact,
|
|
HealthStatus,
|
|
RadioAdvertMode,
|
|
RadioConfig,
|
|
RadioConfigUpdate,
|
|
RadioDiscoveryResponse,
|
|
RadioDiscoveryTarget,
|
|
StatisticsResponse,
|
|
} from '../types';
|
|
import type { SettingsSection } from '../components/settings/settingsConstants';
|
|
import {
|
|
LAST_VIEWED_CONVERSATION_KEY,
|
|
REOPEN_LAST_CONVERSATION_KEY,
|
|
} from '../utils/lastViewedConversation';
|
|
import { api } from '../api';
|
|
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
|
|
import {
|
|
DEFAULT_FONT_SCALE,
|
|
FONT_SCALE_KEY,
|
|
MAX_FONT_SCALE,
|
|
MIN_FONT_SCALE,
|
|
} from '../utils/fontScale';
|
|
|
|
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,
|
|
},
|
|
path_hash_mode: 0,
|
|
path_hash_mode_supported: false,
|
|
advert_location_source: 'current',
|
|
multi_acks_enabled: false,
|
|
};
|
|
|
|
const baseHealth: HealthStatus = {
|
|
status: 'connected',
|
|
radio_connected: true,
|
|
radio_initializing: false,
|
|
connection_info: 'Serial: /dev/ttyUSB0',
|
|
database_size_mb: 1.2,
|
|
oldest_undecrypted_timestamp: null,
|
|
fanout_statuses: {},
|
|
bots_disabled: false,
|
|
};
|
|
|
|
const baseSettings: AppSettings = {
|
|
max_radio_contacts: 200,
|
|
auto_decrypt_dm_on_advert: false,
|
|
last_message_times: {},
|
|
|
|
advert_interval: 0,
|
|
last_advert_time: 0,
|
|
flood_scope: '',
|
|
blocked_keys: [],
|
|
blocked_names: [],
|
|
discovery_blocked_types: [],
|
|
tracked_telemetry_repeaters: [],
|
|
auto_resend_channel: false,
|
|
telemetry_interval_hours: 8,
|
|
telemetry_routed_hourly: false,
|
|
};
|
|
|
|
function renderModal(overrides?: {
|
|
config?: RadioConfig | null;
|
|
appSettings?: AppSettings;
|
|
health?: HealthStatus;
|
|
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
|
|
onRefreshAppSettings?: () => Promise<void>;
|
|
onSave?: (update: RadioConfigUpdate) => Promise<void>;
|
|
onClose?: () => void;
|
|
onSetPrivateKey?: (key: string) => Promise<void>;
|
|
onReboot?: () => Promise<void>;
|
|
onDisconnect?: () => Promise<void>;
|
|
onReconnect?: () => Promise<void>;
|
|
onAdvertise?: (mode: RadioAdvertMode) => Promise<void>;
|
|
meshDiscovery?: RadioDiscoveryResponse | null;
|
|
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
|
|
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
|
|
contacts?: Contact[];
|
|
trackedTelemetryRepeaters?: string[];
|
|
open?: boolean;
|
|
pageMode?: boolean;
|
|
externalSidebarNav?: boolean;
|
|
desktopSection?: SettingsSection;
|
|
mobile?: boolean;
|
|
}) {
|
|
setMatchMedia(overrides?.mobile ?? false);
|
|
|
|
const onSaveAppSettings = overrides?.onSaveAppSettings ?? vi.fn(async () => {});
|
|
const onRefreshAppSettings = overrides?.onRefreshAppSettings ?? vi.fn(async () => {});
|
|
const onSave = overrides?.onSave ?? vi.fn(async (_update: RadioConfigUpdate) => {});
|
|
const onClose = overrides?.onClose ?? vi.fn();
|
|
const onSetPrivateKey = overrides?.onSetPrivateKey ?? vi.fn(async () => {});
|
|
const onReboot = overrides?.onReboot ?? vi.fn(async () => {});
|
|
const onDisconnect = overrides?.onDisconnect ?? vi.fn(async () => {});
|
|
const onReconnect = overrides?.onReconnect ?? vi.fn(async () => {});
|
|
const onAdvertise = overrides?.onAdvertise ?? vi.fn(async (_mode: RadioAdvertMode) => {});
|
|
const onDiscoverMesh = overrides?.onDiscoverMesh ?? vi.fn(async () => {});
|
|
|
|
const commonProps = {
|
|
open: overrides?.open ?? true,
|
|
pageMode: overrides?.pageMode,
|
|
config: overrides?.config === undefined ? baseConfig : overrides.config,
|
|
health: overrides?.health ?? baseHealth,
|
|
appSettings: overrides?.appSettings ?? baseSettings,
|
|
onClose,
|
|
onSave,
|
|
onSaveAppSettings,
|
|
onSetPrivateKey,
|
|
onReboot,
|
|
onDisconnect,
|
|
onReconnect,
|
|
onAdvertise,
|
|
meshDiscovery: overrides?.meshDiscovery ?? null,
|
|
meshDiscoveryLoadingTarget: overrides?.meshDiscoveryLoadingTarget ?? null,
|
|
onDiscoverMesh,
|
|
onHealthRefresh: vi.fn(async () => {}),
|
|
onRefreshAppSettings,
|
|
contacts: overrides?.contacts,
|
|
trackedTelemetryRepeaters: overrides?.trackedTelemetryRepeaters,
|
|
};
|
|
|
|
const view = overrides?.externalSidebarNav
|
|
? render(
|
|
<SettingsModal
|
|
{...commonProps}
|
|
externalSidebarNav
|
|
desktopSection={overrides.desktopSection ?? 'radio'}
|
|
/>
|
|
)
|
|
: render(<SettingsModal {...commonProps} />);
|
|
|
|
return {
|
|
onSaveAppSettings,
|
|
onRefreshAppSettings,
|
|
onSave,
|
|
onClose,
|
|
onSetPrivateKey,
|
|
onReboot,
|
|
onDisconnect,
|
|
onReconnect,
|
|
onAdvertise,
|
|
onDiscoverMesh,
|
|
view,
|
|
};
|
|
}
|
|
|
|
function setMatchMedia(matches: boolean) {
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation(() => ({
|
|
matches,
|
|
media: '(max-width: 767px)',
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
});
|
|
}
|
|
|
|
function openRadioSection() {
|
|
const radioToggle = screen.getByRole('button', { name: /Radio/i });
|
|
fireEvent.click(radioToggle);
|
|
}
|
|
|
|
function openLocalSection() {
|
|
const localToggle = screen.getByRole('button', { name: /Local Configuration/i });
|
|
fireEvent.click(localToggle);
|
|
}
|
|
|
|
function openDatabaseSection() {
|
|
const databaseToggle = screen.getByRole('button', { name: /Database/i });
|
|
fireEvent.click(databaseToggle);
|
|
}
|
|
|
|
describe('SettingsModal', () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(api, 'getFanoutConfigs').mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
localStorage.clear();
|
|
window.location.hash = '';
|
|
document.documentElement.style.fontSize = '';
|
|
});
|
|
|
|
it('refreshes app settings when opened', async () => {
|
|
const { onRefreshAppSettings } = renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(onRefreshAppSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('refreshes app settings in page mode even when open is false', async () => {
|
|
const { onRefreshAppSettings } = renderModal({ open: false, pageMode: true });
|
|
|
|
await waitFor(() => {
|
|
expect(onRefreshAppSettings).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('does not render when closed outside page mode', () => {
|
|
renderModal({ open: false });
|
|
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows favorite-contact radio sync helper text in radio tab', async () => {
|
|
renderModal();
|
|
openRadioSection();
|
|
|
|
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders flood and zero-hop advert buttons and passes the selected mode', async () => {
|
|
const onAdvertise = vi.fn(async (_mode: RadioAdvertMode) => {});
|
|
renderModal({ onAdvertise });
|
|
openRadioSection();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Send Flood Advertisement' }));
|
|
await waitFor(() => {
|
|
expect(onAdvertise).toHaveBeenCalledWith('flood');
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Send Zero-Hop Advertisement' }));
|
|
await waitFor(() => {
|
|
expect(onAdvertise).toHaveBeenCalledWith('zero_hop');
|
|
});
|
|
});
|
|
|
|
it('shows radio-unavailable message when config is null', () => {
|
|
renderModal({ config: null });
|
|
|
|
const radioToggle = screen.getByRole('button', { name: /Radio/i });
|
|
expect(radioToggle).not.toBeDisabled();
|
|
|
|
fireEvent.click(radioToggle);
|
|
expect(screen.getByText('Radio is not available.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows radio-unavailable message in sidebar-nav mode when config is null', () => {
|
|
renderModal({
|
|
config: null,
|
|
externalSidebarNav: true,
|
|
desktopSection: 'radio',
|
|
});
|
|
|
|
expect(screen.getByText('Radio is not available.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows cached radio firmware and capacity info under the connection status', () => {
|
|
renderModal({
|
|
health: {
|
|
...baseHealth,
|
|
radio_device_info: {
|
|
model: 'T-Echo',
|
|
firmware_build: '2025-02-01',
|
|
firmware_version: '1.2.3',
|
|
max_contacts: 350,
|
|
max_channels: 64,
|
|
},
|
|
},
|
|
});
|
|
openRadioSection();
|
|
|
|
expect(
|
|
screen.getByText('T-Echo running 2025-02-01/1.2.3 (max: 350 contacts, 64 channels)')
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows reconnect action when radio connection is paused', () => {
|
|
renderModal({
|
|
health: { ...baseHealth, radio_state: 'paused' },
|
|
});
|
|
openRadioSection();
|
|
|
|
expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('runs repeater mesh discovery from the radio tab', async () => {
|
|
const { onDiscoverMesh } = renderModal();
|
|
openRadioSection();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Discover Repeaters' }));
|
|
|
|
await waitFor(() => {
|
|
expect(onDiscoverMesh).toHaveBeenCalledWith('repeaters');
|
|
});
|
|
});
|
|
|
|
it('renders mesh discovery results in the radio tab', () => {
|
|
renderModal({
|
|
meshDiscovery: {
|
|
target: 'all',
|
|
duration_seconds: 8,
|
|
results: [
|
|
{
|
|
public_key: '11'.repeat(32),
|
|
name: null,
|
|
node_type: 'repeater',
|
|
heard_count: 2,
|
|
local_snr: 7.5,
|
|
local_rssi: -101,
|
|
remote_snr: 4,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
openRadioSection();
|
|
|
|
expect(screen.getByText('Last sweep: 1 node')).toBeInTheDocument();
|
|
expect(screen.getByText('repeater')).toBeInTheDocument();
|
|
expect(screen.getByText('heard 2 times')).toBeInTheDocument();
|
|
expect(screen.getByText('8s listen window')).toBeInTheDocument();
|
|
});
|
|
|
|
it('saves advert location source through radio config save', async () => {
|
|
const { onSave } = renderModal();
|
|
openRadioSection();
|
|
|
|
fireEvent.change(screen.getByLabelText('Advert Location Source'), {
|
|
target: { value: 'off' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config' }));
|
|
|
|
await waitFor(() => {
|
|
expect(onSave).toHaveBeenCalledWith(
|
|
expect.objectContaining({ advert_location_source: 'off' })
|
|
);
|
|
});
|
|
});
|
|
|
|
it('saves multi-acks through radio config save', async () => {
|
|
const { onSave } = renderModal();
|
|
openRadioSection();
|
|
|
|
fireEvent.click(screen.getByLabelText('Extra Direct ACK Transmission'));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config' }));
|
|
|
|
await waitFor(() => {
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ multi_acks_enabled: true }));
|
|
});
|
|
});
|
|
|
|
it('saves changed max contacts value through onSaveAppSettings', async () => {
|
|
const { onSaveAppSettings } = renderModal();
|
|
openRadioSection();
|
|
|
|
const maxContactsInput = screen.getByLabelText('Max Contacts on Radio');
|
|
fireEvent.change(maxContactsInput, { target: { value: '250' } });
|
|
|
|
// Click the "Save Messaging Settings" button
|
|
const saveButtons = screen.getAllByRole('button', { name: 'Save Messaging Settings' });
|
|
fireEvent.click(saveButtons[0]);
|
|
|
|
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 },
|
|
});
|
|
openRadioSection();
|
|
|
|
// Click the "Save Messaging Settings" button
|
|
const saveButtons = screen.getAllByRole('button', { name: 'Save Messaging Settings' });
|
|
fireEvent.click(saveButtons[0]);
|
|
|
|
await waitFor(() => {
|
|
expect(onSaveAppSettings).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('renders selected section from external sidebar nav on desktop mode', async () => {
|
|
renderModal({
|
|
externalSidebarNav: true,
|
|
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', async () => {
|
|
renderModal({
|
|
externalSidebarNav: true,
|
|
desktopSection: 'fanout',
|
|
});
|
|
|
|
const addIntegrationButton = await screen.findByRole('button', { name: 'Add Integration' });
|
|
const wrapperSection = addIntegrationButton.closest('section');
|
|
expect(wrapperSection).not.toHaveClass('overflow-hidden');
|
|
});
|
|
|
|
it('applies the centered 800px column layout to non-fanout settings content', () => {
|
|
renderModal({
|
|
externalSidebarNav: true,
|
|
desktopSection: 'local',
|
|
});
|
|
|
|
const localSettingsText = screen.getByText('These settings apply only to this device/browser.');
|
|
expect(localSettingsText.closest('div')).toHaveClass('mx-auto', 'w-full', 'max-w-[800px]');
|
|
});
|
|
|
|
it('toggles sections in mobile accordion mode', () => {
|
|
renderModal({ mobile: true });
|
|
const localToggle = screen.getAllByRole('button', { name: /Local Configuration/i })[0];
|
|
|
|
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
|
|
expect(screen.queryByLabelText('Local label text')).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(localToggle);
|
|
expect(screen.getByLabelText('Local label text')).toBeInTheDocument();
|
|
|
|
fireEvent.click(localToggle);
|
|
expect(screen.queryByLabelText('Local label text')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('lists the new Windows 95 and iPhone themes', () => {
|
|
renderModal();
|
|
openLocalSection();
|
|
|
|
expect(screen.getByText('Windows 95')).toBeInTheDocument();
|
|
expect(screen.getByText('iPhone')).toBeInTheDocument();
|
|
});
|
|
|
|
it('reverts checkbox state when auto-persist fails on the database section', async () => {
|
|
// Auto-persist replaced the old "Save Settings" button on this section.
|
|
// The risk is now: a toggle gets applied optimistically, the PATCH fails,
|
|
// and we're left with the UI out of sync with saved state. Verify the
|
|
// revert-on-error path keeps the checkbox consistent with the server.
|
|
const onSaveAppSettings = vi.fn(async () => {
|
|
throw new Error('Save failed');
|
|
});
|
|
|
|
renderModal({
|
|
externalSidebarNav: true,
|
|
desktopSection: 'database',
|
|
onSaveAppSettings,
|
|
});
|
|
|
|
const checkbox = screen.getByRole('checkbox', {
|
|
name: /Auto-decrypt historical DMs/i,
|
|
}) as HTMLInputElement;
|
|
const initialChecked = checkbox.checked;
|
|
|
|
fireEvent.click(checkbox);
|
|
|
|
await waitFor(() => {
|
|
expect(onSaveAppSettings).toHaveBeenCalled();
|
|
});
|
|
await waitFor(() => {
|
|
expect(checkbox.checked).toBe(initialChecked);
|
|
});
|
|
});
|
|
|
|
it('serializes rapid auto-persist clicks so stale writes cannot win', async () => {
|
|
// Regression test for a race where rapid consecutive checkbox toggles
|
|
// fire overlapping PATCHes that can land out of order. The page now
|
|
// chains saves through a single promise, so the server sees them in
|
|
// the order the user clicked. This test hand-controls resolution
|
|
// order to force the "stale write" scenario if serialization were off.
|
|
|
|
const deferred: { resolve: () => void }[] = [];
|
|
const callOrder: number[] = [];
|
|
|
|
const onSaveAppSettings = vi.fn(async (_update: unknown) => {
|
|
const index = deferred.length;
|
|
callOrder.push(index);
|
|
await new Promise<void>((res) => {
|
|
deferred.push({ resolve: res });
|
|
});
|
|
});
|
|
|
|
renderModal({
|
|
externalSidebarNav: true,
|
|
desktopSection: 'database',
|
|
onSaveAppSettings,
|
|
});
|
|
|
|
// Two distinct checkboxes in quick succession.
|
|
const blockClients = screen.getByRole('checkbox', { name: /Block clients/i });
|
|
const blockRepeaters = screen.getByRole('checkbox', { name: /Block repeaters/i });
|
|
|
|
fireEvent.click(blockClients);
|
|
fireEvent.click(blockRepeaters);
|
|
|
|
// Wait for the first PATCH to be registered. Only the first should be
|
|
// in-flight — the second must be queued behind it.
|
|
await waitFor(() => {
|
|
expect(deferred.length).toBe(1);
|
|
});
|
|
expect(callOrder).toEqual([0]);
|
|
|
|
// Resolve the first PATCH. The chain should now dispatch the second.
|
|
deferred[0].resolve();
|
|
await waitFor(() => {
|
|
expect(deferred.length).toBe(2);
|
|
});
|
|
expect(callOrder).toEqual([0, 1]);
|
|
|
|
// Resolve the second so the test tears down cleanly.
|
|
deferred[1].resolve();
|
|
await waitFor(() => {
|
|
expect(onSaveAppSettings).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
it('does not call onClose after save/reboot flows in page mode', async () => {
|
|
const onClose = vi.fn();
|
|
const onSave = vi.fn(async () => {});
|
|
const onSetPrivateKey = vi.fn(async () => {});
|
|
const onReboot = vi.fn(async () => {});
|
|
|
|
renderModal({
|
|
pageMode: true,
|
|
onClose,
|
|
onSave,
|
|
onSetPrivateKey,
|
|
onReboot,
|
|
onDisconnect: vi.fn(async () => {}),
|
|
onReconnect: vi.fn(async () => {}),
|
|
});
|
|
openRadioSection();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config & Reboot' }));
|
|
await waitFor(() => {
|
|
expect(onSave).toHaveBeenCalledTimes(1);
|
|
expect(onReboot).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
fireEvent.change(screen.getByLabelText('Set Private Key (write-only)'), {
|
|
target: { value: 'a'.repeat(64) },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: 'Set Private Key & Reboot' }));
|
|
|
|
await waitFor(() => {
|
|
expect(onSetPrivateKey).toHaveBeenCalledWith('a'.repeat(64));
|
|
expect(onReboot).toHaveBeenCalledTimes(2);
|
|
});
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('stores and clears reopen-last-conversation preference locally', () => {
|
|
window.location.hash = '#raw';
|
|
renderModal();
|
|
openLocalSection();
|
|
|
|
const checkbox = screen.getByLabelText('Reopen Last Conversation');
|
|
expect(checkbox).not.toBeChecked();
|
|
|
|
fireEvent.click(checkbox);
|
|
|
|
expect(localStorage.getItem(REOPEN_LAST_CONVERSATION_KEY)).toBe('1');
|
|
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toContain('"type":"raw"');
|
|
|
|
fireEvent.click(checkbox);
|
|
|
|
expect(localStorage.getItem(REOPEN_LAST_CONVERSATION_KEY)).toBeNull();
|
|
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull();
|
|
});
|
|
|
|
it('defaults distance units to metric and stores local changes', () => {
|
|
renderModal();
|
|
openLocalSection();
|
|
|
|
const select = screen.getByLabelText('Distance Units');
|
|
expect(select).toHaveValue('metric');
|
|
|
|
fireEvent.change(select, { target: { value: 'smoots' } });
|
|
|
|
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
|
|
});
|
|
|
|
it('defaults relative font size to 100% and exposes the expected input bounds', () => {
|
|
renderModal();
|
|
openLocalSection();
|
|
|
|
const slider = screen.getByLabelText('Relative font size slider');
|
|
const input = screen.getByLabelText('Relative font size percentage');
|
|
|
|
expect(slider).toHaveValue(String(DEFAULT_FONT_SCALE));
|
|
expect(slider).toHaveAttribute('step', '5');
|
|
expect(input).toHaveValue(DEFAULT_FONT_SCALE);
|
|
expect(input).toHaveAttribute('min', String(MIN_FONT_SCALE));
|
|
expect(input).toHaveAttribute('max', String(MAX_FONT_SCALE));
|
|
});
|
|
|
|
it('stores and applies relative font size changes locally', async () => {
|
|
renderModal();
|
|
openLocalSection();
|
|
|
|
const slider = screen.getByLabelText('Relative font size slider');
|
|
|
|
fireEvent.change(slider, { target: { value: '135' } });
|
|
|
|
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
|
|
expect(document.documentElement.style.fontSize).toBe('');
|
|
|
|
fireEvent.mouseUp(slider);
|
|
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('135');
|
|
expect(document.documentElement.style.fontSize).toBe('135%');
|
|
});
|
|
|
|
fireEvent.change(screen.getByLabelText('Relative font size percentage'), {
|
|
target: { value: '137.5' },
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
|
|
expect(document.documentElement.style.fontSize).toBe('137.5%');
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
|
|
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
|
|
expect(document.documentElement.style.fontSize).toBe('100%');
|
|
});
|
|
});
|
|
|
|
it('purges decrypted raw packets via maintenance endpoint action', async () => {
|
|
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
|
|
packets_deleted: 12,
|
|
vacuumed: true,
|
|
});
|
|
|
|
renderModal();
|
|
openDatabaseSection();
|
|
|
|
expect(
|
|
screen.getByText(/removes packet-analysis availability for those messages/i)
|
|
).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
|
|
|
|
await waitFor(() => {
|
|
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
|
|
});
|
|
});
|
|
|
|
it('renders statistics section with fetched data', async () => {
|
|
const mockStats: StatisticsResponse = {
|
|
busiest_channels_24h: [
|
|
{ channel_key: 'AA'.repeat(16), channel_name: 'general', message_count: 42 },
|
|
],
|
|
contact_count: 10,
|
|
repeater_count: 3,
|
|
channel_count: 5,
|
|
total_packets: 200,
|
|
decrypted_packets: 150,
|
|
undecrypted_packets: 50,
|
|
total_dms: 25,
|
|
total_channel_messages: 80,
|
|
total_outgoing: 30,
|
|
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
|
|
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
|
|
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
|
|
path_hash_width_24h: {
|
|
total_packets: 120,
|
|
single_byte: 60,
|
|
double_byte: 36,
|
|
triple_byte: 24,
|
|
single_byte_pct: 50,
|
|
double_byte_pct: 30,
|
|
triple_byte_pct: 20,
|
|
},
|
|
packets_per_hour_72h: [
|
|
{ timestamp: 1711792800, count: 12 },
|
|
{ timestamp: 1711796400, count: 8 },
|
|
],
|
|
noise_floor_24h: {
|
|
sample_interval_seconds: 60,
|
|
coverage_seconds: 3600,
|
|
latest_noise_floor_dbm: -105,
|
|
latest_timestamp: 1711800000,
|
|
samples: [],
|
|
},
|
|
};
|
|
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify(mockStats), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
);
|
|
|
|
renderModal({
|
|
externalSidebarNav: true,
|
|
desktopSection: 'statistics',
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Network')).toBeInTheDocument();
|
|
});
|
|
|
|
// Verify key labels are present
|
|
expect(screen.getByText('Contacts')).toBeInTheDocument();
|
|
expect(screen.getByText('Repeaters')).toBeInTheDocument();
|
|
expect(screen.getByText('Direct Messages')).toBeInTheDocument();
|
|
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
|
|
expect(screen.getByText('Sent (Outgoing)')).toBeInTheDocument();
|
|
expect(screen.getByText('Total stored')).toBeInTheDocument();
|
|
expect(screen.getByText('Decrypted')).toBeInTheDocument();
|
|
expect(screen.getByText('Undecrypted')).toBeInTheDocument();
|
|
expect(screen.getByText('Path Hash Width (24h)')).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/)
|
|
).toBeInTheDocument();
|
|
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
|
|
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
|
|
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
|
|
expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
|
|
expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
|
|
});
|
|
|
|
it('fetches statistics when expanded in mobile external-nav mode', async () => {
|
|
const mockStats: StatisticsResponse = {
|
|
busiest_channels_24h: [],
|
|
contact_count: 10,
|
|
repeater_count: 3,
|
|
channel_count: 5,
|
|
total_packets: 200,
|
|
decrypted_packets: 150,
|
|
undecrypted_packets: 50,
|
|
total_dms: 25,
|
|
total_channel_messages: 80,
|
|
total_outgoing: 30,
|
|
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
|
|
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
|
|
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
|
|
path_hash_width_24h: {
|
|
total_packets: 120,
|
|
single_byte: 60,
|
|
double_byte: 36,
|
|
triple_byte: 24,
|
|
single_byte_pct: 50,
|
|
double_byte_pct: 30,
|
|
triple_byte_pct: 20,
|
|
},
|
|
packets_per_hour_72h: [],
|
|
noise_floor_24h: {
|
|
sample_interval_seconds: 60,
|
|
coverage_seconds: 0,
|
|
latest_noise_floor_dbm: null,
|
|
latest_timestamp: null,
|
|
samples: [],
|
|
},
|
|
};
|
|
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify(mockStats), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
);
|
|
|
|
renderModal({
|
|
mobile: true,
|
|
externalSidebarNav: true,
|
|
desktopSection: 'radio',
|
|
});
|
|
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByRole('button', { name: /Statistics/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(fetchSpy).toHaveBeenCalledWith('./api/statistics', expect.any(Object));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Network')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('renders routed hourly checkbox and calls save on toggle', async () => {
|
|
const onSaveAppSettings = vi.fn(async () => {});
|
|
|
|
renderModal({
|
|
externalSidebarNav: true,
|
|
desktopSection: 'database',
|
|
onSaveAppSettings,
|
|
});
|
|
|
|
const checkbox = screen.getByRole('checkbox', {
|
|
name: /Poll direct\/routed-path repeaters hourly/i,
|
|
}) as HTMLInputElement;
|
|
|
|
expect(checkbox).toBeInTheDocument();
|
|
expect(checkbox.checked).toBe(false);
|
|
|
|
fireEvent.click(checkbox);
|
|
|
|
await waitFor(() => {
|
|
expect(onSaveAppSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ telemetry_routed_hourly: true })
|
|
);
|
|
});
|
|
});
|
|
|
|
it('shows route badge per tracked repeater', async () => {
|
|
const directKey = 'bb'.repeat(32);
|
|
|
|
renderModal({
|
|
externalSidebarNav: true,
|
|
desktopSection: 'database',
|
|
appSettings: {
|
|
...baseSettings,
|
|
tracked_telemetry_repeaters: [directKey],
|
|
},
|
|
trackedTelemetryRepeaters: [directKey],
|
|
contacts: [
|
|
{
|
|
public_key: directKey,
|
|
name: 'DirectRepeater',
|
|
type: 2,
|
|
flags: 0,
|
|
direct_path: 'aabb',
|
|
direct_path_len: 1,
|
|
direct_path_hash_mode: 1,
|
|
last_advert: null,
|
|
lat: null,
|
|
lon: null,
|
|
last_seen: null,
|
|
on_radio: false,
|
|
favorite: false,
|
|
last_contacted: null,
|
|
last_read_at: null,
|
|
first_seen: null,
|
|
effective_route: { path: 'aabb', path_len: 1, path_hash_mode: 1 },
|
|
effective_route_source: 'direct',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(screen.getByText('DirectRepeater')).toBeInTheDocument();
|
|
expect(screen.getByText('direct')).toBeInTheDocument();
|
|
});
|
|
});
|