mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
782 lines
24 KiB
TypeScript
782 lines
24 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
parsePathHops,
|
||
extractPacketPayloadHex,
|
||
findContactsByPrefix,
|
||
calculateDistance,
|
||
formatRouteLabel,
|
||
formatRoutingOverrideInput,
|
||
getEffectiveContactRoute,
|
||
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,
|
||
first_seen: null,
|
||
...overrides,
|
||
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
|
||
};
|
||
}
|
||
|
||
// 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 },
|
||
path_hash_mode: 0,
|
||
path_hash_mode_supported: false,
|
||
...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']);
|
||
});
|
||
|
||
it('parses 2-byte hops when hopCount is provided', () => {
|
||
// 8 hex chars / 2 hops = 4 chars per hop (2 bytes)
|
||
expect(parsePathHops('AABBCCDD', 2)).toEqual(['AABB', 'CCDD']);
|
||
});
|
||
|
||
it('parses 3-byte hops when hopCount is provided', () => {
|
||
// 12 hex chars / 2 hops = 6 chars per hop (3 bytes)
|
||
expect(parsePathHops('AABBCCDDEEFF', 2)).toEqual(['AABBCC', 'DDEEFF']);
|
||
});
|
||
|
||
it('parses single 2-byte hop', () => {
|
||
expect(parsePathHops('AABB', 1)).toEqual(['AABB']);
|
||
});
|
||
|
||
it('parses single 3-byte hop', () => {
|
||
expect(parsePathHops('AABBCC', 1)).toEqual(['AABBCC']);
|
||
});
|
||
|
||
it('falls back to 2-char chunks when hopCount does not divide evenly', () => {
|
||
// 6 hex chars / 2 hops = 3 chars per hop (odd, invalid)
|
||
expect(parsePathHops('1A2B3C', 2)).toEqual(['1A', '2B', '3C']);
|
||
});
|
||
|
||
it('falls back to 2-char chunks when hopCount is null', () => {
|
||
expect(parsePathHops('AABBCCDD', null)).toEqual(['AA', 'BB', 'CC', 'DD']);
|
||
});
|
||
|
||
it('falls back to 2-char chunks when hopCount is 0', () => {
|
||
expect(parsePathHops('AABB', 0)).toEqual(['AA', 'BB']);
|
||
});
|
||
|
||
it('handles 2-byte hops with many hops', () => {
|
||
// 3 hops × 4 chars = 12 hex chars
|
||
expect(parsePathHops('AABB11223344', 3)).toEqual(['AABB', '1122', '3344']);
|
||
});
|
||
});
|
||
|
||
describe('extractPacketPayloadHex', () => {
|
||
it('extracts payload from legacy 1-byte-hop packet', () => {
|
||
expect(extractPacketPayloadHex('0902AABB48656C6C6F')).toBe('48656C6C6F');
|
||
});
|
||
|
||
it('extracts payload from 2-byte-hop packet', () => {
|
||
expect(extractPacketPayloadHex('0942AABBCCDD48656C6C6F')).toBe('48656C6C6F');
|
||
});
|
||
|
||
it('rejects reserved mode 3', () => {
|
||
expect(extractPacketPayloadHex('09C1AABBCCDDEEFF')).toBeNull();
|
||
});
|
||
|
||
it('rejects oversized path encoding', () => {
|
||
expect(extractPacketPayloadHex(`09BF${'AA'.repeat(189)}4869`)).toBeNull();
|
||
});
|
||
|
||
it('rejects packets with no payload after path', () => {
|
||
expect(extractPacketPayloadHex('0902AABB')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('contact routing helpers', () => {
|
||
it('prefers routing override over learned route', () => {
|
||
const effective = getEffectiveContactRoute(
|
||
createContact({
|
||
last_path: 'AABB',
|
||
last_path_len: 1,
|
||
out_path_hash_mode: 0,
|
||
route_override_path: 'AE92F13E',
|
||
route_override_len: 2,
|
||
route_override_hash_mode: 1,
|
||
})
|
||
);
|
||
|
||
expect(effective.path).toBe('AE92F13E');
|
||
expect(effective.pathLen).toBe(2);
|
||
expect(effective.pathHashMode).toBe(1);
|
||
expect(effective.forced).toBe(true);
|
||
});
|
||
|
||
it('formats route labels and override input', () => {
|
||
expect(formatRouteLabel(-1)).toBe('flood');
|
||
expect(formatRouteLabel(0)).toBe('direct');
|
||
expect(formatRouteLabel(2, true)).toBe('2 hops');
|
||
|
||
expect(
|
||
formatRoutingOverrideInput(
|
||
createContact({
|
||
route_override_path: 'AE92F13E',
|
||
route_override_len: 2,
|
||
route_override_hash_mode: 1,
|
||
})
|
||
)
|
||
).toBe('ae92,f13e');
|
||
});
|
||
});
|
||
|
||
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('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('uses explicit sender and receiver multibyte modes for endpoint prefixes', () => {
|
||
const result = resolvePath(
|
||
'',
|
||
{ ...sender, pathHashMode: 1 },
|
||
contacts,
|
||
createConfig({
|
||
public_key: 'ABCDEF' + 'F'.repeat(58),
|
||
path_hash_mode: 2,
|
||
})
|
||
);
|
||
|
||
expect(result.sender.prefix).toBe('5EEE');
|
||
expect(result.receiver.prefix).toBe('ABCDEF');
|
||
});
|
||
|
||
it('derives sender multibyte width from path metadata when sender mode is unknown', () => {
|
||
const result = resolvePath(
|
||
'1A2B3C4D',
|
||
{ ...sender, publicKeyOrPrefix: 'AABBCCDDEEFF' + '0'.repeat(52), pathHashMode: null },
|
||
contacts,
|
||
config,
|
||
2
|
||
);
|
||
|
||
expect(result.sender.prefix).toBe('AABB');
|
||
});
|
||
|
||
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();
|
||
});
|
||
|
||
it('resolves 2-byte hop path using hopCount parameter', () => {
|
||
// Create repeaters whose public keys match 4-char prefixes
|
||
const repeater2byte1 = createContact({
|
||
public_key: '1A2B' + 'A'.repeat(60),
|
||
name: 'Repeater2B1',
|
||
type: CONTACT_TYPE_REPEATER,
|
||
lat: 40.75,
|
||
lon: -74.0,
|
||
});
|
||
const repeater2byte2 = createContact({
|
||
public_key: '3C4D' + 'B'.repeat(60),
|
||
name: 'Repeater2B2',
|
||
type: CONTACT_TYPE_REPEATER,
|
||
lat: 40.8,
|
||
lon: -73.95,
|
||
});
|
||
const contacts2byte = [repeater2byte1, repeater2byte2];
|
||
|
||
// Path "1A2B3C4D" with hopCount=2 → two 4-char hops: "1A2B", "3C4D"
|
||
const result = resolvePath('1A2B3C4D', sender, contacts2byte, config, 2);
|
||
|
||
expect(result.hops).toHaveLength(2);
|
||
expect(result.hops[0].prefix).toBe('1A2B');
|
||
expect(result.hops[0].matches).toHaveLength(1);
|
||
expect(result.hops[0].matches[0].name).toBe('Repeater2B1');
|
||
expect(result.hops[1].prefix).toBe('3C4D');
|
||
expect(result.hops[1].matches).toHaveLength(1);
|
||
expect(result.hops[1].matches[0].name).toBe('Repeater2B2');
|
||
});
|
||
|
||
it('resolves same path differently without hopCount (legacy fallback)', () => {
|
||
// Without hopCount, "1A2B3C4D" → four 2-char hops: "1A", "2B", "3C", "4D"
|
||
const result = resolvePath('1A2B3C4D', sender, contacts, config);
|
||
|
||
expect(result.hops).toHaveLength(4);
|
||
expect(result.hops[0].prefix).toBe('1A');
|
||
expect(result.hops[1].prefix).toBe('2B');
|
||
expect(result.hops[2].prefix).toBe('3C');
|
||
expect(result.hops[3].prefix).toBe('4D');
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
it('uses path_len metadata for 2-byte hops instead of hex length', () => {
|
||
// 8 hex chars with path_len=2 → 2 hops (not 4 as legacy would infer)
|
||
const result = formatHopCounts([{ path: 'AABBCCDD', path_len: 2, received_at: 1700000000 }]);
|
||
expect(result.display).toBe('2');
|
||
expect(result.allDirect).toBe(false);
|
||
});
|
||
|
||
it('uses path_len metadata for 3-byte hops', () => {
|
||
// 12 hex chars with path_len=2 → 2 hops (not 6 as legacy)
|
||
const result = formatHopCounts([
|
||
{ path: 'AABBCCDDEEFF', path_len: 2, received_at: 1700000000 },
|
||
]);
|
||
expect(result.display).toBe('2');
|
||
});
|
||
|
||
it('falls back to legacy count when path_len is null', () => {
|
||
// 8 hex chars, no path_len → legacy: 8/2 = 4 hops
|
||
const result = formatHopCounts([{ path: 'AABBCCDD', received_at: 1700000000 }]);
|
||
expect(result.display).toBe('4');
|
||
});
|
||
|
||
it('mixes paths with and without path_len metadata', () => {
|
||
const result = formatHopCounts([
|
||
{ path: 'AABBCCDD', path_len: 2, received_at: 1700000000 }, // 2 hops (2-byte)
|
||
{ path: '1A2B', received_at: 1700000001 }, // 2 hops (legacy)
|
||
{ path: '', received_at: 1700000002 }, // direct
|
||
]);
|
||
expect(result.display).toBe('d/2/2');
|
||
expect(result.allDirect).toBe(false);
|
||
expect(result.hasMultiple).toBe(true);
|
||
});
|
||
});
|