From 06530f36ff331eb708f5c9c05c751db4bbbd98ab Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 5 Apr 2026 09:01:43 +0200 Subject: [PATCH] web: add proper short names for meshcore companions (#693) * web: add proper short names for meshcore companions * web: address review comments --- .../application/helpers/node_helpers.rb | 45 +++ .../application/queries/node_queries.rb | 4 + .../js/app/__tests__/map-center-reset.test.js | 381 ++++++++++++++++++ web/public/assets/js/app/main.js | 18 + web/public/assets/js/app/map-center-reset.js | 111 +++++ web/spec/helpers/node_helpers_spec.rb | 66 +++ web/spec/queries_spec.rb | 95 +++++ web/views/shared/_map_panel.erb | 12 + 8 files changed, 732 insertions(+) create mode 100644 web/public/assets/js/app/__tests__/map-center-reset.test.js create mode 100644 web/public/assets/js/app/map-center-reset.js diff --git a/web/lib/potato_mesh/application/helpers/node_helpers.rb b/web/lib/potato_mesh/application/helpers/node_helpers.rb index 8a4ea07..45b1702 100644 --- a/web/lib/potato_mesh/application/helpers/node_helpers.rb +++ b/web/lib/potato_mesh/application/helpers/node_helpers.rb @@ -66,6 +66,51 @@ module PotatoMesh trimmed.start_with?("!") ? trimmed : "!#{trimmed}" end + # Broad emoji regex covering the most common Unicode emoji blocks: + # Supplementary Multilingual Plane emoji (U+1F000–U+1FFFF), Miscellaneous + # Symbols and Dingbats (U+2600–U+27BF), and Miscellaneous Symbols and + # Arrows (U+2B00–U+2BFF). + # + # @type [Regexp] + MESHCORE_COMPANION_EMOJI_PATTERN = /[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}]/u + + # Derive a display short name for a MeshCore COMPANION node from its long + # name. The ingestor stores a raw 2-byte short name; this method produces a + # richer, human-readable variant for the API layer without touching the DB. + # + # Algorithm (applied in priority order): + # 1. If the long name contains an emoji character (see + # +MESHCORE_COMPANION_EMOJI_PATTERN+), use the first emoji embedded in a + # 4-character display slot: ``" E "`` (two leading spaces, emoji, space). + # 2. If the long name contains two or more whitespace-separated words, use + # the capitalised first letters of the first two words: ``" XY "``. + # 3. If the long name is a single word, use its capitalised first letter: + # ``" A "``. + # 4. Return +nil+ when no short name can be derived (blank input, or a + # word without extractable characters). + # + # @param long_name [String, nil] long name stored on the node. + # @return [String, nil] derived display short name or +nil+. + def meshcore_companion_display_short_name(long_name) + name = string_or_nil(long_name) + return nil unless name + + emoji = name.scan(MESHCORE_COMPANION_EMOJI_PATTERN).first + return " #{emoji} " if emoji + + words = name.strip.split(/\s+/).reject(&:empty?) + return nil if words.empty? + + if words.length >= 2 + first = words[0][0]&.upcase + second = words[1][0]&.upcase + return " #{first}#{second} " if first && second + end + + letter = words[0][0]&.upcase + letter ? " #{letter} " : nil + end + # Recursively coerce hash keys to strings and normalise nested arrays. # # @param value [Object] JSON compatible value. diff --git a/web/lib/potato_mesh/application/queries/node_queries.rb b/web/lib/potato_mesh/application/queries/node_queries.rb index 994ea50..0a59846 100644 --- a/web/lib/potato_mesh/application/queries/node_queries.rb +++ b/web/lib/potato_mesh/application/queries/node_queries.rb @@ -157,6 +157,10 @@ module PotatoMesh end rows.each do |r| r["role"] ||= "CLIENT" + if r["role"] == "COMPANION" + derived = meshcore_companion_display_short_name(r["long_name"]) + r["short_name"] = derived if derived + end lh = r["last_heard"]&.to_i pt = r["position_time"]&.to_i lh = now if lh && lh > now diff --git a/web/public/assets/js/app/__tests__/map-center-reset.test.js b/web/public/assets/js/app/__tests__/map-center-reset.test.js new file mode 100644 index 0000000..86ba5f5 --- /dev/null +++ b/web/public/assets/js/app/__tests__/map-center-reset.test.js @@ -0,0 +1,381 @@ +/* + * 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 { createMapCenterResetHandler, DEFAULT_CENTER_RESET_ZOOM } from '../map-center-reset.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a minimal mock map object whose setView calls are recorded. + * + * @returns {{ setView: Function, calls: Array }} Mock map. + */ +function mockMap() { + const obj = { + calls: [], + setView(target, zoom, options) { + obj.calls.push({ type: 'setView', target, zoom, options }); + } + }; + return obj; +} + +/** + * Build a minimal autoFitController stub. + * + * @param {{ bounds?: object | null }} [opts] + * @returns {object} + */ +function mockController(opts = {}) { + const lastFit = opts.bounds !== undefined ? opts.bounds : null; + const runs = []; + return { + runCallCount: 0, + runs, + getLastFit() { return lastFit; }, + runAutoFitOperation(fn) { + this.runCallCount += 1; + runs.push(fn); + fn(); + } + }; +} + +const CENTER = { lat: 38.76, lon: -27.09 }; + +// --------------------------------------------------------------------------- +// Constructor validation +// --------------------------------------------------------------------------- + +test('createMapCenterResetHandler throws when getMap is not a function', () => { + assert.throws(() => { + createMapCenterResetHandler({ + getMap: null, + autoFitController: mockController(), + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + }); + }, /getMap/); +}); + +test('createMapCenterResetHandler throws when autoFitController is missing getLastFit', () => { + assert.throws(() => { + createMapCenterResetHandler({ + getMap: () => mockMap(), + autoFitController: {}, + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + }); + }, /autoFitController/); +}); + +test('createMapCenterResetHandler throws when fitMapToBounds is not a function', () => { + assert.throws(() => { + createMapCenterResetHandler({ + getMap: () => mockMap(), + autoFitController: mockController(), + fitMapToBounds: null, + mapCenterCoords: CENTER, + }); + }, /fitMapToBounds/); +}); + +test('createMapCenterResetHandler throws when mapCenterCoords is invalid', () => { + assert.throws(() => { + createMapCenterResetHandler({ + getMap: () => mockMap(), + autoFitController: mockController(), + fitMapToBounds: () => {}, + mapCenterCoords: { lat: NaN, lon: 0 }, + }); + }, /mapCenterCoords/); + + assert.throws(() => { + createMapCenterResetHandler({ + getMap: () => mockMap(), + autoFitController: mockController(), + fitMapToBounds: () => {}, + mapCenterCoords: null, + }); + }, /mapCenterCoords/); +}); + +// --------------------------------------------------------------------------- +// No-op when map is unavailable +// --------------------------------------------------------------------------- + +test('handler returns without throwing when getMap returns null', () => { + const fitCalls = []; + const handler = createMapCenterResetHandler({ + getMap: () => null, + autoFitController: mockController(), + fitMapToBounds: (...args) => fitCalls.push(args), + mapCenterCoords: CENTER, + }); + assert.doesNotThrow(() => handler()); + assert.equal(fitCalls.length, 0); +}); + +// --------------------------------------------------------------------------- +// Auto-fit checkbox re-enabling +// --------------------------------------------------------------------------- + +test('handler enables fitBoundsEl when present and not disabled', () => { + const dispatched = []; + const fitBoundsEl = { + checked: false, + disabled: false, + dispatchEvent(e) { dispatched.push(e.type); } + }; + // Provide a lastFit so the fallback setView path does not run — this test + // is only asserting the checkbox re-enable behaviour. + const lastFit = { bounds: [[0, 0], [1, 1]], options: { paddingPx: 12 } }; + const controller = mockController({ bounds: lastFit }); + const handler = createMapCenterResetHandler({ + getMap: () => mockMap(), + autoFitController: controller, + fitBoundsEl, + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + }); + handler(); + assert.equal(fitBoundsEl.checked, true); + assert.equal(controller.runCallCount, 1); + assert.deepEqual(dispatched, ['change']); +}); + +test('handler dispatches a bubbling change event when re-enabling fitBoundsEl', () => { + let capturedEvent = null; + const fitBoundsEl = { + checked: false, + disabled: false, + dispatchEvent(e) { capturedEvent = e; } + }; + const handler = createMapCenterResetHandler({ + getMap: () => mockMap(), + autoFitController: mockController(), + fitBoundsEl, + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + }); + handler(); + assert.ok(capturedEvent, 'expected a change event to be dispatched'); + assert.equal(capturedEvent.type, 'change'); + assert.equal(capturedEvent.bubbles, true); +}); + +test('handler does not modify fitBoundsEl.checked when element is disabled', () => { + const fitBoundsEl = { checked: false, disabled: true }; + // Provide a lastFit so the fallback setView path does not run — this test + // is only asserting the checkbox non-modification when disabled. + const lastFit = { bounds: [[0, 0], [1, 1]], options: { paddingPx: 12 } }; + const controller = mockController({ bounds: lastFit }); + const handler = createMapCenterResetHandler({ + getMap: () => mockMap(), + autoFitController: controller, + fitBoundsEl, + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + }); + handler(); + assert.equal(fitBoundsEl.checked, false); + assert.equal(controller.runCallCount, 0); +}); + +test('handler does not throw when fitBoundsEl is null', () => { + const handler = createMapCenterResetHandler({ + getMap: () => mockMap(), + autoFitController: mockController(), + fitBoundsEl: null, + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + }); + assert.doesNotThrow(() => handler()); +}); + +test('handler does not throw when fitBoundsEl is omitted', () => { + const handler = createMapCenterResetHandler({ + getMap: () => mockMap(), + autoFitController: mockController(), + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + }); + assert.doesNotThrow(() => handler()); +}); + +// --------------------------------------------------------------------------- +// Last-fit path +// --------------------------------------------------------------------------- + +test('handler calls fitMapToBounds with last-fit bounds when available', () => { + const fakeBounds = [[1, 2], [3, 4]]; + const lastFit = { bounds: fakeBounds, options: { paddingPx: 12, maxZoom: 13 } }; + const fitCalls = []; + const map = mockMap(); + const handler = createMapCenterResetHandler({ + getMap: () => map, + autoFitController: mockController({ bounds: lastFit }), + fitMapToBounds: (bounds, opts) => fitCalls.push({ bounds, opts }), + mapCenterCoords: CENTER, + }); + handler(); + assert.equal(fitCalls.length, 1); + assert.deepEqual(fitCalls[0].bounds, fakeBounds); + assert.equal(fitCalls[0].opts.animate, true); + assert.equal(fitCalls[0].opts.paddingPx, 12); + assert.equal(fitCalls[0].opts.maxZoom, 13); + // setView must NOT be called when last-fit path is taken + assert.equal(map.calls.length, 0); +}); + +test('handler forwards paddingPx and maxZoom from last-fit options', () => { + const lastFit = { bounds: [[0, 0], [1, 1]], options: { paddingPx: 8 } }; + const fitCalls = []; + const handler = createMapCenterResetHandler({ + getMap: () => mockMap(), + autoFitController: mockController({ bounds: lastFit }), + fitMapToBounds: (b, o) => fitCalls.push(o), + mapCenterCoords: CENTER, + }); + handler(); + assert.equal(fitCalls[0].paddingPx, 8); + assert.equal(fitCalls[0].maxZoom, undefined); +}); + +// --------------------------------------------------------------------------- +// Fallback path (no last fit recorded) +// --------------------------------------------------------------------------- + +test('handler calls setView with configured centre when no last fit exists', () => { + const map = mockMap(); + const controller = mockController({ bounds: null }); + const handler = createMapCenterResetHandler({ + getMap: () => map, + autoFitController: controller, + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + mapZoomOverride: null, + }); + handler(); + assert.equal(map.calls.length, 1); + assert.deepEqual(map.calls[0].target, [CENTER.lat, CENTER.lon]); + assert.equal(map.calls[0].zoom, DEFAULT_CENTER_RESET_ZOOM); + assert.deepEqual(map.calls[0].options, { animate: true }); +}); + +test('fallback setView is wrapped in runAutoFitOperation to prevent movestart/zoomstart unchecking auto-fit', () => { + // Without the wrapper, the programmatic setView triggers movestart which calls + // handleUserInteraction, undoing the auto-fit re-enable. runAutoFitOperation + // sets autoFitInProgress=true so handleUserInteraction returns early. + const map = mockMap(); + const controller = mockController({ bounds: null }); + const handler = createMapCenterResetHandler({ + getMap: () => map, + autoFitController: controller, + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + }); + handler(); + // runAutoFitOperation must have been called at least once for the setView fallback + assert.ok(controller.runCallCount >= 1, 'expected runAutoFitOperation to be called'); + assert.equal(map.calls.length, 1); +}); + +test('fallback uses mapZoomOverride when provided', () => { + const map = mockMap(); + const handler = createMapCenterResetHandler({ + getMap: () => map, + autoFitController: mockController({ bounds: null }), + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + mapZoomOverride: 15, + }); + handler(); + assert.equal(map.calls[0].zoom, 15); +}); + +test('fallback uses DEFAULT_CENTER_RESET_ZOOM when mapZoomOverride is null', () => { + const map = mockMap(); + const handler = createMapCenterResetHandler({ + getMap: () => map, + autoFitController: mockController({ bounds: null }), + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + mapZoomOverride: null, + }); + handler(); + assert.equal(map.calls[0].zoom, DEFAULT_CENTER_RESET_ZOOM); +}); + +test('fallback uses DEFAULT_CENTER_RESET_ZOOM when mapZoomOverride is zero', () => { + const map = mockMap(); + const handler = createMapCenterResetHandler({ + getMap: () => map, + autoFitController: mockController({ bounds: null }), + fitMapToBounds: () => {}, + mapCenterCoords: CENTER, + mapZoomOverride: 0, + }); + handler(); + assert.equal(map.calls[0].zoom, DEFAULT_CENTER_RESET_ZOOM); +}); + +// --------------------------------------------------------------------------- +// Mutual exclusivity +// --------------------------------------------------------------------------- + +test('fitMapToBounds and setView are mutually exclusive per invocation (last fit wins)', () => { + const fitCalls = []; + const map = mockMap(); + const lastFit = { bounds: [[0, 0], [1, 1]], options: { paddingPx: 12 } }; + const handler = createMapCenterResetHandler({ + getMap: () => map, + autoFitController: mockController({ bounds: lastFit }), + fitMapToBounds: (...args) => fitCalls.push(args), + mapCenterCoords: CENTER, + }); + handler(); + assert.equal(fitCalls.length, 1); + assert.equal(map.calls.length, 0); +}); + +test('fitMapToBounds and setView are mutually exclusive per invocation (fallback wins)', () => { + const fitCalls = []; + const map = mockMap(); + const handler = createMapCenterResetHandler({ + getMap: () => map, + autoFitController: mockController({ bounds: null }), + fitMapToBounds: (...args) => fitCalls.push(args), + mapCenterCoords: CENTER, + }); + handler(); + assert.equal(fitCalls.length, 0); + assert.equal(map.calls.length, 1); +}); + +// --------------------------------------------------------------------------- +// DEFAULT_CENTER_RESET_ZOOM export +// --------------------------------------------------------------------------- + +test('DEFAULT_CENTER_RESET_ZOOM is a positive finite number', () => { + assert.ok(Number.isFinite(DEFAULT_CENTER_RESET_ZOOM)); + assert.ok(DEFAULT_CENTER_RESET_ZOOM > 0); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 7008277..8a405cd 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -51,6 +51,7 @@ import { resolveAutoFitBoundsConfig } from './map-auto-fit-settings.js'; import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js'; import { resolveLegendVisibility } from './map-legend-visibility.js'; import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM } from './nodes-map-focus.js'; +import { createMapCenterResetHandler } from './map-center-reset.js'; import { enhanceCoordinateCell } from './nodes-coordinate-links.js'; import { createShortInfoOverlayStack } from './short-info-overlay-manager.js'; import { createNodeDetailOverlayManager } from './node-detail-overlay.js'; @@ -484,6 +485,7 @@ export function initializeApp(config) { const mapContainer = document.getElementById('map'); const mapPanel = document.getElementById('mapPanel'); const mapFullscreenToggle = document.getElementById('mapFullscreenToggle'); + const mapCenterResetEl = document.getElementById('mapCenterReset'); const fullscreenContainer = mapPanel || mapContainer; const isFederationView = bodyClassList ? bodyClassList.contains('view-federation') : false; const legendDefaultCollapsed = mapPanel ? mapPanel.dataset.legendCollapsed === 'true' : false; @@ -536,6 +538,15 @@ export function initializeApp(config) { } }); + const centerResetHandler = createMapCenterResetHandler({ + getMap: () => map, + autoFitController, + fitBoundsEl, + fitMapToBounds, + mapCenterCoords: MAP_CENTER_COORDS, + mapZoomOverride, + }); + /** * Fit the Leaflet map to the provided geographic bounds. * @@ -834,6 +845,13 @@ export function initializeApp(config) { } } + if (mapCenterResetEl) { + mapCenterResetEl.addEventListener('click', event => { + event.preventDefault(); + centerResetHandler(); + }); + } + syncInfoOverlayHost(); /** @type {Set} Active compound role-filter keys, each ``":"``. */ diff --git a/web/public/assets/js/app/map-center-reset.js b/web/public/assets/js/app/map-center-reset.js new file mode 100644 index 0000000..3d73b19 --- /dev/null +++ b/web/public/assets/js/app/map-center-reset.js @@ -0,0 +1,111 @@ +/* + * 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. + */ + +/** + * Default zoom level used when resetting to the configured map centre and no + * last-fit bounds are available. + * + * @type {number} + */ +export const DEFAULT_CENTER_RESET_ZOOM = 10; + +/** + * Build a handler that resets the map view to fit all known nodes. + * + * When invoked, the handler: + * 1. Re-enables the auto-fit checkbox (unless the element is disabled, e.g. + * when a hard zoom override is in effect). + * 2. Re-applies the last recorded auto-fit bounds via ``fitMapToBounds`` when + * available — this is the bounds computed during the most recent + * ``renderMap`` call and covers all visible nodes. + * 3. Falls back to ``map.setView`` at the configured centre coordinates when no + * prior fit has been recorded (e.g. before the first data refresh). + * + * @param {object} options - Factory configuration. + * @param {() => object | null} options.getMap - Returns the active Leaflet map instance. + * @param {{ + * getLastFit(): { bounds: [[number,number],[number,number]], options: { paddingPx: number, maxZoom?: number } } | null, + * runAutoFitOperation(fn: () => void): void + * }} options.autoFitController - Auto-fit controller created by ``createMapAutoFitController``. + * @param {HTMLInputElement | null} [options.fitBoundsEl] - The auto-fit toggle checkbox. + * @param {(bounds: [[number,number],[number,number]], options?: object) => void} options.fitMapToBounds - Fits the map to bounds. + * @param {{ lat: number, lon: number }} options.mapCenterCoords - Configured map centre coordinates. + * @param {number | null} [options.mapZoomOverride] - Hard zoom level from server config, or null when absent. + * @returns {() => void} Handler to call when the centre-reset button is clicked. + */ +export function createMapCenterResetHandler({ + getMap, + autoFitController, + fitBoundsEl = null, + fitMapToBounds, + mapCenterCoords, + mapZoomOverride = null, +}) { + if (typeof getMap !== 'function') { + throw new TypeError('getMap must be a function that returns the active map instance.'); + } + if (!autoFitController || typeof autoFitController.getLastFit !== 'function') { + throw new TypeError('autoFitController must expose getLastFit().'); + } + if (typeof fitMapToBounds !== 'function') { + throw new TypeError('fitMapToBounds must be a function.'); + } + if (!mapCenterCoords || !Number.isFinite(mapCenterCoords.lat) || !Number.isFinite(mapCenterCoords.lon)) { + throw new TypeError('mapCenterCoords must be an object with finite lat and lon.'); + } + + return function handleCenterReset() { + const map = getMap(); + if (!map) return; + + // Re-enable autofit when the checkbox is present and not locked out by a + // hard zoom override (the element is disabled in that case). Dispatch a + // change event so any listeners that synchronise UI state (e.g. aria + // attributes) are notified, mirroring the pattern in handleUserInteraction. + if (fitBoundsEl && !fitBoundsEl.disabled) { + autoFitController.runAutoFitOperation(() => { + fitBoundsEl.checked = true; + fitBoundsEl.dispatchEvent(new Event('change', { bubbles: true })); + }); + } + + // Re-apply the last known node-fit bounds produced by renderMap. + // fitMapToBounds already calls runAutoFitOperation internally, so the + // movestart/zoomstart handlers cannot uncheck auto-fit during the pan. + const lastFit = autoFitController.getLastFit(); + if (lastFit) { + fitMapToBounds(lastFit.bounds, { + animate: true, + paddingPx: lastFit.options.paddingPx, + maxZoom: lastFit.options.maxZoom, + }); + return; + } + + // Fallback: no prior fit recorded yet — reset to the configured centre. + // Wrap in runAutoFitOperation so the movestart/zoomstart handlers see + // autoFitInProgress=true and do not immediately uncheck the auto-fit + // checkbox as a side-effect of the programmatic setView call. + const zoom = Number.isFinite(mapZoomOverride) && mapZoomOverride > 0 + ? mapZoomOverride + : DEFAULT_CENTER_RESET_ZOOM; + if (typeof map.setView === 'function') { + autoFitController.runAutoFitOperation(() => { + map.setView([mapCenterCoords.lat, mapCenterCoords.lon], zoom, { animate: true }); + }); + } + }; +} diff --git a/web/spec/helpers/node_helpers_spec.rb b/web/spec/helpers/node_helpers_spec.rb index 83aa56f..a54a0fe 100644 --- a/web/spec/helpers/node_helpers_spec.rb +++ b/web/spec/helpers/node_helpers_spec.rb @@ -141,4 +141,70 @@ RSpec.describe PotatoMesh::App::Helpers do expect(helper.normalize_json_object(42)).to be_nil end end + + # --------------------------------------------------------------------------- + # meshcore_companion_display_short_name + # --------------------------------------------------------------------------- + describe "#meshcore_companion_display_short_name" do + it "returns nil for nil input" do + expect(helper.meshcore_companion_display_short_name(nil)).to be_nil + end + + it "returns nil for an empty string" do + expect(helper.meshcore_companion_display_short_name("")).to be_nil + end + + it "returns nil for a whitespace-only string" do + expect(helper.meshcore_companion_display_short_name(" ")).to be_nil + end + + it "returns ' A ' for a single-word name" do + expect(helper.meshcore_companion_display_short_name("Alice")).to eq(" A ") + end + + it "returns ' AB ' for a two-word name" do + expect(helper.meshcore_companion_display_short_name("Alice Bob")).to eq(" AB ") + end + + it "uses only the first two words for longer names" do + expect(helper.meshcore_companion_display_short_name("Alice Bob Carol")).to eq(" AB ") + end + + it "uppercases the initials regardless of original case" do + expect(helper.meshcore_companion_display_short_name("alice bob")).to eq(" AB ") + end + + it "strips leading and trailing whitespace before splitting" do + expect(helper.meshcore_companion_display_short_name(" alice bob ")).to eq(" AB ") + end + + it "returns the first emoji from the SMP range (U+1F000–U+1FFFF)" do + name = "Node \u{1F600}" + expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ") + end + + it "returns the first emoji from the misc symbols range (U+2600–U+27BF)" do + name = "\u{2600} Sun" + expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{2600} ") + end + + it "returns the first emoji from the arrows range (U+2B00–U+2BFF)" do + name = "\u{2B50} Star" + expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{2B50} ") + end + + it "uses the FIRST emoji when multiple are present" do + name = "\u{1F600}\u{1F601} Two" + expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ") + end + + it "prefers emoji over initials when both are present" do + name = "Alice \u{1F600} Bob" + expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ") + end + + it "returns the single initial when the name is one word with no emoji" do + expect(helper.meshcore_companion_display_short_name("Zigzag")).to eq(" Z ") + end + end end diff --git a/web/spec/queries_spec.rb b/web/spec/queries_spec.rb index c778913..1f0e40b 100644 --- a/web/spec/queries_spec.rb +++ b/web/spec/queries_spec.rb @@ -418,6 +418,101 @@ RSpec.describe PotatoMesh::App::Queries do rows = queries.query_nodes(10, since: now + 9999) expect(rows).to be_empty end + + context "COMPANION short name enrichment" do + it "derives a two-initial short name for a COMPANION node with a two-word long name" do + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \ + "VALUES (?,?,?,?,?,?,?)", + ["!cc000001", 0xcc000001, "CX", "Alice Bob", now, now, "COMPANION"], + ) + end + rows = queries.query_nodes(10, node_ref: "!cc000001") + row = rows.find { |r| r["node_id"] == "!cc000001" } + expect(row).not_to be_nil + expect(row["short_name"]).to eq(" AB ") + end + + it "derives a single-initial short name for a COMPANION node with a one-word long name" do + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \ + "VALUES (?,?,?,?,?,?,?)", + ["!cc000002", 0xcc000002, "CX", "Zigzag", now, now, "COMPANION"], + ) + end + rows = queries.query_nodes(10, node_ref: "!cc000002") + row = rows.find { |r| r["node_id"] == "!cc000002" } + expect(row["short_name"]).to eq(" Z ") + end + + it "derives an emoji short name for a COMPANION node whose long name contains an emoji" do + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \ + "VALUES (?,?,?,?,?,?,?)", + ["!cc000003", 0xcc000003, "CX", "Node \u{1F600}", now, now, "COMPANION"], + ) + end + rows = queries.query_nodes(10, node_ref: "!cc000003") + row = rows.find { |r| r["node_id"] == "!cc000003" } + expect(row["short_name"]).to eq(" \u{1F600} ") + end + + it "does not overwrite short_name when long_name is blank for a COMPANION node" do + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \ + "VALUES (?,?,?,?,?,?,?)", + ["!cc000004", 0xcc000004, "CX", "", now, now, "COMPANION"], + ) + end + rows = queries.query_nodes(10, node_ref: "!cc000004") + row = rows.find { |r| r["node_id"] == "!cc000004" } + # blank long_name → nil derived → original short_name preserved by compact_api_row + expect(row["short_name"]).to eq("CX") + end + + it "leaves the short_name unchanged for a CLIENT node with a multi-word long name" do + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \ + "VALUES (?,?,?,?,?,?,?)", + ["!cc000005", 0xcc000005, "XY", "Alice Bob", now, now, "CLIENT"], + ) + end + rows = queries.query_nodes(10, node_ref: "!cc000005") + row = rows.find { |r| r["node_id"] == "!cc000005" } + expect(row["short_name"]).to eq("XY") + end + + it "does not overwrite short_name when long_name is NULL in the DB for a COMPANION node" do + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \ + "VALUES (?,?,?,?,?,?,?)", + ["!cc000007", 0xcc000007, "CX", nil, now, now, "COMPANION"], + ) + end + rows = queries.query_nodes(10, node_ref: "!cc000007") + row = rows.find { |r| r["node_id"] == "!cc000007" } + expect(row["short_name"]).to eq("CX") + end + + it "leaves the short_name unchanged for a node whose role defaults to CLIENT (nil in DB)" do + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \ + "VALUES (?,?,?,?,?,?,?)", + ["!cc000006", 0xcc000006, "ZZ", "Alice Bob", now, now, nil], + ) + end + rows = queries.query_nodes(10, node_ref: "!cc000006") + row = rows.find { |r| r["node_id"] == "!cc000006" } + expect(row["short_name"]).to eq("ZZ") + end + end end describe "#query_messages" do diff --git a/web/views/shared/_map_panel.erb b/web/views/shared/_map_panel.erb index 6020075..493662a 100644 --- a/web/views/shared/_map_panel.erb +++ b/web/views/shared/_map_panel.erb @@ -22,6 +22,18 @@
" id="mapPanel" <%= data_attrs.join(" ") %>>
+