web: add proper short names for meshcore companions (#693)

* web: add proper short names for meshcore companions

* web: address review comments
This commit is contained in:
l5y
2026-04-05 09:01:43 +02:00
committed by GitHub
parent 3cfa0db7e6
commit 06530f36ff
8 changed files with 732 additions and 0 deletions

View File

@@ -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+1F000U+1FFFF), Miscellaneous
# Symbols and Dingbats (U+2600U+27BF), and Miscellaneous Symbols and
# Arrows (U+2B00U+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.

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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<string>} Active compound role-filter keys, each ``"<protocol>:<roleKey>"``. */

View File

@@ -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 });
});
}
};
}

View File

@@ -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+1F000U+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+2600U+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+2B00U+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

View File

@@ -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

View File

@@ -22,6 +22,18 @@
<div class="<%= map_classes.join(" ") %>" id="mapPanel" <%= data_attrs.join(" ") %>>
<div id="map" role="region" aria-label="Nodes map"></div>
<div class="map-toolbar" role="group" aria-label="Map view controls">
<button id="mapCenterReset" type="button" aria-label="Re-center map to all nodes">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="M12 2v3m0 14v3M2 12h3m14 0h3m-5 0a5 5 0 1 1-10 0 5 5 0 0 1 10 0Zm-5-2v4m-2-2h4"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button id="mapFullscreenToggle" type="button" aria-pressed="false" aria-label="Enter full screen map view">
<svg class="icon-fullscreen-enter" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path