Files
potato-mesh/web/public/assets/js/app/__tests__/main-protocol.test.js
T
l5y 39e5d01d26 data,web: Reticulum finalization — radio metadata, chat tags, federation zeros (#900)
* data,web: Reticulum finalization — radio metadata, chat tags, federation zeros

* web: purge destinations with their nodes
2026-08-31 12:01:36 +02:00

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('&nbsp;0ac7'), 'should not add leading space');
assert.ok(!html.includes('0ac7&nbsp;'), '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 &nbsp;)
assert.ok(html.includes('&nbsp;\u26A1&nbsp;'), 'emoji should have one space on each side');
// Should NOT have double leading spaces
assert.ok(!html.includes('&nbsp;&nbsp;\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('&nbsp;\uD83D\uDE43&nbsp;'), '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(`&nbsp;${zwj}&nbsp;`), '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('&nbsp;ab&nbsp;'), '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
// `&nbsp;` 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\]/);
});
});