Make repeater neighbor display need a GPS fix to show map + distance, and fetch before display. Closes #58.

This commit is contained in:
Jack Kingsman
2026-03-12 16:15:03 -07:00
parent 07934093e6
commit 07fd88a4d6
15 changed files with 516 additions and 102 deletions
@@ -13,6 +13,7 @@ const mockHook: {
loginError: null,
paneData: {
status: null,
nodeInfo: null,
neighbors: null,
acl: null,
radioSettings: null,
@@ -23,6 +24,7 @@ const mockHook: {
},
paneStates: {
status: { loading: false, attempt: 0, error: null },
nodeInfo: { 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 },
@@ -63,6 +65,7 @@ vi.mock('react-leaflet', () => ({
TileLayer: () => null,
CircleMarker: () => null,
Popup: () => null,
Polyline: () => null,
}));
const REPEATER_KEY = 'aa'.repeat(32);
@@ -120,6 +123,7 @@ describe('RepeaterDashboard', () => {
mockHook.loginError = null;
mockHook.paneData = {
status: null,
nodeInfo: null,
neighbors: null,
acl: null,
radioSettings: null,
@@ -130,6 +134,7 @@ describe('RepeaterDashboard', () => {
};
mockHook.paneStates = {
status: { loading: false, attempt: 0, error: null },
nodeInfo: { 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 },
@@ -157,6 +162,7 @@ describe('RepeaterDashboard', () => {
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry')).toBeInTheDocument();
expect(screen.getByText('Node Info')).toBeInTheDocument();
expect(screen.getByText('Neighbors')).toBeInTheDocument();
expect(screen.getByText('ACL')).toBeInTheDocument();
expect(screen.getByText('Radio Settings')).toBeInTheDocument();
@@ -226,6 +232,102 @@ describe('RepeaterDashboard', () => {
expect(screen.getByText('Timeout')).toBeInTheDocument();
});
it('shows GPS unavailable message for neighbors when repeater coords are missing', () => {
mockHook.loggedIn = true;
mockHook.paneData.neighbors = {
neighbors: [
{ pubkey_prefix: 'bbbbbbbbbbbb', name: 'Neighbor', snr: 7.2, last_heard_seconds: 9 },
],
};
mockHook.paneData.nodeInfo = {
name: 'TestRepeater',
lat: '0',
lon: '0',
clock_utc: null,
};
mockHook.paneStates.neighbors = {
loading: false,
attempt: 1,
error: null,
fetched_at: Date.now(),
};
mockHook.paneStates.nodeInfo = {
loading: false,
attempt: 1,
error: null,
fetched_at: Date.now(),
};
render(<RepeaterDashboard {...defaultProps} />);
expect(
screen.getByText(
'GPS info failed to fetch; map and distance data not available. This may be due to missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try refreshing.'
)
).toBeInTheDocument();
expect(screen.queryByText('Dist')).not.toBeInTheDocument();
});
it('shows neighbor distance when repeater radio settings include valid coords', () => {
mockHook.loggedIn = true;
mockHook.paneData.neighbors = {
neighbors: [
{ pubkey_prefix: 'bbbbbbbbbbbb', name: 'Neighbor', snr: 7.2, last_heard_seconds: 9 },
],
};
mockHook.paneData.nodeInfo = {
name: 'TestRepeater',
lat: '-31.9500',
lon: '115.8600',
clock_utc: null,
};
mockHook.paneStates.neighbors = {
loading: false,
attempt: 1,
error: null,
fetched_at: Date.now(),
};
mockHook.paneStates.nodeInfo = {
loading: false,
attempt: 1,
error: null,
fetched_at: Date.now(),
};
const contactsWithNeighbor = [
...contacts,
{
public_key: 'bbbbbbbbbbbb0000000000000000000000000000000000000000000000000000',
name: 'Neighbor',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
last_advert: null,
lat: -31.94,
lon: 115.87,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
];
render(<RepeaterDashboard {...defaultProps} contacts={contactsWithNeighbor} />);
expect(screen.getByText('Dist')).toBeInTheDocument();
expect(
screen.queryByText(
'GPS info failed to fetch; map and distance data not available. This may be due to missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try refreshing.'
)
).not.toBeInTheDocument();
});
it('shows fetching state with attempt counter', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = { loading: true, attempt: 2, error: null };
@@ -264,6 +366,24 @@ describe('RepeaterDashboard', () => {
expect(screen.getByText('7.5 dB')).toBeInTheDocument();
});
it('formats the radio tuple and preserves the raw tuple in a tooltip', () => {
mockHook.loggedIn = true;
mockHook.paneData.radioSettings = {
firmware_version: 'v1.0',
radio: '910.5250244,62.5,7,5',
tx_power: '20',
airtime_factor: '0',
repeat_enabled: '1',
flood_max: '3',
};
render(<RepeaterDashboard {...defaultProps} />);
const formatted = screen.getByText('910.525 MHz, BW 62.5 kHz, SF7, CR5');
expect(formatted).toBeInTheDocument();
expect(formatted).toHaveAttribute('title', '910.5250244,62.5,7,5');
});
it('shows fetched time and relative age when pane data has been loaded', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = {
+57 -6
View File
@@ -12,6 +12,7 @@ vi.mock('../api', () => ({
api: {
repeaterLogin: vi.fn(),
repeaterStatus: vi.fn(),
repeaterNodeInfo: vi.fn(),
repeaterNeighbors: vi.fn(),
repeaterAcl: vi.fn(),
repeaterRadioSettings: vi.fn(),
@@ -284,8 +285,12 @@ describe('useRepeaterDashboard', () => {
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.repeaterNodeInfo.mockResolvedValueOnce({
name: null,
lat: null,
lon: null,
clock_utc: null,
});
mockApi.repeaterRadioSettings.mockResolvedValueOnce({
firmware_version: 'v1.0',
radio: null,
@@ -293,11 +298,9 @@ describe('useRepeaterDashboard', () => {
airtime_factor: null,
repeat_enabled: null,
flood_max: null,
name: null,
lat: null,
lon: null,
clock_utc: null,
});
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
mockApi.repeaterAcl.mockResolvedValueOnce({ acl: [] });
mockApi.repeaterAdvertIntervals.mockResolvedValueOnce({
advert_interval: null,
flood_advert_interval: null,
@@ -315,6 +318,7 @@ describe('useRepeaterDashboard', () => {
});
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNodeInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterAcl).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterRadioSettings).toHaveBeenCalledTimes(1);
@@ -323,6 +327,53 @@ describe('useRepeaterDashboard', () => {
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
});
it('refreshing neighbors fetches node info first', async () => {
mockApi.repeaterNodeInfo.mockResolvedValueOnce({
name: 'Repeater',
lat: '-31.9523',
lon: '115.8613',
clock_utc: null,
});
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('neighbors');
});
expect(mockApi.repeaterNodeInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNodeInfo.mock.invocationCallOrder[0]).toBeLessThan(
mockApi.repeaterNeighbors.mock.invocationCallOrder[0]
);
expect(result.current.paneData.nodeInfo?.lat).toBe('-31.9523');
expect(result.current.paneData.neighbors).toEqual({ neighbors: [] });
});
it('refreshing neighbors reuses already-fetched node info', async () => {
mockApi.repeaterNodeInfo.mockResolvedValueOnce({
name: 'Repeater',
lat: '-31.9523',
lon: '115.8613',
clock_utc: null,
});
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('neighbors');
});
await act(async () => {
await result.current.refreshPane('neighbors');
});
expect(mockApi.repeaterNodeInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(2);
});
it('restores dashboard state when navigating away and back to the same repeater', async () => {
const statusData = { battery_volts: 4.2 };
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });