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 { AppSettings, AppSettingsUpdate, 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, favorites: [], 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, }; function renderModal(overrides?: { config?: RadioConfig | null; appSettings?: AppSettings; health?: HealthStatus; onSaveAppSettings?: (update: AppSettingsUpdate) => Promise; onRefreshAppSettings?: () => Promise; onSave?: (update: RadioConfigUpdate) => Promise; onClose?: () => void; onSetPrivateKey?: (key: string) => Promise; onReboot?: () => Promise; onDisconnect?: () => Promise; onReconnect?: () => Promise; onAdvertise?: (mode: RadioAdvertMode) => Promise; meshDiscovery?: RadioDiscoveryResponse | null; meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null; onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise; 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, }; const view = overrides?.externalSidebarNav ? render( ) : render(); 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' })); 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' })); 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 Settings" button in the Flood & Advert Control section const saveButtons = screen.getAllByRole('button', { name: 'Save 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 Settings" button in the Flood & Advert Control section const saveButtons = screen.getAllByRole('button', { name: 'Save 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('clears stale errors when switching external desktop sections', async () => { const onSaveAppSettings = vi.fn(async () => { throw new Error('Save failed'); }); const { view } = renderModal({ externalSidebarNav: true, desktopSection: 'database', onSaveAppSettings, }); fireEvent.click(screen.getByRole('button', { name: 'Save Settings' })); await waitFor(() => { expect(screen.getByText('Save failed')).toBeInTheDocument(); }); 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(); }); 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 & 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 to last viewed channel/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, }, noise_floor_24h: { sample_interval_seconds: 300, coverage_seconds: 3600, latest_noise_floor_dbm: -105, latest_timestamp: 1711800000, supported: true, 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, }, noise_floor_24h: { sample_interval_seconds: 300, coverage_seconds: 0, latest_noise_floor_dbm: null, latest_timestamp: null, supported: 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(); }); }); });