mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
* data,web: Reticulum finalization — radio metadata, chat tags, federation zeros * web: purge destinations with their nodes
446 lines
18 KiB
JavaScript
446 lines
18 KiB
JavaScript
/*
|
|
* Copyright © 2025-26 l5yth & contributors
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { withApp, innerHtml } from './main-app-test-helpers.js';
|
|
|
|
// --- buildDisplayContext ---
|
|
|
|
test('buildDisplayContext extracts protocol from trace candidate source', () => {
|
|
withApp((t) => {
|
|
const entry = {
|
|
nodeId: '!aabbccdd',
|
|
trace: { protocol: 'meshcore', node_id: '!aabbccdd' },
|
|
};
|
|
const ctx = t.buildDisplayContext(entry);
|
|
assert.equal(ctx.protocol, 'meshcore', 'protocol must be picked from entry.trace');
|
|
});
|
|
});
|
|
|
|
test('buildDisplayContext extracts protocol from node candidate source', () => {
|
|
withApp((t) => {
|
|
const entry = {
|
|
nodeId: '!aabbccdd',
|
|
node: { protocol: 'meshcore' },
|
|
};
|
|
const ctx = t.buildDisplayContext(entry);
|
|
assert.equal(ctx.protocol, 'meshcore', 'protocol must be picked from entry.node');
|
|
});
|
|
});
|
|
|
|
test('buildDisplayContext protocol is null when no candidate carries it', () => {
|
|
withApp((t) => {
|
|
const entry = { nodeId: '!aabbccdd', node: { short_name: 'X' } };
|
|
const ctx = t.buildDisplayContext(entry);
|
|
assert.equal(ctx.protocol, null, 'protocol should be null when absent from all sources');
|
|
});
|
|
});
|
|
|
|
// --- normalizeOverlaySource ---
|
|
|
|
test('normalizeOverlaySource propagates string protocol field', () => {
|
|
withApp((t) => {
|
|
const result = t.normalizeOverlaySource({ protocol: 'meshcore' });
|
|
assert.equal(result.protocol, 'meshcore');
|
|
});
|
|
});
|
|
|
|
test('normalizeOverlaySource propagates "meshtastic" protocol', () => {
|
|
withApp((t) => {
|
|
const result = t.normalizeOverlaySource({ protocol: 'meshtastic' });
|
|
assert.equal(result.protocol, 'meshtastic');
|
|
});
|
|
});
|
|
|
|
test('normalizeOverlaySource omits protocol when absent', () => {
|
|
withApp((t) => {
|
|
const result = t.normalizeOverlaySource({ longName: 'Alice' });
|
|
assert.ok(!('protocol' in result), 'protocol should not be set when source has none');
|
|
});
|
|
});
|
|
|
|
test('normalizeOverlaySource omits protocol when value is not a string', () => {
|
|
withApp((t) => {
|
|
const result = t.normalizeOverlaySource({ protocol: 42 });
|
|
assert.ok(!('protocol' in result), 'protocol should not be set for non-string values');
|
|
});
|
|
});
|
|
|
|
// --- buildMapPopupHtml ---
|
|
|
|
test('buildMapPopupHtml shows no icon for null protocol', () => {
|
|
withApp((t) => {
|
|
const html = t.buildMapPopupHtml({ long_name: 'Alice', node_id: '!abc123', protocol: null }, 0);
|
|
assert.ok(!html.includes('meshtastic.svg'), 'popup should not show meshtastic icon when protocol is null');
|
|
assert.ok(!html.includes('meshcore.svg'), 'popup should not show meshcore icon when protocol is null');
|
|
});
|
|
});
|
|
|
|
test('buildMapPopupHtml shows no icon when protocol is absent', () => {
|
|
withApp((t) => {
|
|
const html = t.buildMapPopupHtml({ long_name: 'Bob', node_id: '!abc456' }, 0);
|
|
assert.ok(!html.includes('meshtastic.svg'), 'popup should not show any icon when protocol is absent');
|
|
assert.ok(!html.includes('meshcore.svg'), 'popup should not show any icon when protocol is absent');
|
|
});
|
|
});
|
|
|
|
test('buildMapPopupHtml shows meshtastic icon for explicit meshtastic protocol', () => {
|
|
withApp((t) => {
|
|
const html = t.buildMapPopupHtml({ long_name: 'Alice', node_id: '!abc123', protocol: 'meshtastic' }, 0);
|
|
assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon for explicit meshtastic protocol');
|
|
});
|
|
});
|
|
|
|
test('buildMapPopupHtml omits meshtastic icon for meshcore protocol', () => {
|
|
withApp((t) => {
|
|
const html = t.buildMapPopupHtml({ long_name: 'Eve', node_id: '!abc789', protocol: 'meshcore' }, 0);
|
|
assert.ok(!html.includes('meshtastic.svg'), 'popup should not show meshtastic icon for meshcore nodes');
|
|
});
|
|
});
|
|
|
|
// --- createAnnouncementEntry ---
|
|
|
|
test('createAnnouncementEntry prefixes meshtastic icon when protocol is meshtastic', () => {
|
|
withApp((t) => {
|
|
const div = t.createAnnouncementEntry({
|
|
timestampSeconds: 1000,
|
|
shortName: 'ALI',
|
|
longName: 'Alice',
|
|
role: 'CLIENT',
|
|
metadataSource: { protocol: 'meshtastic' },
|
|
nodeData: null,
|
|
messageHtml: 'joined the mesh',
|
|
});
|
|
assert.ok(innerHtml(div).includes('meshtastic.svg'), 'announcement should include meshtastic icon');
|
|
});
|
|
});
|
|
|
|
test('createAnnouncementEntry shows no icon when protocol is absent', () => {
|
|
withApp((t) => {
|
|
const div = t.createAnnouncementEntry({
|
|
timestampSeconds: 1000,
|
|
shortName: 'BOB',
|
|
longName: 'Bob',
|
|
role: 'ROUTER',
|
|
metadataSource: {},
|
|
nodeData: null,
|
|
messageHtml: 'detected',
|
|
});
|
|
assert.ok(!innerHtml(div).includes('meshtastic.svg'), 'no meshtastic icon when protocol is absent');
|
|
assert.ok(!innerHtml(div).includes('meshcore.svg'), 'no meshcore icon when protocol is absent');
|
|
});
|
|
});
|
|
|
|
test('createAnnouncementEntry omits meshtastic icon for meshcore protocol', () => {
|
|
withApp((t) => {
|
|
const div = t.createAnnouncementEntry({
|
|
timestampSeconds: 1000,
|
|
shortName: 'MC1',
|
|
longName: 'MeshCore Node',
|
|
role: 'REPEATER',
|
|
metadataSource: { protocol: 'meshcore' },
|
|
nodeData: null,
|
|
messageHtml: 'seen',
|
|
});
|
|
assert.ok(!innerHtml(div).includes('meshtastic.svg'), 'announcement for meshcore should not include meshtastic icon');
|
|
});
|
|
});
|
|
|
|
test('createAnnouncementEntry shows meshcore icon for meshcore protocol', () => {
|
|
withApp((t) => {
|
|
const div = t.createAnnouncementEntry({
|
|
timestampSeconds: 1000,
|
|
shortName: 'MC1',
|
|
longName: 'MeshCore Node',
|
|
role: 'REPEATER',
|
|
protocol: 'meshcore',
|
|
metadataSource: null,
|
|
nodeData: null,
|
|
messageHtml: 'seen',
|
|
});
|
|
assert.ok(innerHtml(div).includes('meshcore.svg'), 'announcement for meshcore should include meshcore icon');
|
|
});
|
|
});
|
|
|
|
// --- createMessageChatEntry ---
|
|
|
|
test('createMessageChatEntry prefixes meshtastic icon when node protocol is meshtastic', () => {
|
|
withApp((t) => {
|
|
const div = t.createMessageChatEntry({
|
|
text: 'hello mesh',
|
|
rx_time: 1000,
|
|
node: { short_name: 'ALI', role: 'CLIENT', protocol: 'meshtastic' },
|
|
});
|
|
assert.ok(innerHtml(div).includes('meshtastic.svg'), 'chat entry should include meshtastic icon');
|
|
});
|
|
});
|
|
|
|
test('createMessageChatEntry shows no icon when node protocol is absent', () => {
|
|
withApp((t) => {
|
|
const div = t.createMessageChatEntry({
|
|
text: 'hi',
|
|
rx_time: 2000,
|
|
node: { short_name: 'BOB', role: 'ROUTER' },
|
|
});
|
|
assert.ok(!innerHtml(div).includes('meshtastic.svg'), 'no meshtastic icon when protocol is absent');
|
|
assert.ok(!innerHtml(div).includes('meshcore.svg'), 'no meshcore icon when protocol is absent');
|
|
});
|
|
});
|
|
|
|
test('createMessageChatEntry omits meshtastic icon for meshcore node', () => {
|
|
withApp((t) => {
|
|
const div = t.createMessageChatEntry({
|
|
text: 'test',
|
|
rx_time: 3000,
|
|
node: { short_name: 'MC1', role: 'REPEATER', protocol: 'meshcore' },
|
|
});
|
|
assert.ok(!innerHtml(div).includes('meshtastic.svg'), 'chat entry for meshcore node should not show meshtastic icon');
|
|
});
|
|
});
|
|
|
|
test('createMessageChatEntry shows meshcore icon for meshcore node', () => {
|
|
withApp((t) => {
|
|
const div = t.createMessageChatEntry({
|
|
text: 'test',
|
|
rx_time: 3000,
|
|
node: { short_name: 'MC1', role: 'REPEATER', protocol: 'meshcore' },
|
|
});
|
|
assert.ok(innerHtml(div).includes('meshcore.svg'), 'chat entry for meshcore node should show meshcore icon');
|
|
});
|
|
});
|
|
|
|
// --- createMessageChatEntry: MeshCore channel message sender resolution ---
|
|
|
|
/**
|
|
* A MeshCore COMPANION node used as the canonical sender fixture in the
|
|
* channel-message tests below.
|
|
*/
|
|
const T114_ZEH = { node_id: '!aabbccdd', long_name: 'T114-Zeh', short_name: ' T ', role: 'COMPANION', protocol: 'meshcore' };
|
|
|
|
/**
|
|
* Build a minimal MeshCore channel message payload for createMessageChatEntry.
|
|
* @param {string} text Message text (typically "SenderName: body" format).
|
|
* @param {object} [overrides] Properties to merge in.
|
|
*/
|
|
function makeMeshcoreChannelMsg(text, overrides = {}) {
|
|
return { text, rx_time: 1000, protocol: 'meshcore', to_id: '^all', node: null, ...overrides };
|
|
}
|
|
|
|
test('createMessageChatEntry: meshcore channel message uses sender node short name when found', () => {
|
|
withApp((t) => {
|
|
// Seed a node with a known long_name so findNodeByLongName can resolve it.
|
|
t.rebuildNodeIndex([T114_ZEH]);
|
|
const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('T114-Zeh: Hello world'));
|
|
const html = innerHtml(div);
|
|
// Badge should NOT be the fallback '?' — the node's short_name should be used
|
|
assert.ok(html.includes('T'), 'badge should contain T from derived short name');
|
|
assert.ok(!html.includes('?'), 'badge should not show placeholder question mark');
|
|
});
|
|
});
|
|
|
|
test('createMessageChatEntry: meshcore channel message hides sender long name — only body shown', () => {
|
|
withApp((t) => {
|
|
t.rebuildNodeIndex([T114_ZEH]);
|
|
const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('T114-Zeh: Hello world'));
|
|
const html = innerHtml(div);
|
|
// The sender long name is NOT prepended as a link — only the text after the colon is shown
|
|
assert.ok(html.includes('Hello world'), 'body text after colon should be rendered');
|
|
// Sender name should not appear as a link (href to node page) in the body
|
|
assert.ok(!html.includes('T114-Zeh:'), 'sender long name prefix with colon should not appear in body');
|
|
});
|
|
});
|
|
|
|
test('createMessageChatEntry: meshcore channel message, sender node not found — shows body only', () => {
|
|
withApp((t) => {
|
|
t.rebuildNodeIndex([]); // empty — no nodes known
|
|
const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('UnknownSender: Hello'));
|
|
const html = innerHtml(div);
|
|
// Only the body text is shown; sender name is not prepended as a link
|
|
assert.ok(html.includes('Hello'), 'body text after colon should still be rendered');
|
|
assert.ok(!html.includes('UnknownSender:'), 'sender long name prefix with colon should not appear in body');
|
|
assert.ok(!html.includes('/nodes/'), 'should not produce a node link when sender is not found');
|
|
});
|
|
});
|
|
|
|
test('createMessageChatEntry: meshcore channel message, no colon in text — body unchanged', () => {
|
|
withApp((t) => {
|
|
t.rebuildNodeIndex([T114_ZEH]);
|
|
const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('no colon here'));
|
|
const html = innerHtml(div);
|
|
assert.ok(html.includes('no colon here'), 'body text should be rendered as-is when no sender prefix found');
|
|
assert.ok(!html.includes('/nodes/'), 'should not produce a node link when no colon prefix');
|
|
});
|
|
});
|
|
|
|
test('createMessageChatEntry: meshcore message with @[Name] mention resolved to badge', () => {
|
|
withApp((t) => {
|
|
t.rebuildNodeIndex([
|
|
{ ...T114_ZEH, node_id: '!11111111' },
|
|
{ node_id: '!22222222', long_name: 'BGruenauBot', short_name: ' BG ', role: 'CLIENT', protocol: 'meshcore' },
|
|
]);
|
|
const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('BGruenauBot: ack @[T114-Zeh]', { rx_time: 2000 }));
|
|
const html = innerHtml(div);
|
|
// The @[T114-Zeh] mention should render as a short-name badge span
|
|
assert.ok(html.includes('short-name'), 'mention should produce a short-name badge');
|
|
// The sender long name is not prepended as a link in the body
|
|
assert.ok(!html.includes('BGruenauBot:'), 'sender long name prefix with colon should not appear in body');
|
|
});
|
|
});
|
|
|
|
test('createMessageChatEntry: meshcore message with @[Name] mention, node not found — synthetic badge', () => {
|
|
withApp((t) => {
|
|
t.rebuildNodeIndex([]);
|
|
const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('EchoBot: Pong! @[Ghost]', { rx_time: 3000 }));
|
|
const html = innerHtml(div);
|
|
// @[Ghost] mention with no matching node renders a synthetic protocol-stamped
|
|
// node badge carrying the name, never a bare ``@[Name]`` literal.
|
|
assert.ok(html.includes('Ghost'), 'unresolved mention should render a synthetic badge carrying the name');
|
|
assert.ok(html.includes('Pong!'), 'body text should still render');
|
|
assert.ok(!html.includes('@[Ghost]'), 'bare @[Name] literal must not survive');
|
|
});
|
|
});
|
|
|
|
test('createMessageChatEntry: meshcore channel message with hydrated node — body only shown', () => {
|
|
// Simulates the case where the ingestor resolved from_id successfully.
|
|
// The node is hydrated (m.node is not null), and the body still has "SenderName: body".
|
|
withApp((t) => {
|
|
t.rebuildNodeIndex([T114_ZEH]);
|
|
// node is already hydrated — ingestor resolved from_id via contacts
|
|
const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('T114-Zeh: Test message', { rx_time: 5000, node: T114_ZEH }));
|
|
const html = innerHtml(div);
|
|
// Only the body text after the colon is shown; sender long name is not prepended as a link
|
|
assert.ok(html.includes('Test message'), 'body text after colon should be rendered');
|
|
assert.ok(!html.includes('T114-Zeh:'), 'sender long name prefix with colon should not appear in body');
|
|
});
|
|
});
|
|
|
|
test('createMessageChatEntry: meshtastic message with @[Name] is NOT resolved as mention', () => {
|
|
withApp((t) => {
|
|
t.rebuildNodeIndex([
|
|
{ node_id: '!11111111', long_name: 'Alice', short_name: 'ALCE', role: 'CLIENT', protocol: 'meshtastic' },
|
|
]);
|
|
const div = t.createMessageChatEntry({
|
|
text: 'hello @[Alice]',
|
|
rx_time: 4000,
|
|
protocol: 'meshtastic',
|
|
node: { short_name: 'ALCE', role: 'CLIENT', protocol: 'meshtastic' },
|
|
});
|
|
const html = innerHtml(div);
|
|
// Meshtastic messages do not process @[Name] — rendered as literal escaped text
|
|
assert.ok(html.includes('@[Alice]') || html.includes('@[Alice]') || html.includes('@[Alice]') || html.includes('@[Alice]'),
|
|
'meshtastic @[Name] should be escaped literally, not resolved');
|
|
// Ensure no mention badge was injected (no extra short-name span beyond the sender badge)
|
|
const shortNameCount = (html.match(/short-name/g) || []).length;
|
|
assert.ok(shortNameCount <= 1, 'only the sender badge should be present, no mention badge');
|
|
});
|
|
});
|
|
|
|
// --- renderShortHtml badge padding ---
|
|
|
|
test('renderShortHtml leaves 4-char ASCII names unpadded', () => {
|
|
withApp(() => {
|
|
const html = globalThis.PotatoMesh.renderShortHtml('0ac7', 'CLIENT');
|
|
assert.ok(!html.includes(' 0ac7'), 'should not add leading space');
|
|
assert.ok(!html.includes('0ac7 '), 'should not add trailing space');
|
|
});
|
|
});
|
|
|
|
test('renderShortHtml adds single space padding for short emoji names', () => {
|
|
withApp(() => {
|
|
const html = globalThis.PotatoMesh.renderShortHtml('\u26A1', 'CLIENT');
|
|
// Should produce " ⚡ " — one leading, one trailing space (as )
|
|
assert.ok(html.includes(' \u26A1 '), 'emoji should have one space on each side');
|
|
// Should NOT have double leading spaces
|
|
assert.ok(!html.includes(' \u26A1'), 'should not double-pad emoji');
|
|
});
|
|
});
|
|
|
|
test('renderShortHtml adds single space padding for surrogate pair emoji', () => {
|
|
withApp(() => {
|
|
const html = globalThis.PotatoMesh.renderShortHtml('\uD83D\uDE43', 'CLIENT');
|
|
// 🙃 is a surrogate pair (length 2 in JS) but 1 grapheme
|
|
assert.ok(html.includes(' \uD83D\uDE43 '), 'surrogate emoji should have one space on each side');
|
|
});
|
|
});
|
|
|
|
test('renderShortHtml adds single space padding for ZWJ emoji sequence', () => {
|
|
withApp(() => {
|
|
const zwj = '\u{1F3C3}\u{200D}\u{2642}\u{FE0F}'; // 🏃♂️ — length 5, 1 grapheme
|
|
const html = globalThis.PotatoMesh.renderShortHtml(zwj, 'CLIENT');
|
|
assert.ok(html.includes(` ${zwj} `), 'ZWJ emoji should have one space on each side');
|
|
});
|
|
});
|
|
|
|
test('renderShortHtml adds single space padding for plain 2-char name', () => {
|
|
withApp(() => {
|
|
const html = globalThis.PotatoMesh.renderShortHtml('ab', 'CLIENT');
|
|
assert.ok(html.includes(' ab '), '2-char name should have one space on each side');
|
|
});
|
|
});
|
|
|
|
// --- SPEC RL3: the chat/log tags fill from the node's own radio metadata ---
|
|
|
|
test('a Reticulum node announcement fills its frequency and preset tags', () => {
|
|
// The reported symptom was two empty brackets on every Reticulum line:
|
|
// [18:53:11][ ][ ] 2771 ☀️ New node: …
|
|
// No renderer needed a Reticulum branch — the tags were always driven by the
|
|
// node's lora_freq/modem_preset, which the provider simply never sent.
|
|
withApp((t) => {
|
|
const div = t.createAnnouncementEntry({
|
|
timestampSeconds: 1000,
|
|
shortName: '2771',
|
|
longName: 'Department of Decentralization',
|
|
role: 'NODE',
|
|
metadataSource: {
|
|
protocol: 'reticulum',
|
|
// Integer MHz: `nodes.lora_freq` is an INTEGER column and the field
|
|
// orients a reader in a band rather than stating an exact frequency.
|
|
lora_freq: 867,
|
|
modem_preset: 'LongFast',
|
|
},
|
|
nodeData: null,
|
|
messageHtml: 'New node',
|
|
});
|
|
const html = innerHtml(div);
|
|
// Assert the values, not the absence of blanks: an empty slot serialises as
|
|
// ` ` entities, so a /\s/-based "not blank" check can never fail and
|
|
// pinned nothing at all.
|
|
assert.match(html, /\[867\]/, `frequency slot not filled: ${html}`);
|
|
assert.match(html, /\[LF\]/, `preset slot not filled: ${html}`);
|
|
});
|
|
});
|
|
|
|
test('a Reticulum announcement without radio metadata keeps its empty slots', () => {
|
|
// Absent stays absent: the slots hold their width rather than inventing a
|
|
// number, exactly as they do for any other protocol that reported none.
|
|
withApp((t) => {
|
|
const div = t.createAnnouncementEntry({
|
|
timestampSeconds: 1000,
|
|
shortName: '2771',
|
|
longName: 'Department of Decentralization',
|
|
role: 'NODE',
|
|
metadataSource: { protocol: 'reticulum' },
|
|
nodeData: null,
|
|
messageHtml: 'New node',
|
|
});
|
|
const html = innerHtml(div);
|
|
assert.doesNotMatch(html, /867/);
|
|
assert.doesNotMatch(html, /\[LF\]/);
|
|
});
|
|
});
|