Repeater UI overhaul

This commit is contained in:
Jack Kingsman
2026-02-25 21:02:38 -08:00
parent f4a383082e
commit 26fbfcd015
26 changed files with 3622 additions and 1305 deletions
-8
View File
@@ -39,8 +39,6 @@ const mocks = vi.hoisted(() => ({
incrementUnread: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
handleTelemetryRequest: vi.fn(),
handleRepeaterCommand: vi.fn(),
},
}));
@@ -75,12 +73,6 @@ vi.mock('../hooks', async (importOriginal) => {
markAllRead: mocks.hookFns.markAllRead,
trackNewMessage: mocks.hookFns.trackNewMessage,
}),
useRepeaterMode: () => ({
repeaterLoggedIn: false,
activeContactIsRepeater: false,
handleTelemetryRequest: mocks.hookFns.handleTelemetryRequest,
handleRepeaterCommand: mocks.hookFns.handleRepeaterCommand,
}),
getMessageContentKey: () => 'content-key',
};
});
@@ -44,12 +44,6 @@ vi.mock('../hooks', async (importOriginal) => {
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
}),
useRepeaterMode: () => ({
repeaterLoggedIn: false,
activeContactIsRepeater: false,
handleTelemetryRequest: vi.fn(),
handleRepeaterCommand: vi.fn(),
}),
getMessageContentKey: () => 'content-key',
};
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getLocalLabel, setLocalLabel, getContrastTextColor } from '../utils/localLabel';
describe('localLabel utilities', () => {
beforeEach(() => {
localStorage.clear();
});
describe('getLocalLabel', () => {
it('returns default when nothing stored', () => {
const label = getLocalLabel();
expect(label.text).toBe('');
expect(label.color).toBe('#062d60');
});
it('returns stored label', () => {
localStorage.setItem(
'remoteterm-local-label',
JSON.stringify({ text: 'Dev', color: '#ff0000' })
);
const label = getLocalLabel();
expect(label.text).toBe('Dev');
expect(label.color).toBe('#ff0000');
});
it('handles corrupted JSON gracefully', () => {
localStorage.setItem('remoteterm-local-label', '{bad json');
const label = getLocalLabel();
expect(label.text).toBe('');
expect(label.color).toBe('#062d60');
});
it('handles partial stored data', () => {
localStorage.setItem('remoteterm-local-label', JSON.stringify({ text: 'Hi' }));
const label = getLocalLabel();
expect(label.text).toBe('Hi');
expect(label.color).toBe('#062d60'); // falls back to default color
});
it('handles non-string values in stored data', () => {
localStorage.setItem('remoteterm-local-label', JSON.stringify({ text: 123, color: true }));
const label = getLocalLabel();
expect(label.text).toBe(''); // non-string falls back
expect(label.color).toBe('#062d60');
});
});
describe('setLocalLabel', () => {
it('stores label to localStorage', () => {
setLocalLabel('Test', '#00ff00');
const raw = localStorage.getItem('remoteterm-local-label');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.text).toBe('Test');
expect(parsed.color).toBe('#00ff00');
});
});
describe('getContrastTextColor', () => {
it('returns white for dark colors', () => {
expect(getContrastTextColor('#000000')).toBe('white');
expect(getContrastTextColor('#062d60')).toBe('white');
expect(getContrastTextColor('#333333')).toBe('white');
});
it('returns black for light colors', () => {
expect(getContrastTextColor('#ffffff')).toBe('black');
expect(getContrastTextColor('#ffff00')).toBe('black');
expect(getContrastTextColor('#00ff00')).toBe('black');
});
it('handles hex with # prefix', () => {
expect(getContrastTextColor('#000000')).toBe('white');
});
it('handles hex without # prefix', () => {
expect(getContrastTextColor('000000')).toBe('white');
expect(getContrastTextColor('ffffff')).toBe('black');
});
});
});
@@ -0,0 +1,270 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { RepeaterDashboard } from '../components/RepeaterDashboard';
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
import type { Contact, Conversation, Favorite } from '../types';
// Mock the hook — typed as mutable version of the return type
const mockHook: {
-readonly [K in keyof UseRepeaterDashboardResult]: UseRepeaterDashboardResult[K];
} = {
loggedIn: false,
loginLoading: false,
loginError: null,
paneData: {
status: null,
neighbors: null,
acl: null,
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
},
paneStates: {
status: { loading: false, attempt: 0, error: null },
neighbors: { loading: false, attempt: 0, error: null },
acl: { loading: false, attempt: 0, error: null },
radioSettings: { loading: false, attempt: 0, error: null },
advertIntervals: { loading: false, attempt: 0, error: null },
ownerInfo: { loading: false, attempt: 0, error: null },
lppTelemetry: { loading: false, attempt: 0, error: null },
},
consoleHistory: [],
consoleLoading: false,
login: vi.fn(),
loginAsGuest: vi.fn(),
refreshPane: vi.fn(),
loadAll: vi.fn(),
sendConsoleCommand: vi.fn(),
sendAdvert: vi.fn(),
rebootRepeater: vi.fn(),
syncClock: vi.fn(),
};
vi.mock('../hooks/useRepeaterDashboard', () => ({
useRepeaterDashboard: () => mockHook,
}));
// Mock sonner toast
vi.mock('../components/ui/sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
},
}));
// Mock leaflet imports (not needed in test)
vi.mock('react-leaflet', () => ({
MapContainer: () => null,
TileLayer: () => null,
CircleMarker: () => null,
Popup: () => null,
}));
const REPEATER_KEY = 'aa'.repeat(32);
const conversation: Conversation = {
type: 'contact',
id: REPEATER_KEY,
name: 'TestRepeater',
};
const contacts: Contact[] = [
{
public_key: REPEATER_KEY,
name: 'TestRepeater',
type: 2,
flags: 0,
last_path: null,
last_path_len: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
},
];
const favorites: Favorite[] = [];
const defaultProps = {
conversation,
contacts,
favorites,
radioLat: null,
radioLon: null,
radioName: null,
onTrace: vi.fn(),
onToggleFavorite: vi.fn(),
onDeleteContact: vi.fn(),
};
describe('RepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock hook state
mockHook.loggedIn = false;
mockHook.loginLoading = false;
mockHook.loginError = null;
mockHook.paneData = {
status: null,
neighbors: null,
acl: null,
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
};
mockHook.paneStates = {
status: { loading: false, attempt: 0, error: null },
neighbors: { loading: false, attempt: 0, error: null },
acl: { loading: false, attempt: 0, error: null },
radioSettings: { loading: false, attempt: 0, error: null },
advertIntervals: { loading: false, attempt: 0, error: null },
ownerInfo: { loading: false, attempt: 0, error: null },
lppTelemetry: { loading: false, attempt: 0, error: null },
};
mockHook.consoleHistory = [];
mockHook.consoleLoading = false;
});
it('renders login form when not logged in', () => {
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Login with Password')).toBeInTheDocument();
expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument();
expect(screen.getByText('Log in to access repeater dashboard')).toBeInTheDocument();
});
it('renders dashboard panes when logged in', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry')).toBeInTheDocument();
expect(screen.getByText('Neighbors')).toBeInTheDocument();
expect(screen.getByText('ACL')).toBeInTheDocument();
expect(screen.getByText('Radio Settings')).toBeInTheDocument();
expect(screen.getByText('Advert Intervals')).toBeInTheDocument(); // sub-section inside Radio Settings
expect(screen.getByText('LPP Sensors')).toBeInTheDocument();
expect(screen.getByText('Owner Info')).toBeInTheDocument();
expect(screen.getByText('Actions')).toBeInTheDocument();
expect(screen.getByText('Console')).toBeInTheDocument();
});
it('shows not fetched placeholder for empty panes', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
// All panes should show <not fetched> since data is null
const notFetched = screen.getAllByText('<not fetched>');
expect(notFetched.length).toBeGreaterThanOrEqual(7); // At least 7 data panes (incl. LPP Sensors)
});
it('shows Load All button when logged in', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Load All')).toBeInTheDocument();
});
it('calls loadAll when Load All button is clicked', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
fireEvent.click(screen.getByText('Load All'));
expect(mockHook.loadAll).toHaveBeenCalledTimes(1);
});
it('shows login error when present', () => {
mockHook.loginError = 'Invalid password';
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Invalid password')).toBeInTheDocument();
});
it('shows pane error when fetch fails', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = { loading: false, attempt: 3, error: 'Timeout' };
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Timeout')).toBeInTheDocument();
});
it('shows fetching state with attempt counter', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = { loading: true, attempt: 2, error: null };
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Fetching (attempt 2/3)...')).toBeInTheDocument();
});
it('renders telemetry data when available', () => {
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
};
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('4.200V')).toBeInTheDocument();
expect(screen.getByText('-120 dBm')).toBeInTheDocument();
expect(screen.getByText('7.5 dB')).toBeInTheDocument();
});
it('renders action buttons', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Send Advert')).toBeInTheDocument();
expect(screen.getByText('Sync Clock')).toBeInTheDocument();
expect(screen.getByText('Reboot')).toBeInTheDocument();
});
it('calls onTrace when trace button clicked', () => {
render(<RepeaterDashboard {...defaultProps} />);
// The trace button has title "Direct Trace"
fireEvent.click(screen.getByTitle('Direct Trace'));
expect(defaultProps.onTrace).toHaveBeenCalledTimes(1);
});
it('console shows placeholder when empty', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Type a CLI command below...')).toBeInTheDocument();
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { formatDuration, formatClockDrift } from '../components/RepeaterDashboard';
describe('formatDuration', () => {
it('formats seconds only', () => {
expect(formatDuration(0)).toBe('0s');
expect(formatDuration(30)).toBe('30s');
expect(formatDuration(59)).toBe('59s');
});
it('formats minutes only', () => {
expect(formatDuration(60)).toBe('1m');
expect(formatDuration(300)).toBe('5m');
expect(formatDuration(3540)).toBe('59m');
});
it('formats hours and minutes', () => {
expect(formatDuration(3600)).toBe('1h');
expect(formatDuration(3660)).toBe('1h1m');
expect(formatDuration(7200)).toBe('2h');
expect(formatDuration(7260)).toBe('2h1m');
});
it('formats days', () => {
expect(formatDuration(86400)).toBe('1d');
expect(formatDuration(86400 + 3600)).toBe('1d1h');
expect(formatDuration(86400 + 60)).toBe('1d1m');
expect(formatDuration(86400 + 3600 + 60)).toBe('1d1h1m');
expect(formatDuration(172800)).toBe('2d');
});
it('formats multi-day durations', () => {
expect(formatDuration(3 * 86400 + 12 * 3600 + 30 * 60)).toBe('3d12h30m');
});
});
describe('formatClockDrift', () => {
afterEach(() => {
vi.useRealTimers();
});
it('parses firmware format HH:MM - D/M/YYYY UTC', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-09T12:30:00Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('0s');
});
it('parses firmware format with seconds HH:MM:SS - D/M/YYYY', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-06-15T08:00:00Z'));
const result = formatClockDrift('08:00:00 - 15/6/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('0s');
});
it('reports large drift (>24h)', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-11T12:30:00Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(true);
expect(result.text).toBe('>24 hours!');
});
it('handles invalid date strings', () => {
const result = formatClockDrift('not a date');
expect(result.text).toBe('(invalid)');
expect(result.isLarge).toBe(false);
});
it('formats multi-unit drift', () => {
vi.useFakeTimers();
// 1h30m5s drift
vi.setSystemTime(new Date('2025-01-09T14:00:05Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('1h30m5s');
});
it('formats minutes and seconds drift', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-09T12:35:10Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('5m10s');
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { RepeaterLogin } from '../components/RepeaterLogin';
describe('RepeaterLogin', () => {
const defaultProps = {
repeaterName: 'TestRepeater',
loading: false,
error: null as string | null,
onLogin: vi.fn(),
onLoginAsGuest: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders repeater name and description', () => {
render(<RepeaterLogin {...defaultProps} />);
expect(screen.getByText('TestRepeater')).toBeInTheDocument();
expect(screen.getByText('Log in to access repeater dashboard')).toBeInTheDocument();
});
it('renders password input and buttons', () => {
render(<RepeaterLogin {...defaultProps} />);
expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument();
expect(screen.getByText('Login with Password')).toBeInTheDocument();
expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument();
});
it('calls onLogin with trimmed password on submit', () => {
render(<RepeaterLogin {...defaultProps} />);
const input = screen.getByPlaceholderText('Repeater password...');
fireEvent.change(input, { target: { value: ' secret ' } });
fireEvent.submit(screen.getByText('Login with Password').closest('form')!);
expect(defaultProps.onLogin).toHaveBeenCalledWith('secret');
});
it('calls onLoginAsGuest when guest button clicked', () => {
render(<RepeaterLogin {...defaultProps} />);
fireEvent.click(screen.getByText('Login as Guest / ACLs'));
expect(defaultProps.onLoginAsGuest).toHaveBeenCalledTimes(1);
});
it('disables inputs when loading', () => {
render(<RepeaterLogin {...defaultProps} loading={true} />);
expect(screen.getByPlaceholderText('Repeater password...')).toBeDisabled();
expect(screen.getByText('Logging in...')).toBeDisabled();
expect(screen.getByText('Login as Guest / ACLs')).toBeDisabled();
});
it('shows loading text on submit button', () => {
render(<RepeaterLogin {...defaultProps} loading={true} />);
expect(screen.getByText('Logging in...')).toBeInTheDocument();
expect(screen.queryByText('Login with Password')).not.toBeInTheDocument();
});
it('displays error message when present', () => {
render(<RepeaterLogin {...defaultProps} error="Invalid password" />);
expect(screen.getByText('Invalid password')).toBeInTheDocument();
});
it('does not call onLogin when loading', () => {
render(<RepeaterLogin {...defaultProps} loading={true} />);
fireEvent.submit(screen.getByText('Logging in...').closest('form')!);
expect(defaultProps.onLogin).not.toHaveBeenCalled();
});
});
@@ -1,126 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAirtimeTracking } from '../hooks/useAirtimeTracking';
import type { Message, TelemetryResponse } from '../types';
function createTelemetry(overrides: Partial<TelemetryResponse> = {}): TelemetryResponse {
return {
pubkey_prefix: 'AABB',
battery_volts: 3.7,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -80,
last_snr_db: 10,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 10,
rx_airtime_seconds: 5,
uptime_seconds: 3600,
sent_flood: 30,
sent_direct: 20,
recv_flood: 60,
recv_direct: 40,
flood_dups: 5,
direct_dups: 2,
full_events: 0,
clock_output: null,
neighbors: [],
acl: [],
...overrides,
};
}
function createDeferred<T>() {
let resolve: (value: T | PromiseLike<T>) => void = () => {};
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
const mockRequestTelemetry = vi.fn<(...args: unknown[]) => Promise<TelemetryResponse>>();
vi.mock('../api', () => ({
api: {
requestTelemetry: (...args: unknown[]) => mockRequestTelemetry(...args),
},
}));
describe('useAirtimeTracking stale poll guard', () => {
beforeEach(() => {
mockRequestTelemetry.mockReset();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('discards poll response when tracking was stopped during in-flight request', async () => {
const setMessages = vi.fn<React.Dispatch<React.SetStateAction<Message[]>>>();
// Initial telemetry for dutycycle_start succeeds immediately
mockRequestTelemetry.mockResolvedValueOnce(createTelemetry());
const { result } = renderHook(() => useAirtimeTracking(setMessages));
// Start tracking
await act(async () => {
await result.current.handleAirtimeCommand('dutycycle_start', 'repeater_a');
});
// setMessages was called with the start message
const startCallCount = setMessages.mock.calls.length;
expect(startCallCount).toBeGreaterThanOrEqual(1);
// Set up a deferred telemetry response for the poll
const deferred = createDeferred<TelemetryResponse>();
mockRequestTelemetry.mockReturnValueOnce(deferred.promise);
// Advance timer to trigger the 5-minute poll
act(() => {
vi.advanceTimersByTime(5 * 60 * 1000);
});
// Poll is now in-flight. Stop tracking (simulates conversation switch).
act(() => {
result.current.stopTracking();
});
// Resolve the stale telemetry response
await act(async () => {
deferred.resolve(createTelemetry({ uptime_seconds: 7200 }));
});
// setMessages should NOT have been called with the stale poll result
// Only the start message calls should exist
expect(setMessages.mock.calls.length).toBe(startCallCount);
});
it('appends poll result when tracking is still active', async () => {
const setMessages = vi.fn<React.Dispatch<React.SetStateAction<Message[]>>>();
// Initial telemetry for dutycycle_start
mockRequestTelemetry.mockResolvedValueOnce(createTelemetry());
const { result } = renderHook(() => useAirtimeTracking(setMessages));
await act(async () => {
await result.current.handleAirtimeCommand('dutycycle_start', 'repeater_a');
});
const startCallCount = setMessages.mock.calls.length;
// Set up poll response
mockRequestTelemetry.mockResolvedValueOnce(createTelemetry({ uptime_seconds: 7200 }));
// Advance timer to trigger the 5-minute poll
await act(async () => {
vi.advanceTimersByTime(5 * 60 * 1000);
});
// setMessages SHOULD have been called with the poll result
expect(setMessages.mock.calls.length).toBeGreaterThan(startCallCount);
});
});
@@ -0,0 +1,289 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import type { Conversation } from '../types';
// Mock the api module
vi.mock('../api', () => ({
api: {
repeaterLogin: vi.fn(),
repeaterStatus: vi.fn(),
repeaterNeighbors: vi.fn(),
repeaterAcl: vi.fn(),
repeaterRadioSettings: vi.fn(),
repeaterAdvertIntervals: vi.fn(),
repeaterOwnerInfo: vi.fn(),
repeaterLppTelemetry: vi.fn(),
sendRepeaterCommand: vi.fn(),
},
}));
// Mock sonner toast
vi.mock('../components/ui/sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
},
}));
// Get mock reference — cast to Record<string, Mock> for type-safe mock method access
const { api: _rawApi } = await import('../api');
const mockApi = _rawApi as unknown as Record<string, Mock>;
const REPEATER_KEY = 'aa'.repeat(32);
const repeaterConversation: Conversation = {
type: 'contact',
id: REPEATER_KEY,
name: 'TestRepeater',
};
describe('useRepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('starts with logged out state', () => {
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
expect(result.current.loggedIn).toBe(false);
expect(result.current.loginLoading).toBe(false);
expect(result.current.loginError).toBe(null);
});
it('login sets loggedIn on success', async () => {
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.login('secret');
});
expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe(null);
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret');
});
it('login sets error on failure', async () => {
mockApi.repeaterLogin.mockRejectedValueOnce(new Error('Auth failed'));
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.login('bad');
});
expect(result.current.loggedIn).toBe(false);
expect(result.current.loginError).toBe('Auth failed');
});
it('loginAsGuest calls login with empty password', async () => {
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.loginAsGuest();
});
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, '');
expect(result.current.loggedIn).toBe(true);
});
it('refreshPane stores data on success', async () => {
const statusData = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
};
mockApi.repeaterStatus.mockResolvedValueOnce(statusData);
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('status');
});
expect(result.current.paneData.status).toEqual(statusData);
expect(result.current.paneStates.status.loading).toBe(false);
expect(result.current.paneStates.status.error).toBe(null);
});
it('refreshPane retries up to 3 times', async () => {
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail1'));
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail2'));
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail3'));
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('status');
});
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(3);
expect(result.current.paneStates.status.error).toBe('fail3');
expect(result.current.paneData.status).toBe(null);
});
it('refreshPane succeeds on second attempt', async () => {
const statusData = { battery_volts: 3.7 };
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail1'));
mockApi.repeaterStatus.mockResolvedValueOnce(statusData);
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('status');
});
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(2);
expect(result.current.paneData.status).toEqual(statusData);
expect(result.current.paneStates.status.error).toBe(null);
});
it('sendConsoleCommand adds entries to console history', async () => {
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'ver',
response: 'v2.1.0',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.sendConsoleCommand('ver');
});
expect(result.current.consoleHistory).toHaveLength(2);
expect(result.current.consoleHistory[0].outgoing).toBe(true);
expect(result.current.consoleHistory[0].command).toBe('ver');
expect(result.current.consoleHistory[1].outgoing).toBe(false);
expect(result.current.consoleHistory[1].response).toBe('v2.1.0');
});
it('sendConsoleCommand adds error entry on failure', async () => {
mockApi.sendRepeaterCommand.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.sendConsoleCommand('ver');
});
expect(result.current.consoleHistory).toHaveLength(2);
expect(result.current.consoleHistory[0].outgoing).toBe(true);
expect(result.current.consoleHistory[0].command).toBe('ver');
expect(result.current.consoleHistory[1].outgoing).toBe(false);
expect(result.current.consoleHistory[1].response).toBe('Error: Network error');
expect(result.current.consoleLoading).toBe(false);
});
it('sendAdvert sends "advert" command', async () => {
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'advert',
response: 'ok',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.sendAdvert();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'advert');
});
it('rebootRepeater sends "reboot" command', async () => {
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'reboot',
response: 'ok',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.rebootRepeater();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'reboot');
});
it('syncClock sends "clock <epoch>" command', async () => {
const fakeNow = 1700000000000;
vi.spyOn(Date, 'now').mockReturnValue(fakeNow);
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'clock 1700000000',
response: 'ok',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.syncClock();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'clock 1700000000');
vi.restoreAllMocks();
});
it('loadAll calls refreshPane for all panes serially', async () => {
mockApi.repeaterStatus.mockResolvedValueOnce({ battery_volts: 4.0 });
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
mockApi.repeaterAcl.mockResolvedValueOnce({ acl: [] });
mockApi.repeaterRadioSettings.mockResolvedValueOnce({
firmware_version: 'v1.0',
radio: null,
tx_power: null,
airtime_factor: null,
repeat_enabled: null,
flood_max: null,
name: null,
lat: null,
lon: null,
clock_utc: null,
});
mockApi.repeaterAdvertIntervals.mockResolvedValueOnce({
advert_interval: null,
flood_advert_interval: null,
});
mockApi.repeaterOwnerInfo.mockResolvedValueOnce({
owner_info: null,
guest_password: null,
});
mockApi.repeaterLppTelemetry.mockResolvedValueOnce({ sensors: [] });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.loadAll();
});
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterAcl).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterRadioSettings).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterAdvertIntervals).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterOwnerInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
});
});