mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
More code rip out
This commit is contained in:
@@ -327,7 +327,6 @@ def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
|
||||
# Parse fixed-position fields
|
||||
public_key = payload[0:32].hex()
|
||||
timestamp = int.from_bytes(payload[32:36], byteorder="little")
|
||||
# signature = payload[36:100] # Not currently verified
|
||||
flags = payload[100]
|
||||
|
||||
# Parse flags
|
||||
|
||||
@@ -26,13 +26,7 @@ import {
|
||||
} from '../utils/lastViewedConversation';
|
||||
import { RADIO_PRESETS } from '../utils/radioPresets';
|
||||
|
||||
// Import for local use + re-export so existing imports from this file still work
|
||||
import {
|
||||
SETTINGS_SECTION_ORDER,
|
||||
SETTINGS_SECTION_LABELS,
|
||||
type SettingsSection,
|
||||
} from './settingsConstants';
|
||||
export { SETTINGS_SECTION_ORDER, SETTINGS_SECTION_LABELS, type SettingsSection };
|
||||
import { SETTINGS_SECTION_LABELS, type SettingsSection } from './settingsConstants';
|
||||
|
||||
interface SettingsModalBaseProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -108,7 +108,7 @@ function createLocalMessage(conversationKey: string, text: string, outgoing: boo
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseAirtimeTrackingResult {
|
||||
interface UseAirtimeTrackingResult {
|
||||
/** Returns true if this was an airtime command that was handled */
|
||||
handleAirtimeCommand: (command: string, conversationId: string) => Promise<boolean>;
|
||||
/** Stop any active airtime tracking */
|
||||
|
||||
@@ -144,7 +144,6 @@ export function useAppSettings() {
|
||||
|
||||
return {
|
||||
appSettings,
|
||||
setAppSettings,
|
||||
favorites,
|
||||
fetchAppSettings,
|
||||
handleSaveAppSettings,
|
||||
|
||||
@@ -57,13 +57,12 @@ export function getMessageContentKey(msg: Message): string {
|
||||
return `${msg.type}-${msg.conversation_key}-${msg.text}-${ts}`;
|
||||
}
|
||||
|
||||
export interface UseConversationMessagesResult {
|
||||
interface UseConversationMessagesResult {
|
||||
messages: Message[];
|
||||
messagesLoading: boolean;
|
||||
loadingOlder: boolean;
|
||||
hasOlderMessages: boolean;
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||
fetchMessages: (showLoading?: boolean) => Promise<void>;
|
||||
fetchOlderMessages: () => Promise<void>;
|
||||
addMessageIfNew: (msg: Message) => boolean;
|
||||
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
@@ -433,7 +432,6 @@ export function useConversationMessages(
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
setMessages,
|
||||
fetchMessages,
|
||||
fetchOlderMessages,
|
||||
addMessageIfNew,
|
||||
updateMessageAck,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { useAirtimeTracking } from './useAirtimeTracking';
|
||||
|
||||
// Format seconds into human-readable duration (e.g., 1d17h2m, 1h5m, 3m)
|
||||
export function formatDuration(seconds: number): string {
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
|
||||
const days = Math.floor(seconds / 86400);
|
||||
@@ -32,7 +32,7 @@ export function formatDuration(seconds: number): string {
|
||||
}
|
||||
|
||||
// Format telemetry response as human-readable text
|
||||
export function formatTelemetry(telemetry: TelemetryResponse): string {
|
||||
function formatTelemetry(telemetry: TelemetryResponse): string {
|
||||
const lines = [
|
||||
`Telemetry`,
|
||||
`Battery Voltage: ${telemetry.battery_volts.toFixed(3)}V`,
|
||||
@@ -57,7 +57,7 @@ export function formatTelemetry(telemetry: TelemetryResponse): string {
|
||||
}
|
||||
|
||||
// Format neighbors list as human-readable text
|
||||
export function formatNeighbors(neighbors: NeighborInfo[]): string {
|
||||
function formatNeighbors(neighbors: NeighborInfo[]): string {
|
||||
if (neighbors.length === 0) {
|
||||
return 'Neighbors\nNo neighbors reported';
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function formatNeighbors(neighbors: NeighborInfo[]): string {
|
||||
}
|
||||
|
||||
// Format ACL list as human-readable text
|
||||
export function formatAcl(acl: AclEntry[]): string {
|
||||
function formatAcl(acl: AclEntry[]): string {
|
||||
if (acl.length === 0) {
|
||||
return 'ACL\nNo ACL entries';
|
||||
}
|
||||
@@ -108,7 +108,7 @@ function createLocalMessage(
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseRepeaterModeResult {
|
||||
interface UseRepeaterModeResult {
|
||||
repeaterLoggedIn: boolean;
|
||||
activeContactIsRepeater: boolean;
|
||||
handleTelemetryRequest: (password: string) => Promise<void>;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types';
|
||||
import { takePrefetch } from '../prefetch';
|
||||
|
||||
export interface UseUnreadCountsResult {
|
||||
interface UseUnreadCountsResult {
|
||||
unreadCounts: Record<string, number>;
|
||||
/** Tracks which conversations have unread messages that mention the user */
|
||||
mentions: Record<string, boolean>;
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Message, MessagePath } from './types';
|
||||
export const MAX_CACHED_CONVERSATIONS = 20;
|
||||
export const MAX_MESSAGES_PER_ENTRY = 200;
|
||||
|
||||
export interface CacheEntry {
|
||||
interface CacheEntry {
|
||||
messages: Message[];
|
||||
seenContent: Set<string>;
|
||||
hasOlderMessages: boolean;
|
||||
@@ -139,8 +139,3 @@ export function remove(id: string): void {
|
||||
export function clear(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/** Get current cache size (for testing). */
|
||||
export function size(): number {
|
||||
return cache.size;
|
||||
}
|
||||
|
||||
@@ -1,74 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getAvatarText, getAvatarColor, getContactAvatar } from '../utils/contactAvatar';
|
||||
import { getContactAvatar } from '../utils/contactAvatar';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
|
||||
describe('getAvatarText', () => {
|
||||
it('returns first emoji when name contains emoji', () => {
|
||||
expect(getAvatarText('John 🚀 Doe', 'abc123')).toBe('🚀');
|
||||
expect(getAvatarText('🎉 Party', 'abc123')).toBe('🎉');
|
||||
expect(getAvatarText('Test 😀 More 🎯', 'abc123')).toBe('😀');
|
||||
});
|
||||
|
||||
it('returns full flag emoji (not just first regional indicator)', () => {
|
||||
expect(getAvatarText('Jason 🇺🇸', 'abc123')).toBe('🇺🇸');
|
||||
expect(getAvatarText('🇬🇧 London', 'abc123')).toBe('🇬🇧');
|
||||
expect(getAvatarText('Test 🇯🇵 Japan', 'abc123')).toBe('🇯🇵');
|
||||
});
|
||||
|
||||
it('returns initials when name has space', () => {
|
||||
expect(getAvatarText('John Doe', 'abc123')).toBe('JD');
|
||||
expect(getAvatarText('Alice Bob Charlie', 'abc123')).toBe('AB');
|
||||
expect(getAvatarText('jane smith', 'abc123')).toBe('JS');
|
||||
});
|
||||
|
||||
it('returns single letter when no space', () => {
|
||||
expect(getAvatarText('John', 'abc123')).toBe('J');
|
||||
expect(getAvatarText('alice', 'abc123')).toBe('A');
|
||||
});
|
||||
|
||||
it('falls back to public key when name is null', () => {
|
||||
expect(getAvatarText(null, 'abc123def456')).toBe('AB');
|
||||
});
|
||||
|
||||
it('falls back to public key when name has no letters', () => {
|
||||
expect(getAvatarText('123 456', 'xyz789')).toBe('XY');
|
||||
expect(getAvatarText('---', 'def456')).toBe('DE');
|
||||
});
|
||||
|
||||
it('handles space but no letter after', () => {
|
||||
expect(getAvatarText('John ', 'abc123')).toBe('J');
|
||||
expect(getAvatarText('A 123', 'abc123')).toBe('A');
|
||||
});
|
||||
|
||||
it('emoji takes priority over initials', () => {
|
||||
expect(getAvatarText('John 🎯 Doe', 'abc123')).toBe('🎯');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvatarColor', () => {
|
||||
it('returns consistent colors for same public key', () => {
|
||||
const color1 = getAvatarColor('abc123def456');
|
||||
const color2 = getAvatarColor('abc123def456');
|
||||
expect(color1).toEqual(color2);
|
||||
});
|
||||
|
||||
it('returns different colors for different public keys', () => {
|
||||
const color1 = getAvatarColor('abc123def456');
|
||||
const color2 = getAvatarColor('xyz789uvw012');
|
||||
expect(color1.background).not.toBe(color2.background);
|
||||
});
|
||||
|
||||
it('returns valid HSL background color', () => {
|
||||
const color = getAvatarColor('test123');
|
||||
expect(color.background).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/);
|
||||
});
|
||||
|
||||
it('returns white or black text color', () => {
|
||||
const color = getAvatarColor('test123');
|
||||
expect(['#ffffff', '#000000']).toContain(color.text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContactAvatar', () => {
|
||||
it('returns complete avatar info', () => {
|
||||
const avatar = getContactAvatar('John Doe', 'abc123def456');
|
||||
@@ -103,4 +36,41 @@ describe('getContactAvatar', () => {
|
||||
expect(avatar0.text).toBe('J');
|
||||
expect(avatar1.text).toBe('J');
|
||||
});
|
||||
|
||||
it('extracts emoji from name', () => {
|
||||
const avatar = getContactAvatar('John 🚀 Doe', 'abc123');
|
||||
expect(avatar.text).toBe('🚀');
|
||||
});
|
||||
|
||||
it('extracts flag emoji', () => {
|
||||
const avatar = getContactAvatar('Jason 🇺🇸', 'abc123');
|
||||
expect(avatar.text).toBe('🇺🇸');
|
||||
});
|
||||
|
||||
it('extracts initials from two-word name', () => {
|
||||
const avatar = getContactAvatar('Jane Smith', 'abc123');
|
||||
expect(avatar.text).toBe('JS');
|
||||
});
|
||||
|
||||
it('extracts single letter from one-word name', () => {
|
||||
const avatar = getContactAvatar('Alice', 'abc123');
|
||||
expect(avatar.text).toBe('A');
|
||||
});
|
||||
|
||||
it('falls back to pubkey prefix for names with no letters', () => {
|
||||
const avatar = getContactAvatar('123 456', 'xyz789');
|
||||
expect(avatar.text).toBe('XY');
|
||||
});
|
||||
|
||||
it('returns consistent colors for same public key', () => {
|
||||
const avatar1 = getContactAvatar('A', 'abc123def456');
|
||||
const avatar2 = getContactAvatar('B', 'abc123def456');
|
||||
expect(avatar1.background).toBe(avatar2.background);
|
||||
});
|
||||
|
||||
it('returns different colors for different public keys', () => {
|
||||
const avatar1 = getContactAvatar('A', 'abc123def456');
|
||||
const avatar2 = getContactAvatar('A', 'xyz789uvw012');
|
||||
expect(avatar1.background).not.toBe(avatar2.background);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
};
|
||||
}
|
||||
|
||||
function createEntry(messages: Message[] = [], hasOlderMessages = false): messageCache.CacheEntry {
|
||||
function createEntry(messages: Message[] = [], hasOlderMessages = false) {
|
||||
const seenContent = new Set<string>();
|
||||
for (const msg of messages) {
|
||||
seenContent.add(`${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`);
|
||||
@@ -97,7 +97,6 @@ describe('messageCache', () => {
|
||||
|
||||
const result = messageCache.get('conv1');
|
||||
expect(result!.messages[0].text).toBe('second');
|
||||
expect(messageCache.size()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -231,7 +230,6 @@ describe('messageCache', () => {
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(messageCache.size()).toBe(1);
|
||||
const entry = messageCache.get('new_conv');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.messages).toHaveLength(1);
|
||||
@@ -319,13 +317,12 @@ describe('messageCache', () => {
|
||||
|
||||
expect(messageCache.get('conv1')).toBeUndefined();
|
||||
expect(messageCache.get('conv2')).toBeDefined();
|
||||
expect(messageCache.size()).toBe(1);
|
||||
});
|
||||
|
||||
it('does nothing for non-existent key', () => {
|
||||
messageCache.set('conv1', createEntry());
|
||||
messageCache.remove('nonexistent');
|
||||
expect(messageCache.size()).toBe(1);
|
||||
expect(messageCache.get('conv1')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -437,16 +434,4 @@ describe('messageCache', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes all entries', () => {
|
||||
messageCache.set('conv1', createEntry());
|
||||
messageCache.set('conv2', createEntry());
|
||||
messageCache.set('conv3', createEntry());
|
||||
|
||||
messageCache.clear();
|
||||
|
||||
expect(messageCache.size()).toBe(0);
|
||||
expect(messageCache.get('conv1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
parsePathHops,
|
||||
findContactsByPrefix,
|
||||
calculateDistance,
|
||||
sortContactsByDistance,
|
||||
resolvePath,
|
||||
formatDistance,
|
||||
formatHopCounts,
|
||||
@@ -150,50 +149,6 @@ describe('calculateDistance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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),
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
RadioConfigUpdate,
|
||||
StatisticsResponse,
|
||||
} from '../types';
|
||||
import type { SettingsSection } from '../components/SettingsModal';
|
||||
import type { SettingsSection } from '../components/settingsConstants';
|
||||
import {
|
||||
LAST_VIEWED_CONVERSATION_KEY,
|
||||
REOPEN_LAST_CONVERSATION_KEY,
|
||||
|
||||
@@ -8,12 +8,11 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
parseHashConversation,
|
||||
getConversationHash,
|
||||
getMapFocusHash,
|
||||
resolveChannelFromHashToken,
|
||||
resolveContactFromHashToken,
|
||||
} from '../utils/urlHash';
|
||||
import type { Channel, Contact, Conversation } from '../types';
|
||||
import type { Channel, Contact } from '../types';
|
||||
|
||||
describe('parseHashConversation', () => {
|
||||
let originalHash: string;
|
||||
@@ -147,122 +146,6 @@ describe('parseHashConversation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConversationHash', () => {
|
||||
it('returns empty string for null conversation', () => {
|
||||
const result = getConversationHash(null);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('returns #raw for raw conversation', () => {
|
||||
const conv: Conversation = { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
|
||||
|
||||
const result = getConversationHash(conv);
|
||||
|
||||
expect(result).toBe('#raw');
|
||||
});
|
||||
|
||||
it('returns #map for map conversation', () => {
|
||||
const conv: Conversation = { type: 'map', id: 'map', name: 'Node Map' };
|
||||
|
||||
const result = getConversationHash(conv);
|
||||
|
||||
expect(result).toBe('#map');
|
||||
});
|
||||
|
||||
it('generates channel hash', () => {
|
||||
const conv: Conversation = { type: 'channel', id: 'key123', name: 'Public' };
|
||||
|
||||
const result = getConversationHash(conv);
|
||||
|
||||
expect(result).toBe('#channel/key123/Public');
|
||||
});
|
||||
|
||||
it('generates contact hash', () => {
|
||||
const conv: Conversation = { type: 'contact', id: 'pubkey123', name: 'Alice' };
|
||||
|
||||
const result = getConversationHash(conv);
|
||||
|
||||
expect(result).toBe('#contact/pubkey123/Alice');
|
||||
});
|
||||
|
||||
it('uses channel id even when name starts with #', () => {
|
||||
const conv: Conversation = { type: 'channel', id: 'key123', name: '#TestChannel' };
|
||||
|
||||
const result = getConversationHash(conv);
|
||||
|
||||
expect(result).toBe('#channel/key123/TestChannel');
|
||||
});
|
||||
|
||||
it('encodes special characters in ids', () => {
|
||||
const conv: Conversation = { type: 'contact', id: 'key with space', name: 'John Doe' };
|
||||
|
||||
const result = getConversationHash(conv);
|
||||
|
||||
expect(result).toBe('#contact/key%20with%20space/John%20Doe');
|
||||
});
|
||||
|
||||
it('uses id regardless of contact display name', () => {
|
||||
const conv: Conversation = { type: 'contact', id: 'key', name: '#Hashtag' };
|
||||
|
||||
const result = getConversationHash(conv);
|
||||
|
||||
expect(result).toBe('#contact/key/%23Hashtag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseHashConversation and getConversationHash roundtrip', () => {
|
||||
let originalHash: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalHash = window.location.hash;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.location.hash = originalHash;
|
||||
});
|
||||
|
||||
it('channel roundtrip preserves data', () => {
|
||||
const conv: Conversation = { type: 'channel', id: 'key123', name: 'Test Channel' };
|
||||
|
||||
const hash = getConversationHash(conv);
|
||||
window.location.hash = hash;
|
||||
const parsed = parseHashConversation();
|
||||
|
||||
expect(parsed).toEqual({ type: 'channel', name: 'key123', label: 'Test Channel' });
|
||||
});
|
||||
|
||||
it('contact roundtrip preserves data', () => {
|
||||
const conv: Conversation = { type: 'contact', id: 'pubkey', name: 'Alice Bob' };
|
||||
|
||||
const hash = getConversationHash(conv);
|
||||
window.location.hash = hash;
|
||||
const parsed = parseHashConversation();
|
||||
|
||||
expect(parsed).toEqual({ type: 'contact', name: 'pubkey', label: 'Alice Bob' });
|
||||
});
|
||||
|
||||
it('raw roundtrip preserves type', () => {
|
||||
const conv: Conversation = { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
|
||||
|
||||
const hash = getConversationHash(conv);
|
||||
window.location.hash = hash;
|
||||
const parsed = parseHashConversation();
|
||||
|
||||
expect(parsed).toEqual({ type: 'raw', name: 'raw' });
|
||||
});
|
||||
|
||||
it('map roundtrip preserves type', () => {
|
||||
const conv: Conversation = { type: 'map', id: 'map', name: 'Node Map' };
|
||||
|
||||
const hash = getConversationHash(conv);
|
||||
window.location.hash = hash;
|
||||
const parsed = parseHashConversation();
|
||||
|
||||
expect(parsed).toEqual({ type: 'map', name: 'map' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveChannelFromHashToken', () => {
|
||||
const channels: Channel[] = [
|
||||
{
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
/**
|
||||
* Tests for useRepeaterMode hook utilities.
|
||||
*
|
||||
* These tests verify the formatting functions used to display
|
||||
* telemetry data from repeaters.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatDuration,
|
||||
formatTelemetry,
|
||||
formatNeighbors,
|
||||
formatAcl,
|
||||
} from '../hooks/useRepeaterMode';
|
||||
import type { TelemetryResponse, NeighborInfo, AclEntry } from '../types';
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('formats seconds under a minute', () => {
|
||||
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(120)).toBe('2m');
|
||||
expect(formatDuration(300)).toBe('5m');
|
||||
expect(formatDuration(3599)).toBe('59m');
|
||||
});
|
||||
|
||||
it('formats hours and minutes', () => {
|
||||
expect(formatDuration(3600)).toBe('1h');
|
||||
expect(formatDuration(3660)).toBe('1h1m');
|
||||
expect(formatDuration(7200)).toBe('2h');
|
||||
expect(formatDuration(7380)).toBe('2h3m');
|
||||
});
|
||||
|
||||
it('formats days only', () => {
|
||||
expect(formatDuration(86400)).toBe('1d');
|
||||
expect(formatDuration(172800)).toBe('2d');
|
||||
});
|
||||
|
||||
it('formats days and hours', () => {
|
||||
expect(formatDuration(90000)).toBe('1d1h');
|
||||
expect(formatDuration(97200)).toBe('1d3h');
|
||||
});
|
||||
|
||||
it('formats days and minutes (no hours)', () => {
|
||||
expect(formatDuration(86700)).toBe('1d5m');
|
||||
});
|
||||
|
||||
it('formats days, hours, and minutes', () => {
|
||||
expect(formatDuration(90060)).toBe('1d1h1m');
|
||||
expect(formatDuration(148920)).toBe('1d17h22m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTelemetry', () => {
|
||||
it('formats telemetry response with all fields', () => {
|
||||
const telemetry: TelemetryResponse = {
|
||||
pubkey_prefix: 'abc123',
|
||||
battery_volts: 4.123,
|
||||
uptime_seconds: 90060, // 1d1h1m
|
||||
airtime_seconds: 3600, // 1h
|
||||
rx_airtime_seconds: 7200, // 2h
|
||||
noise_floor_dbm: -120,
|
||||
last_rssi_dbm: -90,
|
||||
last_snr_db: 8.5,
|
||||
packets_received: 1000,
|
||||
packets_sent: 500,
|
||||
recv_flood: 800,
|
||||
sent_flood: 400,
|
||||
recv_direct: 200,
|
||||
sent_direct: 100,
|
||||
flood_dups: 50,
|
||||
direct_dups: 10,
|
||||
tx_queue_len: 2,
|
||||
full_events: 0,
|
||||
neighbors: [],
|
||||
acl: [],
|
||||
clock_output: null,
|
||||
};
|
||||
|
||||
const result = formatTelemetry(telemetry);
|
||||
|
||||
expect(result).toContain('Telemetry');
|
||||
expect(result).toContain('Battery Voltage: 4.123V');
|
||||
expect(result).toContain('Uptime: 1d1h1m');
|
||||
expect(result).toContain('TX Airtime: 1h');
|
||||
expect(result).toContain('RX Airtime: 2h');
|
||||
expect(result).toContain('Noise Floor: -120 dBm');
|
||||
expect(result).toContain('Last RSSI: -90 dBm');
|
||||
expect(result).toContain('Last SNR: 8.5 dB');
|
||||
expect(result).toContain('Packets: 1,000 rx / 500 tx');
|
||||
expect(result).toContain('Flood: 800 rx / 400 tx');
|
||||
expect(result).toContain('Direct: 200 rx / 100 tx');
|
||||
expect(result).toContain('Duplicates: 50 flood / 10 direct');
|
||||
expect(result).toContain('TX Queue: 2');
|
||||
});
|
||||
|
||||
it('formats battery voltage with 3 decimal places', () => {
|
||||
const telemetry: TelemetryResponse = {
|
||||
pubkey_prefix: 'abc123',
|
||||
battery_volts: 3.7,
|
||||
uptime_seconds: 0,
|
||||
airtime_seconds: 0,
|
||||
rx_airtime_seconds: 0,
|
||||
noise_floor_dbm: 0,
|
||||
last_rssi_dbm: 0,
|
||||
last_snr_db: 0,
|
||||
packets_received: 0,
|
||||
packets_sent: 0,
|
||||
recv_flood: 0,
|
||||
sent_flood: 0,
|
||||
recv_direct: 0,
|
||||
sent_direct: 0,
|
||||
flood_dups: 0,
|
||||
direct_dups: 0,
|
||||
tx_queue_len: 0,
|
||||
full_events: 0,
|
||||
neighbors: [],
|
||||
acl: [],
|
||||
clock_output: null,
|
||||
};
|
||||
|
||||
const result = formatTelemetry(telemetry);
|
||||
expect(result).toContain('Battery Voltage: 3.700V');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatNeighbors', () => {
|
||||
it('returns "No neighbors" message for empty list', () => {
|
||||
const result = formatNeighbors([]);
|
||||
|
||||
expect(result).toBe('Neighbors\nNo neighbors reported');
|
||||
});
|
||||
|
||||
it('formats single neighbor', () => {
|
||||
const neighbors: NeighborInfo[] = [
|
||||
{ pubkey_prefix: 'abc123', name: 'Alice', snr: 8.5, last_heard_seconds: 60 },
|
||||
];
|
||||
|
||||
const result = formatNeighbors(neighbors);
|
||||
|
||||
expect(result).toContain('Neighbors (1)');
|
||||
expect(result).toContain('Alice, +8.5 dB [1m ago]');
|
||||
});
|
||||
|
||||
it('sorts neighbors by SNR descending', () => {
|
||||
const neighbors: NeighborInfo[] = [
|
||||
{ pubkey_prefix: 'aaa', name: 'Low', snr: -5, last_heard_seconds: 10 },
|
||||
{ pubkey_prefix: 'bbb', name: 'High', snr: 10, last_heard_seconds: 20 },
|
||||
{ pubkey_prefix: 'ccc', name: 'Mid', snr: 5, last_heard_seconds: 30 },
|
||||
];
|
||||
|
||||
const result = formatNeighbors(neighbors);
|
||||
const lines = result.split('\n');
|
||||
|
||||
expect(lines[1]).toContain('High');
|
||||
expect(lines[2]).toContain('Mid');
|
||||
expect(lines[3]).toContain('Low');
|
||||
});
|
||||
|
||||
it('uses pubkey_prefix when name is null', () => {
|
||||
const neighbors: NeighborInfo[] = [
|
||||
{ pubkey_prefix: 'abc123def456', name: null, snr: 5, last_heard_seconds: 120 },
|
||||
];
|
||||
|
||||
const result = formatNeighbors(neighbors);
|
||||
|
||||
expect(result).toContain('abc123def456, +5.0 dB [2m ago]');
|
||||
});
|
||||
|
||||
it('formats negative SNR without plus sign', () => {
|
||||
const neighbors: NeighborInfo[] = [
|
||||
{ pubkey_prefix: 'abc', name: 'Test', snr: -3.5, last_heard_seconds: 60 },
|
||||
];
|
||||
|
||||
const result = formatNeighbors(neighbors);
|
||||
|
||||
expect(result).toContain('Test, -3.5 dB');
|
||||
});
|
||||
|
||||
it('formats last heard in various durations', () => {
|
||||
const neighbors: NeighborInfo[] = [
|
||||
{ pubkey_prefix: 'a', name: 'Seconds', snr: 0, last_heard_seconds: 45 },
|
||||
{ pubkey_prefix: 'b', name: 'Minutes', snr: 0, last_heard_seconds: 300 },
|
||||
{ pubkey_prefix: 'c', name: 'Hours', snr: 0, last_heard_seconds: 7200 },
|
||||
];
|
||||
|
||||
const result = formatNeighbors(neighbors);
|
||||
|
||||
expect(result).toContain('Seconds, +0.0 dB [45s ago]');
|
||||
expect(result).toContain('Minutes, +0.0 dB [5m ago]');
|
||||
expect(result).toContain('Hours, +0.0 dB [2h ago]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAcl', () => {
|
||||
it('returns "No ACL entries" message for empty list', () => {
|
||||
const result = formatAcl([]);
|
||||
|
||||
expect(result).toBe('ACL\nNo ACL entries');
|
||||
});
|
||||
|
||||
it('formats single ACL entry', () => {
|
||||
const acl: AclEntry[] = [
|
||||
{ pubkey_prefix: 'abc123', name: 'Alice', permission: 3, permission_name: 'Admin' },
|
||||
];
|
||||
|
||||
const result = formatAcl(acl);
|
||||
|
||||
expect(result).toContain('ACL (1)');
|
||||
expect(result).toContain('Alice: Admin');
|
||||
});
|
||||
|
||||
it('formats multiple ACL entries', () => {
|
||||
const acl: AclEntry[] = [
|
||||
{ pubkey_prefix: 'aaa', name: 'Admin User', permission: 3, permission_name: 'Admin' },
|
||||
{ pubkey_prefix: 'bbb', name: 'Read Only', permission: 1, permission_name: 'Read-only' },
|
||||
{ pubkey_prefix: 'ccc', name: null, permission: 0, permission_name: 'Guest' },
|
||||
];
|
||||
|
||||
const result = formatAcl(acl);
|
||||
|
||||
expect(result).toContain('ACL (3)');
|
||||
expect(result).toContain('Admin User: Admin');
|
||||
expect(result).toContain('Read Only: Read-only');
|
||||
expect(result).toContain('ccc: Guest');
|
||||
});
|
||||
|
||||
it('uses pubkey_prefix when name is null', () => {
|
||||
const acl: AclEntry[] = [
|
||||
{ pubkey_prefix: 'xyz789', name: null, permission: 2, permission_name: 'Read-write' },
|
||||
];
|
||||
|
||||
const result = formatAcl(acl);
|
||||
|
||||
expect(result).toContain('xyz789: Read-write');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface RadioSettings {
|
||||
interface RadioSettings {
|
||||
freq: number;
|
||||
bw: number;
|
||||
sf: number;
|
||||
@@ -99,7 +99,7 @@ export interface Message {
|
||||
acked: number;
|
||||
}
|
||||
|
||||
export type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer';
|
||||
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer';
|
||||
|
||||
export interface Conversation {
|
||||
type: ConversationType;
|
||||
@@ -228,13 +228,13 @@ export interface UnreadCounts {
|
||||
last_message_times: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface BusyChannel {
|
||||
interface BusyChannel {
|
||||
channel_key: string;
|
||||
channel_name: string;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface ContactActivityCounts {
|
||||
interface ContactActivityCounts {
|
||||
last_hour: number;
|
||||
last_24_hours: number;
|
||||
last_week: number;
|
||||
|
||||
@@ -38,7 +38,7 @@ const emojiRegex =
|
||||
* 2. First letter + first letter after first space (initials)
|
||||
* 3. First letter only
|
||||
*/
|
||||
export function getAvatarText(name: string | null, publicKey: string): string {
|
||||
function getAvatarText(name: string | null, publicKey: string): string {
|
||||
if (!name) {
|
||||
// Use first 2 chars of public key as fallback
|
||||
return publicKey.slice(0, 2).toUpperCase();
|
||||
@@ -76,7 +76,7 @@ export function getAvatarText(name: string | null, publicKey: string): string {
|
||||
* Generate a consistent HSL color from a public key.
|
||||
* Uses saturation and lightness ranges that work well for backgrounds.
|
||||
*/
|
||||
export function getAvatarColor(publicKey: string): {
|
||||
function getAvatarColor(publicKey: string): {
|
||||
background: string;
|
||||
text: string;
|
||||
} {
|
||||
|
||||
@@ -123,7 +123,7 @@ export function formatDistance(km: number): string {
|
||||
* Sort contacts by distance from a reference point
|
||||
* Contacts without location are placed at the end
|
||||
*/
|
||||
export function sortContactsByDistance(
|
||||
function sortContactsByDistance(
|
||||
contacts: Contact[],
|
||||
fromLat: number | null,
|
||||
fromLon: number | null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Radio presets for common LoRa configurations
|
||||
export interface RadioPreset {
|
||||
interface RadioPreset {
|
||||
name: string;
|
||||
freq: number;
|
||||
bw: number;
|
||||
|
||||
@@ -102,7 +102,7 @@ export function getMapFocusHash(publicKeyPrefix: string): string {
|
||||
}
|
||||
|
||||
// Generate URL hash from conversation
|
||||
export function getConversationHash(conv: Conversation | null): string {
|
||||
function getConversationHash(conv: Conversation | null): string {
|
||||
if (!conv) return '';
|
||||
if (conv.type === 'raw') return '#raw';
|
||||
if (conv.type === 'map') return '#map';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket } from '../types';
|
||||
// =============================================================================
|
||||
|
||||
export type NodeType = 'self' | 'repeater' | 'client';
|
||||
export type PacketLabel = 'AD' | 'GT' | 'DM' | 'ACK' | 'TR' | 'RQ' | 'RS' | '?';
|
||||
type PacketLabel = 'AD' | 'GT' | 'DM' | 'ACK' | 'TR' | 'RQ' | 'RS' | '?';
|
||||
|
||||
export interface Particle {
|
||||
linkKey: string;
|
||||
@@ -18,7 +18,7 @@ export interface Particle {
|
||||
toNodeId: string;
|
||||
}
|
||||
|
||||
export interface ObservedPath {
|
||||
interface ObservedPath {
|
||||
nodes: string[];
|
||||
snr: number | null;
|
||||
timestamp: number;
|
||||
@@ -32,7 +32,7 @@ export interface PendingPacket {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface ParsedPacket {
|
||||
interface ParsedPacket {
|
||||
payloadType: number;
|
||||
messageHash: string | null;
|
||||
pathBytes: string[];
|
||||
@@ -44,7 +44,7 @@ export interface ParsedPacket {
|
||||
}
|
||||
|
||||
// Traffic pattern tracking for smarter repeater disambiguation
|
||||
export interface TrafficObservation {
|
||||
interface TrafficObservation {
|
||||
source: string; // Node that originated traffic (could be resolved node ID or ambiguous)
|
||||
nextHop: string | null; // Next hop after this repeater (null if final hop before self)
|
||||
timestamp: number;
|
||||
@@ -56,7 +56,7 @@ export interface RepeaterTrafficData {
|
||||
}
|
||||
|
||||
// Analysis result for whether to split an ambiguous repeater
|
||||
export interface RepeaterSplitAnalysis {
|
||||
interface RepeaterSplitAnalysis {
|
||||
shouldSplit: boolean;
|
||||
// If shouldSplit, maps nextHop -> the sources that exclusively route through it
|
||||
disjointGroups: Map<string, Set<string>> | null;
|
||||
@@ -95,9 +95,9 @@ export const PARTICLE_SPEED = 0.008;
|
||||
export const DEFAULT_OBSERVATION_WINDOW_SEC = 15;
|
||||
// Traffic pattern analysis thresholds
|
||||
// Be conservative - once split, we can't unsplit, so require strong evidence
|
||||
export const MIN_OBSERVATIONS_TO_SPLIT = 20; // Need at least this many unique sources per next-hop group
|
||||
export const MAX_TRAFFIC_OBSERVATIONS = 200; // Per ambiguous prefix, to limit memory
|
||||
export const TRAFFIC_OBSERVATION_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes - old observations are pruned
|
||||
const MIN_OBSERVATIONS_TO_SPLIT = 20; // Need at least this many unique sources per next-hop group
|
||||
const MAX_TRAFFIC_OBSERVATIONS = 200; // Per ambiguous prefix, to limit memory
|
||||
const TRAFFIC_OBSERVATION_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes - old observations are pruned
|
||||
|
||||
export const PACKET_LEGEND_ITEMS = [
|
||||
{ label: 'AD', color: COLORS.particleAD, description: 'Advertisement' },
|
||||
@@ -114,7 +114,7 @@ export const PACKET_LEGEND_ITEMS = [
|
||||
// UTILITY FUNCTIONS (Data Layer)
|
||||
// =============================================================================
|
||||
|
||||
export function simpleHash(str: string): string {
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
|
||||
Reference in New Issue
Block a user