diff --git a/web/public/assets/img/meshcore.svg b/web/public/assets/img/meshcore.svg new file mode 100644 index 0000000..51aa36b --- /dev/null +++ b/web/public/assets/img/meshcore.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/public/assets/img/meshtastic.svg b/web/public/assets/img/meshtastic.svg new file mode 100644 index 0000000..721b0b2 --- /dev/null +++ b/web/public/assets/img/meshtastic.svg @@ -0,0 +1,16 @@ + + + +Created with Fabric.js 4.6.0 + + + + + + + + + + + + diff --git a/web/public/assets/js/app/__tests__/chat-tabs.test.js b/web/public/assets/js/app/__tests__/chat-tabs.test.js index 52a78d4..99f529d 100644 --- a/web/public/assets/js/app/__tests__/chat-tabs.test.js +++ b/web/public/assets/js/app/__tests__/chat-tabs.test.js @@ -56,7 +56,10 @@ class MockFragment { class MockElement { constructor(tagName) { this.tagName = tagName.toUpperCase(); + // children mirrors HTMLElement.children: element nodes only. this.children = []; + // childNodes mirrors HTMLElement.childNodes: all nodes including text. + this.childNodes = []; this.attributes = new Map(); this.dataset = {}; this.classList = new MockClassList(); @@ -67,18 +70,26 @@ class MockElement { } appendChild(node) { - this.children.push(node); + this.childNodes.push(node); + if (node instanceof MockElement) { + this.children.push(node); + } return node; } replaceChildren(...nodes) { this.children = []; + this.childNodes = []; for (const node of nodes) { if (!node) continue; if (node.isFragment && Array.isArray(node.children)) { this.children.push(...node.children); + this.childNodes.push(...node.children); } else { - this.children.push(node); + this.childNodes.push(node); + if (node instanceof MockElement) { + this.children.push(node); + } } } } @@ -113,6 +124,13 @@ class MockElement { } } +class MockTextNode { + constructor(text) { + this.textContent = String(text); + this.nodeType = 3; + } +} + function createMockDocument() { return { createElement(tag) { @@ -120,6 +138,9 @@ function createMockDocument() { }, createDocumentFragment() { return new MockFragment(); + }, + createTextNode(text) { + return new MockTextNode(text); } }; } @@ -192,3 +213,42 @@ test('renderChatTabs clears container when no tabs exist', () => { assert.equal(container.children.length, 0); assert.equal(container.dataset.activeTab, ''); }); + +test('renderChatTabs renders icon img child when tab.iconSrc is provided', () => { + const document = createMockDocument(); + const container = new MockElement('div'); + + const tabs = [ + { id: 'channel-0', label: 'LongFast', iconSrc: '/assets/img/meshtastic.svg' } + ]; + + renderChatTabs({ document, container, tabs }); + + const [tabList] = container.children; + const button = tabList.children[0]; + // Button has one element child (the icon ) and one text node — two childNodes total. + assert.equal(button.children.length, 1, 'should have exactly one element child (icon img)'); + assert.equal(button.childNodes.length, 2, 'should have two child nodes (icon img + text node)'); + const iconImg = button.children[0]; + assert.equal(iconImg.tagName, 'IMG', 'first element child should be an img'); + assert.equal(iconImg.getAttribute('src'), '/assets/img/meshtastic.svg', 'img src should match iconSrc'); + assert.equal(iconImg.getAttribute('aria-hidden'), 'true', 'img should be hidden from AT'); + const textNode = button.childNodes[1]; + assert.equal(textNode.nodeType, 3, 'second child node should be a text node'); + assert.equal(textNode.textContent, 'LongFast'); +}); + +test('renderChatTabs uses textContent when no iconSrc is provided', () => { + const document = createMockDocument(); + const container = new MockElement('div'); + + const tabs = [{ id: 'log', label: 'Log' }]; + + renderChatTabs({ document, container, tabs }); + + const [tabList] = container.children; + const button = tabList.children[0]; + assert.equal(button.textContent, 'Log'); + // No icon child elements + assert.equal(button.children.length, 0); +}); diff --git a/web/public/assets/js/app/__tests__/dom-environment.js b/web/public/assets/js/app/__tests__/dom-environment.js index a82e4fb..63f2c50 100644 --- a/web/public/assets/js/app/__tests__/dom-environment.js +++ b/web/public/assets/js/app/__tests__/dom-environment.js @@ -211,6 +211,32 @@ class MockElement { this.childNodes = [String(value)]; } + /** + * Register an event handler for the given event type. + * + * @param {string} event Event type. + * @param {Function} handler Callback to invoke. + * @returns {void} + */ + addEventListener(event, handler) { + if (!this._listeners) this._listeners = new Map(); + if (!this._listeners.has(event)) this._listeners.set(event, []); + this._listeners.get(event).push(handler); + } + + /** + * Remove a previously registered event handler. + * + * @param {string} event Event type. + * @param {Function} handler Callback to remove. + * @returns {void} + */ + removeEventListener(event, handler) { + if (!this._listeners || !this._listeners.has(event)) return; + const remaining = this._listeners.get(event).filter(h => h !== handler); + this._listeners.set(event, remaining); + } + /** * Very small querySelectorAll implementation that supports ``.class`` lookups * used in unit tests. @@ -303,6 +329,9 @@ export function createDomEnvironment(options = {}) { createElement(tagName) { return new MockElement(tagName, registry); }, + createTextNode(text) { + return String(text); + }, createDocumentFragment() { const fragment = new MockElement('fragment', null); fragment.childNodes = []; diff --git a/web/public/assets/js/app/__tests__/main-protocol.test.js b/web/public/assets/js/app/__tests__/main-protocol.test.js new file mode 100644 index 0000000..ed56ff3 --- /dev/null +++ b/web/public/assets/js/app/__tests__/main-protocol.test.js @@ -0,0 +1,230 @@ +/* + * 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 { createDomEnvironment } from './dom-environment.js'; +import { initializeApp } from '../main.js'; + +const MINIMAL_CONFIG = Object.freeze({ + channel: 'Primary', + frequency: '915MHz', + refreshMs: 0, + refreshIntervalSeconds: 30, + chatEnabled: true, + mapCenter: { lat: 0, lon: 0 }, + mapZoom: null, + maxDistanceKm: 0, + tileFilters: { light: '', dark: '' }, + instancesFeatureEnabled: false, + instanceDomain: null, + snapshotWindowSeconds: 3600, +}); + +/** + * Spin up a minimal DOM environment, call initializeApp with a stub config, + * and return the inner test utilities alongside an env.cleanup() handle. + * + * @returns {{ testUtils: Object, cleanup: Function }} + */ +function setupApp() { + const env = createDomEnvironment({ includeBody: true }); + // themeToggle is accessed without a null guard in initializeApp. + env.createElement('button', 'themeToggle'); + const { _testUtils } = initializeApp(MINIMAL_CONFIG); + return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) }; +} + +// --- normalizeOverlaySource --- + +test('normalizeOverlaySource propagates string protocol field', () => { + const { testUtils, cleanup } = setupApp(); + try { + const result = testUtils.normalizeOverlaySource({ protocol: 'meshcore' }); + assert.equal(result.protocol, 'meshcore'); + } finally { + cleanup(); + } +}); + +test('normalizeOverlaySource propagates "meshtastic" protocol', () => { + const { testUtils, cleanup } = setupApp(); + try { + const result = testUtils.normalizeOverlaySource({ protocol: 'meshtastic' }); + assert.equal(result.protocol, 'meshtastic'); + } finally { + cleanup(); + } +}); + +test('normalizeOverlaySource omits protocol when absent', () => { + const { testUtils, cleanup } = setupApp(); + try { + const result = testUtils.normalizeOverlaySource({ longName: 'Alice' }); + assert.ok(!('protocol' in result), 'protocol should not be set when source has none'); + } finally { + cleanup(); + } +}); + +test('normalizeOverlaySource omits protocol when value is not a string', () => { + const { testUtils, cleanup } = setupApp(); + try { + const result = testUtils.normalizeOverlaySource({ protocol: 42 }); + assert.ok(!('protocol' in result), 'protocol should not be set for non-string values'); + } finally { + cleanup(); + } +}); + +// --- buildMapPopupHtml --- + +test('buildMapPopupHtml includes meshtastic icon for null protocol', () => { + const { testUtils, cleanup } = setupApp(); + try { + const html = testUtils.buildMapPopupHtml({ long_name: 'Alice', node_id: '!abc123', protocol: null }, 0); + assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon for null protocol'); + } finally { + cleanup(); + } +}); + +test('buildMapPopupHtml includes meshtastic icon for absent protocol', () => { + const { testUtils, cleanup } = setupApp(); + try { + const html = testUtils.buildMapPopupHtml({ long_name: 'Bob', node_id: '!abc456' }, 0); + assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon when protocol absent'); + } finally { + cleanup(); + } +}); + +test('buildMapPopupHtml omits meshtastic icon for meshcore protocol', () => { + const { testUtils, cleanup } = setupApp(); + try { + const html = testUtils.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'); + } finally { + cleanup(); + } +}); + +// --- createAnnouncementEntry --- + +test('createAnnouncementEntry prefixes meshtastic icon when protocol is meshtastic', () => { + const { testUtils, cleanup } = setupApp(); + try { + const div = testUtils.createAnnouncementEntry({ + timestampSeconds: 1000, + shortName: 'ALI', + longName: 'Alice', + role: 'CLIENT', + metadataSource: { protocol: 'meshtastic' }, + nodeData: null, + messageHtml: 'joined the mesh', + }); + const html = typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? ''; + assert.ok(String(html).includes('meshtastic.svg'), 'announcement should include meshtastic icon'); + } finally { + cleanup(); + } +}); + +test('createAnnouncementEntry prefixes meshtastic icon when protocol is absent', () => { + const { testUtils, cleanup } = setupApp(); + try { + const div = testUtils.createAnnouncementEntry({ + timestampSeconds: 1000, + shortName: 'BOB', + longName: 'Bob', + role: 'ROUTER', + metadataSource: {}, + nodeData: null, + messageHtml: 'detected', + }); + const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? ''); + assert.ok(html.includes('meshtastic.svg'), 'announcement without protocol should show meshtastic icon'); + } finally { + cleanup(); + } +}); + +test('createAnnouncementEntry omits meshtastic icon for meshcore protocol', () => { + const { testUtils, cleanup } = setupApp(); + try { + const div = testUtils.createAnnouncementEntry({ + timestampSeconds: 1000, + shortName: 'MC1', + longName: 'MeshCore Node', + role: 'REPEATER', + metadataSource: { protocol: 'meshcore' }, + nodeData: null, + messageHtml: 'seen', + }); + const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? ''); + assert.ok(!html.includes('meshtastic.svg'), 'announcement for meshcore should not include meshtastic icon'); + } finally { + cleanup(); + } +}); + +// --- createMessageChatEntry --- + +test('createMessageChatEntry prefixes meshtastic icon when node protocol is meshtastic', () => { + const { testUtils, cleanup } = setupApp(); + try { + const div = testUtils.createMessageChatEntry({ + text: 'hello mesh', + rx_time: 1000, + node: { short_name: 'ALI', role: 'CLIENT', protocol: 'meshtastic' }, + }); + const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? ''); + assert.ok(html.includes('meshtastic.svg'), 'chat entry should include meshtastic icon'); + } finally { + cleanup(); + } +}); + +test('createMessageChatEntry prefixes meshtastic icon when node protocol is absent', () => { + const { testUtils, cleanup } = setupApp(); + try { + const div = testUtils.createMessageChatEntry({ + text: 'hi', + rx_time: 2000, + node: { short_name: 'BOB', role: 'ROUTER' }, + }); + const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? ''); + assert.ok(html.includes('meshtastic.svg'), 'chat entry without protocol should show meshtastic icon'); + } finally { + cleanup(); + } +}); + +test('createMessageChatEntry omits meshtastic icon for meshcore node', () => { + const { testUtils, cleanup } = setupApp(); + try { + const div = testUtils.createMessageChatEntry({ + text: 'test', + rx_time: 3000, + node: { short_name: 'MC1', role: 'REPEATER', protocol: 'meshcore' }, + }); + const html = String(typeof div.innerHTML === 'string' ? div.innerHTML : div.childNodes?.[0] ?? ''); + assert.ok(!html.includes('meshtastic.svg'), 'chat entry for meshcore node should not show meshtastic icon'); + } finally { + cleanup(); + } +}); diff --git a/web/public/assets/js/app/__tests__/node-link-helpers.test.js b/web/public/assets/js/app/__tests__/node-link-helpers.test.js new file mode 100644 index 0000000..71ad0bc --- /dev/null +++ b/web/public/assets/js/app/__tests__/node-link-helpers.test.js @@ -0,0 +1,159 @@ +/* + * 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 { + escapeHtml, + normalizeNodeNameValue, + buildNodeDetailHref, + canonicalNodeIdentifier, + renderNodeLongNameLink, +} from '../main.js'; + +// --- escapeHtml --- + +test('escapeHtml escapes & < > " and single-quote', () => { + assert.equal(escapeHtml('a & b'), 'a & b'); + assert.equal(escapeHtml('