mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-05 16:23:27 +02:00
Repeater UI overhaul
This commit is contained in:
@@ -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',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user