Files
Remote-Terminal-for-MeshCore/frontend/src/test/pathUtils.test.ts

625 lines
18 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
parsePathHops,
findContactsByPrefix,
calculateDistance,
sortContactsByDistance,
resolvePath,
formatDistance,
formatHopCounts,
} from '../utils/pathUtils';
import type { Contact, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
// Helper to create mock contacts
function createContact(overrides: Partial<Contact> = {}): Contact {
return {
public_key: 'AAAAAAAAAAAABBBBBBBBBBBBCCCCCCCCCCCCDDDDDDDDDDDDEEEEEEEEEEEE',
name: 'Test Contact',
type: CONTACT_TYPE_REPEATER,
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,
...overrides,
};
}
// Helper to create mock config
function createConfig(overrides: Partial<RadioConfig> = {}): RadioConfig {
return {
public_key: 'FFFFFFFFFFFFEEEEEEEEEEEEDDDDDDDDDDDDCCCCCCCCCCCCBBBBBBBBBBBB',
name: 'MyRadio',
lat: 40.7128,
lon: -74.006,
tx_power: 10,
max_tx_power: 20,
radio: { freq: 915, bw: 250, sf: 10, cr: 8 },
...overrides,
};
}
describe('parsePathHops', () => {
it('returns empty array for null/empty path', () => {
expect(parsePathHops(null)).toEqual([]);
expect(parsePathHops(undefined)).toEqual([]);
expect(parsePathHops('')).toEqual([]);
});
it('parses single hop', () => {
expect(parsePathHops('1A')).toEqual(['1A']);
});
it('parses multiple hops', () => {
expect(parsePathHops('1A2B3C')).toEqual(['1A', '2B', '3C']);
});
it('converts to uppercase', () => {
expect(parsePathHops('1a2b')).toEqual(['1A', '2B']);
});
it('handles odd length by ignoring last character', () => {
expect(parsePathHops('1A2B3')).toEqual(['1A', '2B']);
});
});
describe('findContactsByPrefix', () => {
const contacts: Contact[] = [
createContact({
public_key: '1AAAAA' + 'A'.repeat(52),
name: 'Repeater1',
type: CONTACT_TYPE_REPEATER,
}),
createContact({
public_key: '1ABBBB' + 'B'.repeat(52),
name: 'Repeater2',
type: CONTACT_TYPE_REPEATER,
}),
createContact({
public_key: '2BAAAA' + 'A'.repeat(52),
name: 'Repeater3',
type: CONTACT_TYPE_REPEATER,
}),
createContact({
public_key: '1ACCCC' + 'C'.repeat(52),
name: 'Client1',
type: 1, // client
}),
];
it('finds matching repeaters', () => {
const matches = findContactsByPrefix('1A', contacts, true);
expect(matches).toHaveLength(2);
expect(matches.map((c) => c.name)).toContain('Repeater1');
expect(matches.map((c) => c.name)).toContain('Repeater2');
});
it('returns empty array for no match', () => {
expect(findContactsByPrefix('XX', contacts, true)).toEqual([]);
});
it('is case insensitive', () => {
expect(findContactsByPrefix('1a', contacts, true)).toHaveLength(2);
});
it('excludes non-repeaters when repeatersOnly is true', () => {
const matches = findContactsByPrefix('1A', contacts, true);
expect(matches.every((c) => c.type === CONTACT_TYPE_REPEATER)).toBe(true);
expect(matches.map((c) => c.name)).not.toContain('Client1');
});
it('includes all types when repeatersOnly is false', () => {
const matches = findContactsByPrefix('1A', contacts, false);
expect(matches).toHaveLength(3);
expect(matches.map((c) => c.name)).toContain('Client1');
});
});
describe('calculateDistance', () => {
it('returns null for null coordinates', () => {
expect(calculateDistance(null, 0, 0, 0)).toBeNull();
expect(calculateDistance(0, null, 0, 0)).toBeNull();
expect(calculateDistance(0, 0, null, 0)).toBeNull();
expect(calculateDistance(0, 0, 0, null)).toBeNull();
});
it('returns 0 for same point', () => {
expect(calculateDistance(40.7128, -74.006, 40.7128, -74.006)).toBe(0);
});
it('calculates known distances approximately correctly', () => {
// NYC (40.7128, -74.0060) to LA (34.0522, -118.2437) is approximately 3944 km
const distance = calculateDistance(40.7128, -74.006, 34.0522, -118.2437);
expect(distance).not.toBeNull();
expect(distance).toBeGreaterThan(3900);
expect(distance).toBeLessThan(4000);
});
it('handles short distances', () => {
// About 1km apart in NYC
const distance = calculateDistance(40.7128, -74.006, 40.7218, -74.006);
expect(distance).not.toBeNull();
expect(distance).toBeGreaterThan(0.9);
expect(distance).toBeLessThan(1.1);
});
});
describe('sortContactsByDistance', () => {
const contactClose = createContact({
public_key: 'AA' + 'A'.repeat(62),
name: 'Close',
lat: 40.7228,
lon: -74.006,
});
const contactFar = createContact({
public_key: 'BB' + 'B'.repeat(62),
name: 'Far',
lat: 40.9,
lon: -74.006,
});
const contactNoLocation = createContact({
public_key: 'CC' + 'C'.repeat(62),
name: 'NoLoc',
lat: null,
lon: null,
});
it('sorts by distance ascending', () => {
const sorted = sortContactsByDistance([contactFar, contactClose], 40.7128, -74.006);
expect(sorted[0].name).toBe('Close');
expect(sorted[1].name).toBe('Far');
});
it('places contacts without location at end', () => {
const sorted = sortContactsByDistance(
[contactNoLocation, contactClose, contactFar],
40.7128,
-74.006
);
expect(sorted[0].name).toBe('Close');
expect(sorted[1].name).toBe('Far');
expect(sorted[2].name).toBe('NoLoc');
});
it('returns unsorted if reference is null', () => {
const contacts = [contactFar, contactClose];
const sorted = sortContactsByDistance(contacts, null, -74.006);
expect(sorted[0].name).toBe(contacts[0].name);
});
});
describe('resolvePath', () => {
const repeater1 = createContact({
public_key: '1A' + 'A'.repeat(62),
name: 'Repeater1',
type: CONTACT_TYPE_REPEATER,
lat: 40.75,
lon: -74.0,
});
const repeater2 = createContact({
public_key: '2B' + 'B'.repeat(62),
name: 'Repeater2',
type: CONTACT_TYPE_REPEATER,
lat: 40.8,
lon: -73.95,
});
const contacts = [repeater1, repeater2];
const sender = {
name: 'Sender',
publicKeyOrPrefix: '5E' + 'E'.repeat(62),
lat: 40.7,
lon: -74.05,
};
const config = createConfig({
public_key: 'FF' + 'F'.repeat(62),
name: 'MyRadio',
lat: 40.85,
lon: -73.9,
});
it('resolves simple path with known repeaters', () => {
const result = resolvePath('1A2B', sender, contacts, config);
expect(result.sender.name).toBe('Sender');
expect(result.sender.prefix).toBe('5E');
expect(result.hops).toHaveLength(2);
expect(result.hops[0].prefix).toBe('1A');
expect(result.hops[0].matches).toHaveLength(1);
expect(result.hops[0].matches[0].name).toBe('Repeater1');
expect(result.hops[1].prefix).toBe('2B');
expect(result.hops[1].matches).toHaveLength(1);
expect(result.hops[1].matches[0].name).toBe('Repeater2');
expect(result.receiver.name).toBe('MyRadio');
expect(result.receiver.prefix).toBe('FF');
});
it('handles unknown repeaters (no matches)', () => {
const result = resolvePath('XX', sender, contacts, config);
expect(result.hops).toHaveLength(1);
expect(result.hops[0].prefix).toBe('XX');
expect(result.hops[0].matches).toHaveLength(0);
});
it('handles ambiguous repeaters (multiple matches)', () => {
// Create two repeaters with same prefix
const ambiguousContacts = [
createContact({
public_key: '1A' + 'A'.repeat(62),
name: 'Repeater1A',
type: CONTACT_TYPE_REPEATER,
lat: 40.75,
lon: -74.0,
}),
createContact({
public_key: '1A' + 'B'.repeat(62),
name: 'Repeater1B',
type: CONTACT_TYPE_REPEATER,
lat: 40.76,
lon: -73.99,
}),
];
const result = resolvePath('1A', sender, ambiguousContacts, config);
expect(result.hops).toHaveLength(1);
expect(result.hops[0].matches).toHaveLength(2);
// Should be sorted by distance from sender
expect(result.hops[0].matches[0].name).toBe('Repeater1A');
});
it('calculates total distance when all locations known', () => {
const result = resolvePath('1A2B', sender, contacts, config);
expect(result.totalDistances).not.toBeNull();
expect(result.totalDistances!.length).toBeGreaterThan(0);
expect(result.totalDistances![0]).toBeGreaterThan(0);
});
it('returns null totalDistances when locations unknown', () => {
const unknownRepeater = createContact({
public_key: 'XX' + 'X'.repeat(62),
name: 'Unknown',
type: CONTACT_TYPE_REPEATER,
lat: null,
lon: null,
});
const result = resolvePath('XX', sender, [unknownRepeater], config);
expect(result.totalDistances).toBeNull();
});
it('handles empty path', () => {
const result = resolvePath('', sender, contacts, config);
expect(result.hops).toHaveLength(0);
expect(result.sender.name).toBe('Sender');
expect(result.receiver.name).toBe('MyRadio');
});
it('handles null config gracefully', () => {
const result = resolvePath('1A', sender, contacts, null);
expect(result.receiver.name).toBe('Unknown');
expect(result.receiver.prefix).toBe('??');
});
it('excludes receiver distance when receiver location is (0, 0)', () => {
const configAtOrigin = createConfig({
public_key: 'FF' + 'F'.repeat(62),
name: 'MyRadio',
lat: 0,
lon: 0,
});
const result = resolvePath('1A', sender, contacts, configAtOrigin);
// Total distance should NOT include the final leg to receiver
// It should only be sender -> repeater1
expect(result.totalDistances).not.toBeNull();
const senderToRepeater = calculateDistance(
sender.lat,
sender.lon,
repeater1.lat,
repeater1.lon
);
expect(result.totalDistances![0]).toBeCloseTo(senderToRepeater!, 1);
});
it('skips distance after ambiguous hops', () => {
// Create two repeaters with same prefix (ambiguous)
const ambiguousContacts = [
createContact({
public_key: '1A' + 'A'.repeat(62),
name: 'Repeater1A',
type: CONTACT_TYPE_REPEATER,
lat: 40.75,
lon: -74.0,
}),
createContact({
public_key: '1A' + 'B'.repeat(62),
name: 'Repeater1B',
type: CONTACT_TYPE_REPEATER,
lat: 40.76,
lon: -73.99,
}),
// Known repeater after the ambiguous one
createContact({
public_key: '2B' + 'B'.repeat(62),
name: 'Repeater2',
type: CONTACT_TYPE_REPEATER,
lat: 40.8,
lon: -73.95,
}),
];
const result = resolvePath('1A2B', sender, ambiguousContacts, config);
// First hop is ambiguous, second hop is known
expect(result.hops[0].matches).toHaveLength(2);
expect(result.hops[1].matches).toHaveLength(1);
// First hop is ambiguous, so no single distanceFromPrev
// (UI shows individual distances for each match via getDistanceForContact)
expect(result.hops[0].distanceFromPrev).toBeNull();
// Second hop should also NOT have distanceFromPrev because previous hop was ambiguous
expect(result.hops[1].distanceFromPrev).toBeNull();
});
it('calculates partial distance when sender has no location', () => {
const senderNoLocation = {
name: 'SenderNoLoc',
publicKeyOrPrefix: '5E' + 'E'.repeat(62),
lat: null,
lon: null,
};
const result = resolvePath('1A2B', senderNoLocation, contacts, config);
// First hop has no distance (can't calculate from unknown sender location)
expect(result.hops[0].distanceFromPrev).toBeNull();
// Second hop has distance (from first hop to second hop)
expect(result.hops[1].distanceFromPrev).not.toBeNull();
// Total distance should start from first known hop
expect(result.totalDistances).not.toBeNull();
expect(result.totalDistances![0]).toBeGreaterThan(0);
});
it('returns null totalDistances when all hops have no location', () => {
const noLocationContacts = [
createContact({
public_key: '1A' + 'A'.repeat(62),
name: 'NoLoc1',
type: CONTACT_TYPE_REPEATER,
lat: null,
lon: null,
}),
createContact({
public_key: '2B' + 'B'.repeat(62),
name: 'NoLoc2',
type: CONTACT_TYPE_REPEATER,
lat: null,
lon: null,
}),
];
const senderNoLocation = {
name: 'SenderNoLoc',
publicKeyOrPrefix: '5E' + 'E'.repeat(62),
lat: null,
lon: null,
};
const result = resolvePath('1A2B', senderNoLocation, noLocationContacts, config);
expect(result.totalDistances).toBeNull();
});
it('treats contact at (0, 0) as having no location', () => {
const contactAtOrigin = createContact({
public_key: '1A' + 'A'.repeat(62),
name: 'AtOrigin',
type: CONTACT_TYPE_REPEATER,
lat: 0,
lon: 0,
});
const result = resolvePath('1A', sender, [contactAtOrigin], config);
// Hop should match but have no distance (0, 0 treated as invalid)
expect(result.hops).toHaveLength(1);
expect(result.hops[0].matches).toHaveLength(1);
expect(result.hops[0].distanceFromPrev).toBeNull();
});
it('treats sender at (0, 0) as having no location', () => {
const senderAtOrigin = {
name: 'SenderAtOrigin',
publicKeyOrPrefix: '5E' + 'E'.repeat(62),
lat: 0,
lon: 0,
};
const result = resolvePath('1A2B', senderAtOrigin, contacts, config);
// First hop should have no distance (sender at 0,0 treated as invalid)
expect(result.hops[0].distanceFromPrev).toBeNull();
// But second hop CAN have distance (from first hop)
expect(result.hops[1].distanceFromPrev).not.toBeNull();
});
it('sets hasGaps to false when all hops are unambiguous with locations', () => {
const result = resolvePath('1A2B', sender, contacts, config);
expect(result.hasGaps).toBe(false);
});
it('sets hasGaps to true when path has unknown hops', () => {
const result = resolvePath('XX', sender, contacts, config);
expect(result.hasGaps).toBe(true);
});
it('sets hasGaps to true when path has ambiguous hops', () => {
const ambiguousContacts = [
createContact({
public_key: '1A' + 'A'.repeat(62),
name: 'Repeater1A',
type: CONTACT_TYPE_REPEATER,
lat: 40.75,
lon: -74.0,
}),
createContact({
public_key: '1A' + 'B'.repeat(62),
name: 'Repeater1B',
type: CONTACT_TYPE_REPEATER,
lat: 40.76,
lon: -73.99,
}),
];
const result = resolvePath('1A', sender, ambiguousContacts, config);
expect(result.hasGaps).toBe(true);
});
it('sets hasGaps to true when sender has no location', () => {
const senderNoLocation = {
name: 'SenderNoLoc',
publicKeyOrPrefix: '5E' + 'E'.repeat(62),
lat: null,
lon: null,
};
const result = resolvePath('1A', senderNoLocation, contacts, config);
expect(result.hasGaps).toBe(true);
});
it('sets hasGaps to true when receiver has no valid location', () => {
const configNoLocation = createConfig({
public_key: 'FF' + 'F'.repeat(62),
name: 'MyRadio',
lat: 0,
lon: 0,
});
const result = resolvePath('1A', sender, contacts, configNoLocation);
expect(result.hasGaps).toBe(true);
});
it('includes receiver public key when config has one', () => {
const result = resolvePath('1A', sender, contacts, config);
expect(result.receiver.publicKey).toBe(config.public_key);
});
it('sets receiver public key to null when config has no public key', () => {
const configNoKey = createConfig({
public_key: undefined as unknown as string,
name: 'NoKeyRadio',
});
const result = resolvePath('1A', sender, contacts, configNoKey);
expect(result.receiver.publicKey).toBeNull();
});
});
describe('formatDistance', () => {
it('formats distances under 1km in meters', () => {
expect(formatDistance(0.5)).toBe('500m');
expect(formatDistance(0.123)).toBe('123m');
expect(formatDistance(0.9999)).toBe('1000m');
});
it('formats distances at or above 1km with one decimal', () => {
expect(formatDistance(1)).toBe('1.0km');
expect(formatDistance(1.5)).toBe('1.5km');
expect(formatDistance(12.34)).toBe('12.3km');
expect(formatDistance(100)).toBe('100.0km');
});
it('rounds meters to nearest integer', () => {
expect(formatDistance(0.4567)).toBe('457m');
expect(formatDistance(0.001)).toBe('1m');
});
});
describe('formatHopCounts', () => {
it('returns empty for null paths', () => {
const result = formatHopCounts(null);
expect(result.display).toBe('');
expect(result.allDirect).toBe(true);
expect(result.hasMultiple).toBe(false);
});
it('returns empty for empty paths array', () => {
const result = formatHopCounts([]);
expect(result.display).toBe('');
expect(result.allDirect).toBe(true);
expect(result.hasMultiple).toBe(false);
});
it('formats single direct path as "d"', () => {
const result = formatHopCounts([{ path: '', received_at: 1700000000 }]);
expect(result.display).toBe('d');
expect(result.allDirect).toBe(true);
expect(result.hasMultiple).toBe(false);
});
it('formats single multi-hop path with hop count', () => {
const result = formatHopCounts([{ path: '1A2B', received_at: 1700000000 }]);
expect(result.display).toBe('2');
expect(result.allDirect).toBe(false);
expect(result.hasMultiple).toBe(false);
});
it('formats multiple paths sorted by hop count', () => {
const result = formatHopCounts([
{ path: '1A2B3C', received_at: 1700000000 }, // 3 hops
{ path: '', received_at: 1700000001 }, // direct
{ path: '1A', received_at: 1700000002 }, // 1 hop
{ path: '1A2B3C', received_at: 1700000003 }, // 3 hops
]);
expect(result.display).toBe('d/1/3/3');
expect(result.allDirect).toBe(false);
expect(result.hasMultiple).toBe(true);
});
it('formats multiple direct paths', () => {
const result = formatHopCounts([
{ path: '', received_at: 1700000000 },
{ path: '', received_at: 1700000001 },
]);
expect(result.display).toBe('d/d');
expect(result.allDirect).toBe(true);
expect(result.hasMultiple).toBe(true);
});
it('handles mixed paths with multiple direct routes', () => {
const result = formatHopCounts([
{ path: '1A', received_at: 1700000000 }, // 1 hop
{ path: '', received_at: 1700000001 }, // direct
{ path: '', received_at: 1700000002 }, // direct
]);
expect(result.display).toBe('d/d/1');
expect(result.allDirect).toBe(false);
expect(result.hasMultiple).toBe(true);
});
});