mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-07 01:13:01 +02:00
web: reference meshcore nodes in chat (#709)
* web: reference meshcore nodes in chat * data: add adv_name to messages * web: address review comments * derive actual companion from name string * derive actual companion from name string * derive actual companion from name string * web: address review comments * web: address review comments
This commit is contained in:
@@ -45,6 +45,7 @@ import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
@@ -204,6 +205,94 @@ def _meshcore_adv_type_to_role(adv_type: object) -> str | None:
|
||||
return _MESHCORE_ADV_TYPE_ROLE.get(adv_type)
|
||||
|
||||
|
||||
def _parse_sender_name(text: str) -> str | None:
|
||||
"""Extract the sender name from a MeshCore channel message text.
|
||||
|
||||
MeshCore channel messages use the convention ``"SenderName: body"``.
|
||||
Only the first colon is treated as the separator; colons that appear in the
|
||||
body are preserved. The sender name is stripped of leading and trailing
|
||||
whitespace.
|
||||
|
||||
Parameters:
|
||||
text: Raw message text as stored in the database.
|
||||
|
||||
Returns:
|
||||
Stripped sender name string, or ``None`` when the text does not
|
||||
contain a colon or the portion before the colon is blank.
|
||||
"""
|
||||
colon_idx = text.find(":")
|
||||
if colon_idx < 0:
|
||||
return None
|
||||
name = text[:colon_idx].strip()
|
||||
return name if name else None
|
||||
|
||||
|
||||
# Matches @[Name] mention patterns in MeshCore message bodies.
|
||||
_MENTION_RE = re.compile(r"@\[([^\]]+)\]")
|
||||
|
||||
|
||||
def _derive_synthetic_node_id(long_name: str) -> str:
|
||||
"""Derive a deterministic synthetic ``!xxxxxxxx`` node ID from a long name.
|
||||
|
||||
Uses the first four bytes of SHA-256(UTF-8 encoded name), formatted as
|
||||
``!xxxxxxxx``. The same long name always produces the same ID across
|
||||
restarts. The probability of collision with a real public-key-derived ID
|
||||
is ~1 in 4 billion per pair, which is negligible in practice.
|
||||
|
||||
Parameters:
|
||||
long_name: Node long name used as the hash input.
|
||||
|
||||
Returns:
|
||||
Canonical ``!xxxxxxxx`` node ID string.
|
||||
"""
|
||||
return "!" + hashlib.sha256(long_name.encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
def _synthetic_node_dict(long_name: str) -> dict:
|
||||
"""Build a synthetic node dict for an unknown MeshCore channel sender.
|
||||
|
||||
Synthetic nodes are placeholder entries created when a channel message
|
||||
arrives from a sender who is not yet in the connected device's contacts
|
||||
roster. They carry ``role=COMPANION`` (the only role capable of sending
|
||||
channel messages). The short name is intentionally omitted here — the
|
||||
Ruby web app derives it at query time via
|
||||
``meshcore_companion_display_short_name`` for all COMPANION nodes.
|
||||
|
||||
When the real contact advertisement is later received, the Ruby web app
|
||||
detects the matching long name, migrates all messages from the synthetic
|
||||
node ID to the real one, and removes the placeholder row.
|
||||
|
||||
Parameters:
|
||||
long_name: Sender name parsed from the ``"SenderName: body"`` prefix.
|
||||
|
||||
Returns:
|
||||
Node dict compatible with the ``POST /api/nodes`` payload format,
|
||||
with ``user.synthetic`` set to ``True``.
|
||||
"""
|
||||
return {
|
||||
"lastHeard": int(time.time()),
|
||||
"protocol": "meshcore",
|
||||
"user": {
|
||||
"longName": long_name,
|
||||
"shortName": "",
|
||||
"role": "COMPANION",
|
||||
"synthetic": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _extract_mention_names(text: str) -> list[str]:
|
||||
"""Extract all ``@[Name]`` mention names from a MeshCore message body.
|
||||
|
||||
Parameters:
|
||||
text: Raw message text that may contain ``@[Name]`` mention patterns.
|
||||
|
||||
Returns:
|
||||
List of extracted name strings (may be empty).
|
||||
"""
|
||||
return _MENTION_RE.findall(text)
|
||||
|
||||
|
||||
def _pubkey_prefix_to_node_id(contacts: dict, pubkey_prefix: str) -> str | None:
|
||||
"""Look up a canonical node ID by six-byte public-key prefix.
|
||||
|
||||
@@ -371,6 +460,12 @@ class _MeshcoreInterface:
|
||||
self._contacts_lock = threading.Lock()
|
||||
self._contacts: dict = {}
|
||||
self.isConnected: bool = False
|
||||
# Tracks synthetic node IDs already upserted this session to avoid
|
||||
# repeating the HTTP POST for every message from the same unknown sender.
|
||||
# This set is reset on reconnect (because _MeshcoreInterface is recreated),
|
||||
# which may cause extra upserts after a disconnect — the ON CONFLICT guard
|
||||
# in the Ruby web app ensures those are idempotent and safe.
|
||||
self._synthetic_node_ids: set[str] = set()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Contact management (called from the asyncio thread)
|
||||
@@ -417,6 +512,32 @@ class _MeshcoreInterface:
|
||||
with self._contacts_lock:
|
||||
return _pubkey_prefix_to_node_id(self._contacts, pubkey_prefix)
|
||||
|
||||
def lookup_node_id_by_name(self, adv_name: str) -> str | None:
|
||||
"""Return the canonical node ID for the contact whose ``adv_name`` matches.
|
||||
|
||||
Used to resolve the sender of a MeshCore channel message from the
|
||||
``"SenderName: body"`` text prefix when no ``pubkey_prefix`` is
|
||||
available in the event payload. The comparison is case-sensitive
|
||||
because ``adv_name`` values come verbatim from the MeshCore firmware.
|
||||
|
||||
Parameters:
|
||||
adv_name: Advertised name to look up. Leading and trailing
|
||||
whitespace is stripped before comparison.
|
||||
|
||||
Returns:
|
||||
Canonical ``!xxxxxxxx`` node ID, or ``None`` when no contact with
|
||||
that name is known.
|
||||
"""
|
||||
name = adv_name.strip() if adv_name else ""
|
||||
if not name:
|
||||
return None
|
||||
with self._contacts_lock:
|
||||
for pub_key, contact in self._contacts.items():
|
||||
contact_name = (contact.get("adv_name") or "").strip()
|
||||
if contact_name == name:
|
||||
return _meshcore_node_id(pub_key)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
@@ -657,11 +778,40 @@ def _make_event_handlers(iface: _MeshcoreInterface, target: str | None) -> dict:
|
||||
rx_time = int(time.time())
|
||||
channel_idx = payload.get("channel_idx", 0)
|
||||
|
||||
# MeshCore channel messages carry no sender identifier in the event
|
||||
# payload. Try to resolve the sender from the "SenderName: body"
|
||||
# convention embedded in the message text, matched against the known
|
||||
# contacts roster. When the contacts roster does not yet contain the
|
||||
# sender, create a synthetic placeholder node so that the message
|
||||
# receives a stable from_id and the UI can render a badge immediately.
|
||||
# The web app will migrate messages to the real node ID once the sender
|
||||
# is seen via a contact advertisement.
|
||||
sender_name = _parse_sender_name(text)
|
||||
from_id = iface.lookup_node_id_by_name(sender_name) if sender_name else None
|
||||
if from_id is None and sender_name:
|
||||
synthetic_id = _derive_synthetic_node_id(sender_name)
|
||||
if synthetic_id not in iface._synthetic_node_ids:
|
||||
_handlers.upsert_node(synthetic_id, _synthetic_node_dict(sender_name))
|
||||
iface._synthetic_node_ids.add(synthetic_id)
|
||||
from_id = synthetic_id
|
||||
|
||||
# Upsert synthetic placeholder nodes for any @[Name] mentions in the
|
||||
# message body whose names are not yet in the contacts roster. This
|
||||
# ensures mention badges resolve even before the mentioned node is seen.
|
||||
for mention_name in _extract_mention_names(text):
|
||||
if not iface.lookup_node_id_by_name(mention_name):
|
||||
mention_id = _derive_synthetic_node_id(mention_name)
|
||||
if mention_id not in iface._synthetic_node_ids:
|
||||
_handlers.upsert_node(
|
||||
mention_id, _synthetic_node_dict(mention_name)
|
||||
)
|
||||
iface._synthetic_node_ids.add(mention_id)
|
||||
|
||||
packet = {
|
||||
"id": _derive_message_id(sender_ts, f"c{channel_idx}", text),
|
||||
"rxTime": rx_time,
|
||||
"rx_time": rx_time,
|
||||
"from_id": None,
|
||||
"from_id": from_id,
|
||||
"to_id": "^all",
|
||||
"channel": channel_idx,
|
||||
"snr": payload.get("SNR"),
|
||||
@@ -679,6 +829,8 @@ def _make_event_handlers(iface: _MeshcoreInterface, target: str | None) -> dict:
|
||||
"MeshCore channel message",
|
||||
context="meshcore.channel_msg",
|
||||
channel=channel_idx,
|
||||
sender=sender_name,
|
||||
from_id=from_id,
|
||||
)
|
||||
|
||||
async def on_contact_msg(evt) -> None:
|
||||
|
||||
+3
-1
@@ -42,9 +42,11 @@ CREATE TABLE IF NOT EXISTS nodes (
|
||||
altitude REAL,
|
||||
lora_freq INTEGER,
|
||||
modem_preset TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'meshtastic'
|
||||
protocol TEXT NOT NULL DEFAULT 'meshtastic',
|
||||
synthetic BOOLEAN NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_last_heard ON nodes(last_heard);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_hw_model ON nodes(hw_model);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_latlon ON nodes(latitude, longitude);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_long_name ON nodes(long_name);
|
||||
|
||||
+380
-13
@@ -40,18 +40,22 @@ from data.mesh_ingestor.protocols.meshcore import ( # noqa: E402 - path setup
|
||||
_contact_to_node_dict,
|
||||
_derive_message_id,
|
||||
_derive_modem_preset,
|
||||
_derive_synthetic_node_id,
|
||||
_ensure_channel_names,
|
||||
_extract_mention_names,
|
||||
_make_connection,
|
||||
_make_event_handlers,
|
||||
_meshcore_adv_type_to_role,
|
||||
_meshcore_node_id,
|
||||
_meshcore_short_name,
|
||||
_parse_sender_name,
|
||||
_process_contact_update,
|
||||
_process_contacts,
|
||||
_process_self_info,
|
||||
_pubkey_prefix_to_node_id,
|
||||
_record_meshcore_message,
|
||||
_self_info_to_node_dict,
|
||||
_synthetic_node_dict,
|
||||
_to_json_safe,
|
||||
)
|
||||
|
||||
@@ -578,6 +582,182 @@ def test_pubkey_prefix_returns_none_for_empty_contacts():
|
||||
assert _pubkey_prefix_to_node_id({}, "aabbccddee11") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_sender_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_sender_name_typical():
|
||||
"""Returns the name portion of 'SenderName: body' text."""
|
||||
assert _parse_sender_name("T114-Zeh: Hello world") == "T114-Zeh"
|
||||
|
||||
|
||||
def test_parse_sender_name_trims_whitespace():
|
||||
"""Leading and trailing whitespace is stripped from the sender name."""
|
||||
assert _parse_sender_name(" Alice : body ") == "Alice"
|
||||
|
||||
|
||||
def test_parse_sender_name_body_may_contain_colons():
|
||||
"""Only the first colon separates sender from body; body colons are kept."""
|
||||
assert _parse_sender_name("BGruenauBot: ack | 80,42,68 (3 hops)") == "BGruenauBot"
|
||||
|
||||
|
||||
def test_parse_sender_name_no_colon_returns_none():
|
||||
"""Returns None when the text contains no colon."""
|
||||
assert _parse_sender_name("no colon here") is None
|
||||
|
||||
|
||||
def test_parse_sender_name_empty_string_returns_none():
|
||||
"""Returns None for an empty string."""
|
||||
assert _parse_sender_name("") is None
|
||||
|
||||
|
||||
def test_parse_sender_name_colon_first_returns_none():
|
||||
"""Returns None when the colon is the first character (empty sender)."""
|
||||
assert _parse_sender_name(":body") is None
|
||||
|
||||
|
||||
def test_parse_sender_name_whitespace_only_before_colon_returns_none():
|
||||
"""Returns None when only whitespace appears before the colon."""
|
||||
assert _parse_sender_name(" : body") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _derive_synthetic_node_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_derive_synthetic_node_id_format():
|
||||
"""Synthetic node ID must start with ! and have eight hex chars."""
|
||||
nid = _derive_synthetic_node_id("Alice")
|
||||
assert nid.startswith("!")
|
||||
assert len(nid) == 9
|
||||
assert all(c in "0123456789abcdef" for c in nid[1:])
|
||||
|
||||
|
||||
def test_derive_synthetic_node_id_deterministic():
|
||||
"""Same long name always produces the same node ID."""
|
||||
assert _derive_synthetic_node_id("Alice") == _derive_synthetic_node_id("Alice")
|
||||
|
||||
|
||||
def test_derive_synthetic_node_id_distinct_names():
|
||||
"""Different long names produce different node IDs."""
|
||||
assert _derive_synthetic_node_id("Alice") != _derive_synthetic_node_id("Bob")
|
||||
|
||||
|
||||
def test_derive_synthetic_node_id_unicode():
|
||||
"""Unicode names produce valid IDs."""
|
||||
nid = _derive_synthetic_node_id("pete 🍁")
|
||||
assert nid.startswith("!")
|
||||
assert len(nid) == 9
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _synthetic_node_dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_synthetic_node_dict_fields():
|
||||
"""_synthetic_node_dict returns a node dict with correct user fields."""
|
||||
nd = _synthetic_node_dict("T114-Zeh")
|
||||
assert nd["protocol"] == "meshcore"
|
||||
assert nd["user"]["longName"] == "T114-Zeh"
|
||||
assert nd["user"]["role"] == "COMPANION"
|
||||
assert nd["user"]["synthetic"] is True
|
||||
assert isinstance(nd["lastHeard"], int)
|
||||
|
||||
|
||||
def test_synthetic_node_dict_short_name_empty():
|
||||
"""Short name is always empty — the Ruby web app derives it at query time."""
|
||||
nd = _synthetic_node_dict("pete 🍁")
|
||||
assert nd["user"]["shortName"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_mention_names
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_mention_names_single():
|
||||
"""Extracts one mention name."""
|
||||
assert _extract_mention_names("Hey @[Alice]!") == ["Alice"]
|
||||
|
||||
|
||||
def test_extract_mention_names_multiple():
|
||||
"""Extracts multiple mention names in order."""
|
||||
assert _extract_mention_names("@[Alpha] and @[Beta]") == ["Alpha", "Beta"]
|
||||
|
||||
|
||||
def test_extract_mention_names_none():
|
||||
"""Returns empty list when no mentions are present."""
|
||||
assert _extract_mention_names("no mentions here") == []
|
||||
|
||||
|
||||
def test_extract_mention_names_preserves_spaces():
|
||||
"""Names with spaces inside brackets are preserved."""
|
||||
assert _extract_mention_names("Hi @[MaLiBu'2 Britz-Sued]") == [
|
||||
"MaLiBu'2 Britz-Sued"
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _MeshcoreInterface.lookup_node_id_by_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_lookup_node_id_by_name_finds_exact_match():
|
||||
"""Returns the node ID when a contact with the given adv_name exists."""
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
pub_key = "aabbccdd" + "00" * 28
|
||||
iface._update_contact({"public_key": pub_key, "adv_name": "Alice"})
|
||||
assert iface.lookup_node_id_by_name("Alice") == "!aabbccdd"
|
||||
|
||||
|
||||
def test_lookup_node_id_by_name_trims_query():
|
||||
"""Strips whitespace from the query before comparing."""
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
pub_key = "aabbccdd" + "00" * 28
|
||||
iface._update_contact({"public_key": pub_key, "adv_name": "Alice"})
|
||||
assert iface.lookup_node_id_by_name(" Alice ") == "!aabbccdd"
|
||||
|
||||
|
||||
def test_lookup_node_id_by_name_case_sensitive_mismatch():
|
||||
"""Returns None for a case-insensitive match — comparison is case-sensitive."""
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
pub_key = "aabbccdd" + "00" * 28
|
||||
iface._update_contact({"public_key": pub_key, "adv_name": "Alice"})
|
||||
assert iface.lookup_node_id_by_name("alice") is None
|
||||
|
||||
|
||||
def test_lookup_node_id_by_name_no_contacts():
|
||||
"""Returns None when no contacts are registered."""
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
assert iface.lookup_node_id_by_name("Alice") is None
|
||||
|
||||
|
||||
def test_lookup_node_id_by_name_empty_string():
|
||||
"""Returns None for an empty name query."""
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
pub_key = "aabbccdd" + "00" * 28
|
||||
iface._update_contact({"public_key": pub_key, "adv_name": "Alice"})
|
||||
assert iface.lookup_node_id_by_name("") is None
|
||||
|
||||
|
||||
def test_lookup_node_id_by_name_none_query():
|
||||
"""Returns None when adv_name argument is None."""
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
assert iface.lookup_node_id_by_name(None) is None
|
||||
|
||||
|
||||
def test_lookup_node_id_by_name_multiple_contacts():
|
||||
"""Returns the correct node ID when multiple contacts are registered."""
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
iface._update_contact({"public_key": "11111111" + "00" * 28, "adv_name": "Alpha"})
|
||||
iface._update_contact({"public_key": "22222222" + "00" * 28, "adv_name": "Beta"})
|
||||
assert iface.lookup_node_id_by_name("Alpha") == "!11111111"
|
||||
assert iface.lookup_node_id_by_name("Beta") == "!22222222"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _meshcore_adv_type_to_role
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -829,26 +1009,56 @@ def _make_stub_handlers_module():
|
||||
return mod
|
||||
|
||||
|
||||
def test_on_channel_msg_queues_packet(monkeypatch):
|
||||
"""on_channel_msg must call store_packet_dict with the correct packet fields."""
|
||||
import asyncio
|
||||
class _FakeEvt:
|
||||
"""Minimal stand-in for a MeshCore SDK event object used in handler tests."""
|
||||
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
|
||||
def _setup_channel_msg_handlers(monkeypatch, *, contacts=None):
|
||||
"""Set up the patched handler environment for ``CHANNEL_MSG_RECV`` tests.
|
||||
|
||||
Patches the debug logger and the ``handlers`` module reference so that
|
||||
:func:`_make_event_handlers` can be called without a real connection.
|
||||
|
||||
Parameters:
|
||||
monkeypatch: pytest monkeypatch fixture.
|
||||
contacts: Optional list of contact dicts to pre-register on the
|
||||
returned interface, e.g. ``[{"public_key": "aabb…", "adv_name": "Alice"}]``.
|
||||
|
||||
Returns:
|
||||
Tuple of ``(captured, upserted, iface, hmap)`` where *captured* is the
|
||||
list of packets passed to ``store_packet_dict``, *upserted* is the list
|
||||
of ``(node_id, node_dict)`` pairs passed to ``upsert_node``, *iface* is
|
||||
the :class:`_MeshcoreInterface` instance, and *hmap* is the event
|
||||
handler map returned by :func:`_make_event_handlers`.
|
||||
"""
|
||||
import data.mesh_ingestor as _mesh_pkg
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
|
||||
captured: list = []
|
||||
upserted: list = []
|
||||
stub = _make_stub_handlers_module()
|
||||
stub.store_packet_dict = lambda pkt: captured.append(pkt)
|
||||
stub.upsert_node = lambda node_id, node_dict: upserted.append((node_id, node_dict))
|
||||
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
||||
# _make_event_handlers does `from .. import handlers`; patch the package attr
|
||||
# so the deferred import resolves to our stub without touching sys.modules.
|
||||
monkeypatch.setattr(_mesh_pkg, "handlers", stub)
|
||||
|
||||
class _FakeEvt:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
for contact in contacts or []:
|
||||
iface._update_contact(contact)
|
||||
hmap = _make_event_handlers(iface, "/dev/ttyUSB0")
|
||||
return captured, upserted, iface, hmap
|
||||
|
||||
|
||||
def test_on_channel_msg_queues_packet(monkeypatch):
|
||||
"""on_channel_msg must call store_packet_dict with the correct packet fields."""
|
||||
import asyncio
|
||||
|
||||
# _make_event_handlers does `from .. import handlers`; _setup_channel_msg_handlers
|
||||
# patches the package attribute so the deferred import resolves to a stub.
|
||||
captured, _upserted, _iface, hmap = _setup_channel_msg_handlers(monkeypatch)
|
||||
asyncio.run(
|
||||
hmap["CHANNEL_MSG_RECV"](
|
||||
_FakeEvt(
|
||||
@@ -868,6 +1078,7 @@ def test_on_channel_msg_queues_packet(monkeypatch):
|
||||
assert pkt["decoded"]["text"] == "hello mesh"
|
||||
assert pkt["channel"] == 2
|
||||
assert pkt["to_id"] == "^all"
|
||||
# Text has no "SenderName:" prefix so from_id cannot be resolved.
|
||||
assert pkt["from_id"] is None
|
||||
assert pkt["snr"] == 5
|
||||
assert pkt["rssi"] == -80
|
||||
@@ -875,6 +1086,166 @@ def test_on_channel_msg_queues_packet(monkeypatch):
|
||||
assert pkt["id"] == _derive_message_id(1_758_000_000, "c2", "hello mesh")
|
||||
|
||||
|
||||
def test_on_channel_msg_resolves_from_id_via_sender_name(monkeypatch):
|
||||
"""on_channel_msg sets from_id when sender name matches a known contact."""
|
||||
import asyncio
|
||||
|
||||
pub_key = "aabbccdd" + "00" * 28
|
||||
captured, _upserted, _iface, hmap = _setup_channel_msg_handlers(
|
||||
monkeypatch,
|
||||
contacts=[{"public_key": pub_key, "adv_name": "T114-Zeh"}],
|
||||
)
|
||||
asyncio.run(
|
||||
hmap["CHANNEL_MSG_RECV"](
|
||||
_FakeEvt(
|
||||
{
|
||||
"sender_timestamp": 1_758_000_002,
|
||||
"text": "T114-Zeh: Test message",
|
||||
"channel_idx": 0,
|
||||
"SNR": 7,
|
||||
"RSSI": -70,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
pkt = captured[0]
|
||||
assert pkt["decoded"]["text"] == "T114-Zeh: Test message"
|
||||
assert pkt["to_id"] == "^all"
|
||||
# Sender resolved from contacts via name prefix — no synthetic upsert needed.
|
||||
assert pkt["from_id"] == "!aabbccdd"
|
||||
|
||||
|
||||
def test_on_channel_msg_creates_synthetic_node_when_sender_not_in_contacts(monkeypatch):
|
||||
"""on_channel_msg upserts a synthetic node and sets from_id when sender is unknown."""
|
||||
import asyncio
|
||||
from data.mesh_ingestor.protocols.meshcore import _derive_synthetic_node_id
|
||||
|
||||
captured, upserted, _iface, hmap = _setup_channel_msg_handlers(monkeypatch)
|
||||
asyncio.run(
|
||||
hmap["CHANNEL_MSG_RECV"](
|
||||
_FakeEvt(
|
||||
{
|
||||
"sender_timestamp": 1_758_000_003,
|
||||
"text": "UnknownSender: Hello",
|
||||
"channel_idx": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
expected_id = _derive_synthetic_node_id("UnknownSender")
|
||||
assert captured[0]["from_id"] == expected_id
|
||||
# A synthetic node should have been upserted for the sender.
|
||||
synth_upserts = [(nid, nd) for nid, nd in upserted if nid == expected_id]
|
||||
assert len(synth_upserts) == 1
|
||||
synth_node = synth_upserts[0][1]
|
||||
assert synth_node["user"]["longName"] == "UnknownSender"
|
||||
assert synth_node["user"]["role"] == "COMPANION"
|
||||
assert synth_node["user"]["synthetic"] is True
|
||||
|
||||
|
||||
def test_on_channel_msg_synthetic_upserted_only_once_per_session(monkeypatch):
|
||||
"""on_channel_msg only calls upsert_node once per unique synthetic ID per session."""
|
||||
import asyncio
|
||||
from data.mesh_ingestor.protocols.meshcore import _derive_synthetic_node_id
|
||||
|
||||
captured, upserted, _iface, hmap = _setup_channel_msg_handlers(monkeypatch)
|
||||
payload = {
|
||||
"sender_timestamp": 1_758_000_010,
|
||||
"text": "UnknownSender: First",
|
||||
"channel_idx": 0,
|
||||
}
|
||||
asyncio.run(hmap["CHANNEL_MSG_RECV"](_FakeEvt(payload)))
|
||||
payload2 = {
|
||||
"sender_timestamp": 1_758_000_011,
|
||||
"text": "UnknownSender: Second",
|
||||
"channel_idx": 0,
|
||||
}
|
||||
asyncio.run(hmap["CHANNEL_MSG_RECV"](_FakeEvt(payload2)))
|
||||
|
||||
expected_id = _derive_synthetic_node_id("UnknownSender")
|
||||
sender_upserts = [nid for nid, _ in upserted if nid == expected_id]
|
||||
# Second message must NOT re-upsert the same synthetic node.
|
||||
assert len(sender_upserts) == 1
|
||||
|
||||
|
||||
def test_on_channel_msg_no_synthetic_when_no_sender_prefix(monkeypatch):
|
||||
"""on_channel_msg leaves from_id None when text has no SenderName: prefix."""
|
||||
import asyncio
|
||||
|
||||
captured, upserted, _iface, hmap = _setup_channel_msg_handlers(monkeypatch)
|
||||
asyncio.run(
|
||||
hmap["CHANNEL_MSG_RECV"](
|
||||
_FakeEvt(
|
||||
{
|
||||
"sender_timestamp": 1_758_000_004,
|
||||
"text": "no colon here",
|
||||
"channel_idx": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0]["from_id"] is None
|
||||
assert upserted == []
|
||||
|
||||
|
||||
def test_on_channel_msg_upserts_synthetic_for_unknown_mention(monkeypatch):
|
||||
"""on_channel_msg upserts synthetic nodes for @[Name] mentions not in contacts."""
|
||||
import asyncio
|
||||
from data.mesh_ingestor.protocols.meshcore import _derive_synthetic_node_id
|
||||
|
||||
captured, upserted, _iface, hmap = _setup_channel_msg_handlers(monkeypatch)
|
||||
asyncio.run(
|
||||
hmap["CHANNEL_MSG_RECV"](
|
||||
_FakeEvt(
|
||||
{
|
||||
"sender_timestamp": 1_758_000_005,
|
||||
"text": "Alice: Hey @[Bob] and @[Carol]",
|
||||
"channel_idx": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
upserted_ids = {nid for nid, _ in upserted}
|
||||
assert _derive_synthetic_node_id("Bob") in upserted_ids
|
||||
assert _derive_synthetic_node_id("Carol") in upserted_ids
|
||||
|
||||
|
||||
def test_on_channel_msg_skips_synthetic_for_known_mention(monkeypatch):
|
||||
"""on_channel_msg does not upsert synthetic for @[Name] if name is in contacts."""
|
||||
import asyncio
|
||||
from data.mesh_ingestor.protocols.meshcore import _derive_synthetic_node_id
|
||||
|
||||
pub_key = "aabbccdd" + "00" * 28
|
||||
captured, upserted, _iface, hmap = _setup_channel_msg_handlers(
|
||||
monkeypatch,
|
||||
contacts=[{"public_key": pub_key, "adv_name": "Bob"}],
|
||||
)
|
||||
asyncio.run(
|
||||
hmap["CHANNEL_MSG_RECV"](
|
||||
_FakeEvt(
|
||||
{
|
||||
"sender_timestamp": 1_758_000_006,
|
||||
"text": "Alice: Hey @[Bob]",
|
||||
"channel_idx": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Bob is in contacts — no synthetic upsert for Bob.
|
||||
bob_upserts = [
|
||||
nid for nid, _ in upserted if nid == _derive_synthetic_node_id("Bob")
|
||||
]
|
||||
assert bob_upserts == []
|
||||
|
||||
|
||||
def test_on_contact_msg_queues_packet_with_from_id(monkeypatch):
|
||||
"""on_contact_msg must resolve from_id via pubkey_prefix and set to_id to host."""
|
||||
import asyncio
|
||||
@@ -892,10 +1263,6 @@ def test_on_contact_msg_queues_packet_with_from_id(monkeypatch):
|
||||
iface.host_node_id = "!deadbeef"
|
||||
iface._update_contact({"public_key": pub_key, "adv_name": "Alice"})
|
||||
|
||||
class _FakeEvt:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
hmap = _make_event_handlers(iface, "/dev/ttyUSB0")
|
||||
asyncio.run(
|
||||
hmap["CONTACT_MSG_RECV"](
|
||||
|
||||
@@ -373,12 +373,16 @@ module PotatoMesh
|
||||
|
||||
lora_freq = coerce_integer(n["lora_freq"] || n["loraFrequency"])
|
||||
modem_preset = string_or_nil(n["modem_preset"] || n["modemPreset"])
|
||||
# Synthetic flag: true for placeholder nodes created from channel message
|
||||
# sender names before the real contact advertisement is received.
|
||||
synthetic = user["synthetic"] ? 1 : 0
|
||||
long_name = user["longName"]
|
||||
|
||||
# If the incoming long name is a generic placeholder, prefer any real
|
||||
# name already on record so we never stomp known data with fallback
|
||||
# text. For new nodes there is nothing to preserve, so the generic
|
||||
# name is still written via the INSERT VALUES path.
|
||||
long_name_conflict_sql = if generic_fallback_name?(user["longName"], node_id, protocol)
|
||||
long_name_conflict_sql = if generic_fallback_name?(long_name, node_id, protocol)
|
||||
# Generic placeholder: keep any real name already on record.
|
||||
# COALESCE returns nodes.long_name when non-null, otherwise falls
|
||||
# back to the incoming generic — so brand-new nodes still get it.
|
||||
@@ -395,7 +399,7 @@ module PotatoMesh
|
||||
node_id,
|
||||
node_num,
|
||||
user["shortName"],
|
||||
user["longName"],
|
||||
long_name,
|
||||
user["macaddr"],
|
||||
user["hwModel"] || n["hwModel"],
|
||||
role,
|
||||
@@ -424,36 +428,82 @@ module PotatoMesh
|
||||
lora_freq,
|
||||
modem_preset,
|
||||
protocol,
|
||||
synthetic,
|
||||
]
|
||||
with_busy_retry do
|
||||
db.execute(<<~SQL, row)
|
||||
INSERT INTO nodes(node_id,num,short_name,long_name,macaddr,hw_model,role,public_key,is_unmessagable,is_favorite,
|
||||
hops_away,snr,last_heard,first_heard,battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds,
|
||||
position_time,location_source,precision_bits,latitude,longitude,altitude,lora_freq,modem_preset,protocol)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
num=COALESCE(excluded.num, nodes.num),
|
||||
short_name=COALESCE(excluded.short_name, nodes.short_name),
|
||||
long_name=#{long_name_conflict_sql},
|
||||
macaddr=COALESCE(excluded.macaddr, nodes.macaddr),
|
||||
hw_model=COALESCE(excluded.hw_model, nodes.hw_model),
|
||||
role=COALESCE(excluded.role, nodes.role),
|
||||
public_key=COALESCE(excluded.public_key, nodes.public_key),
|
||||
is_unmessagable=COALESCE(excluded.is_unmessagable, nodes.is_unmessagable),
|
||||
is_favorite=excluded.is_favorite, hops_away=excluded.hops_away, snr=excluded.snr, last_heard=excluded.last_heard,
|
||||
first_heard=COALESCE(nodes.first_heard, excluded.first_heard, excluded.last_heard),
|
||||
battery_level=excluded.battery_level, voltage=excluded.voltage, channel_utilization=excluded.channel_utilization,
|
||||
air_util_tx=excluded.air_util_tx, uptime_seconds=excluded.uptime_seconds,
|
||||
position_time=COALESCE(excluded.position_time, nodes.position_time),
|
||||
location_source=COALESCE(excluded.location_source, nodes.location_source),
|
||||
precision_bits=COALESCE(excluded.precision_bits, nodes.precision_bits),
|
||||
latitude=COALESCE(excluded.latitude, nodes.latitude),
|
||||
longitude=COALESCE(excluded.longitude, nodes.longitude),
|
||||
altitude=COALESCE(excluded.altitude, nodes.altitude),
|
||||
lora_freq=excluded.lora_freq, modem_preset=excluded.modem_preset,
|
||||
protocol=COALESCE(NULLIF(nodes.protocol,'meshtastic'), excluded.protocol)
|
||||
WHERE COALESCE(excluded.last_heard,0) >= COALESCE(nodes.last_heard,0)
|
||||
SQL
|
||||
db.transaction do
|
||||
db.execute(<<~SQL, row)
|
||||
INSERT INTO nodes(node_id,num,short_name,long_name,macaddr,hw_model,role,public_key,is_unmessagable,is_favorite,
|
||||
hops_away,snr,last_heard,first_heard,battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds,
|
||||
position_time,location_source,precision_bits,latitude,longitude,altitude,lora_freq,modem_preset,protocol,synthetic)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
num=COALESCE(excluded.num, nodes.num),
|
||||
short_name=COALESCE(excluded.short_name, nodes.short_name),
|
||||
long_name=#{long_name_conflict_sql},
|
||||
macaddr=COALESCE(excluded.macaddr, nodes.macaddr),
|
||||
hw_model=COALESCE(excluded.hw_model, nodes.hw_model),
|
||||
role=COALESCE(excluded.role, nodes.role),
|
||||
public_key=COALESCE(excluded.public_key, nodes.public_key),
|
||||
is_unmessagable=COALESCE(excluded.is_unmessagable, nodes.is_unmessagable),
|
||||
is_favorite=excluded.is_favorite, hops_away=excluded.hops_away, snr=excluded.snr, last_heard=excluded.last_heard,
|
||||
first_heard=COALESCE(nodes.first_heard, excluded.first_heard, excluded.last_heard),
|
||||
battery_level=excluded.battery_level, voltage=excluded.voltage, channel_utilization=excluded.channel_utilization,
|
||||
air_util_tx=excluded.air_util_tx, uptime_seconds=excluded.uptime_seconds,
|
||||
position_time=COALESCE(excluded.position_time, nodes.position_time),
|
||||
location_source=COALESCE(excluded.location_source, nodes.location_source),
|
||||
precision_bits=COALESCE(excluded.precision_bits, nodes.precision_bits),
|
||||
latitude=COALESCE(excluded.latitude, nodes.latitude),
|
||||
longitude=COALESCE(excluded.longitude, nodes.longitude),
|
||||
altitude=COALESCE(excluded.altitude, nodes.altitude),
|
||||
lora_freq=excluded.lora_freq, modem_preset=excluded.modem_preset,
|
||||
protocol=COALESCE(NULLIF(nodes.protocol,'meshtastic'), excluded.protocol),
|
||||
synthetic=MIN(COALESCE(excluded.synthetic,1), COALESCE(nodes.synthetic,1))
|
||||
WHERE COALESCE(excluded.last_heard,0) >= COALESCE(nodes.last_heard,0)
|
||||
AND NOT (COALESCE(nodes.synthetic,0) = 0 AND excluded.synthetic = 1)
|
||||
SQL
|
||||
|
||||
# When a real (non-synthetic) node is upserted with a known long
|
||||
# name, migrate any synthetic placeholder rows that share that name.
|
||||
# This fires when the MeshCore device finally receives the sender's
|
||||
# contact advertisement, resolving the placeholder to a real node ID.
|
||||
if synthetic == 0 && long_name && !long_name.empty?
|
||||
merge_synthetic_nodes(db, node_id, long_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Migrate messages from synthetic placeholder nodes to a newly confirmed
|
||||
# real node, then remove the placeholders.
|
||||
#
|
||||
# Called inside a transaction from +upsert_node+ when a real (non-synthetic)
|
||||
# MeshCore node with the same +long_name+ is upserted.
|
||||
#
|
||||
# Only +messages.from_id+ is migrated. Synthetic nodes are placeholders
|
||||
# created solely from parsed channel message sender names, so they cannot
|
||||
# have associated positions, telemetry, neighbors, or traces — those tables
|
||||
# are intentionally left untouched.
|
||||
#
|
||||
# @param db [SQLite3::Database] open database connection.
|
||||
# @param real_node_id [String] canonical node ID for the real contact.
|
||||
# @param long_name [String] long name to match against synthetic rows.
|
||||
# @return [void]
|
||||
def merge_synthetic_nodes(db, real_node_id, long_name)
|
||||
synthetic_ids = db.execute(
|
||||
"SELECT node_id FROM nodes WHERE long_name = ? AND synthetic = 1 AND protocol = 'meshcore' AND node_id != ?",
|
||||
[long_name, real_node_id],
|
||||
).map { |row| row[0] }
|
||||
|
||||
synthetic_ids.each do |synthetic_id|
|
||||
db.execute(
|
||||
"UPDATE messages SET from_id = ? WHERE from_id = ?",
|
||||
[real_node_id, synthetic_id],
|
||||
)
|
||||
db.execute(
|
||||
"DELETE FROM nodes WHERE node_id = ? AND synthetic = 1",
|
||||
[synthetic_id],
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -136,6 +136,17 @@ module PotatoMesh
|
||||
db.execute("ALTER TABLE nodes ADD COLUMN protocol TEXT NOT NULL DEFAULT 'meshtastic'")
|
||||
db.execute("UPDATE nodes SET protocol = 'meshtastic' WHERE protocol IS NULL OR TRIM(protocol) = ''")
|
||||
end
|
||||
|
||||
unless node_columns.include?("synthetic")
|
||||
db.execute("ALTER TABLE nodes ADD COLUMN synthetic BOOLEAN NOT NULL DEFAULT 0")
|
||||
end
|
||||
|
||||
if node_columns.include?("long_name")
|
||||
existing_indexes = db.execute("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='nodes'").flatten
|
||||
unless existing_indexes.include?("idx_nodes_long_name")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_nodes_long_name ON nodes(long_name)")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
message_table_exists = db.get_first_value(
|
||||
|
||||
@@ -81,7 +81,9 @@ module PotatoMesh
|
||||
# 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).
|
||||
# 4-column display slot: ``" E "`` (one leading space, emoji, one trailing
|
||||
# space). Emoji are rendered double-width in monospace fonts, so one leading
|
||||
# space keeps the badge at four visual columns.
|
||||
# 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:
|
||||
@@ -96,7 +98,9 @@ module PotatoMesh
|
||||
return nil unless name
|
||||
|
||||
emoji = name.scan(MESHCORE_COMPANION_EMOJI_PATTERN).first
|
||||
return " #{emoji} " if emoji
|
||||
# Wide emoji occupies two display columns, so use one leading space and
|
||||
# one trailing space to stay within the four-column badge width.
|
||||
return " #{emoji} " if emoji
|
||||
|
||||
words = name.strip.split(/\s+/).reject(&:empty?)
|
||||
return nil if words.empty?
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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 { createDomEnvironment } from './dom-environment.js';
|
||||
import { initializeApp } from '../main.js';
|
||||
|
||||
/**
|
||||
* Minimal {@link initializeApp} configuration shared across main.js test suites.
|
||||
* Frozen to prevent accidental mutation between tests.
|
||||
*/
|
||||
export 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 {@link initializeApp} with a stub
|
||||
* config, and return the inner test utilities alongside a cleanup handle.
|
||||
*
|
||||
* @returns {{ testUtils: Object, cleanup: Function }}
|
||||
*/
|
||||
export 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) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test body with a fresh app instance, ensuring cleanup regardless of
|
||||
* outcome. Eliminates the repetitive try/finally boilerplate across tests.
|
||||
*
|
||||
* @param {function(Object): void} fn Receives the _testUtils object.
|
||||
*/
|
||||
export function withApp(fn) {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
fn(testUtils);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the serialised HTML string from a DOM element returned by the test
|
||||
* utils. The stub environment exposes innerHTML as a plain string; this
|
||||
* normalises the fallback path for environments where it may not be.
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
* @returns {string}
|
||||
*/
|
||||
export function innerHtml(el) {
|
||||
return String(typeof el.innerHTML === 'string' ? el.innerHTML : el.childNodes?.[0] ?? '');
|
||||
}
|
||||
@@ -17,49 +17,7 @@
|
||||
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 app and return test utilities with a cleanup handle.
|
||||
*
|
||||
* @returns {{ testUtils: Object, cleanup: Function }}
|
||||
*/
|
||||
function setupApp() {
|
||||
const env = createDomEnvironment({ includeBody: true });
|
||||
env.createElement('button', 'themeToggle');
|
||||
const { _testUtils } = initializeApp(MINIMAL_CONFIG);
|
||||
return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test body with a fresh app instance, ensuring cleanup regardless of outcome.
|
||||
*
|
||||
* @param {function(Object): void} fn Receives the _testUtils object.
|
||||
*/
|
||||
function withApp(fn) {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
fn(testUtils);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
import { withApp } from './main-app-test-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// makeRoleFilterKey
|
||||
|
||||
@@ -17,64 +17,7 @@
|
||||
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) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test body with a fresh app instance, ensuring cleanup regardless of
|
||||
* outcome. Eliminates the repetitive try/finally boilerplate across tests.
|
||||
*
|
||||
* @param {function(Object): void} fn Receives the _testUtils object.
|
||||
*/
|
||||
function withApp(fn) {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
fn(testUtils);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the serialised HTML string from a DOM element returned by the test
|
||||
* utils. The stub environment exposes innerHTML as a plain string; this
|
||||
* normalises the fallback path for environments where it may not be.
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
* @returns {string}
|
||||
*/
|
||||
function innerHtml(el) {
|
||||
return String(typeof el.innerHTML === 'string' ? el.innerHTML : el.childNodes?.[0] ?? '');
|
||||
}
|
||||
import { withApp, innerHtml } from './main-app-test-helpers.js';
|
||||
|
||||
// --- buildDisplayContext ---
|
||||
|
||||
@@ -280,3 +223,126 @@ test('createMessageChatEntry shows meshcore icon for meshcore node', () => {
|
||||
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 — fallback', () => {
|
||||
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 as escaped plain text
|
||||
assert.ok(html.includes('@[Ghost]'), 'unresolved mention should render as escaped @[Name] text');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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 {
|
||||
parseMeshcoreSenderPrefix,
|
||||
findNodeByLongName,
|
||||
} from '../meshcore-chat-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseMeshcoreSenderPrefix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('parseMeshcoreSenderPrefix: typical message', () => {
|
||||
const result = parseMeshcoreSenderPrefix('T114-Zeh: Hello world');
|
||||
assert.deepEqual(result, { senderName: 'T114-Zeh', bodyText: 'Hello world' });
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: trims whitespace around sender and body', () => {
|
||||
const result = parseMeshcoreSenderPrefix(' Alice : body text ');
|
||||
assert.deepEqual(result, { senderName: 'Alice', bodyText: 'body text' });
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: empty body after colon', () => {
|
||||
const result = parseMeshcoreSenderPrefix('Sender:');
|
||||
assert.deepEqual(result, { senderName: 'Sender', bodyText: '' });
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: no colon returns null', () => {
|
||||
assert.equal(parseMeshcoreSenderPrefix('No colon here'), null);
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: empty string returns null', () => {
|
||||
assert.equal(parseMeshcoreSenderPrefix(''), null);
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: null input returns null', () => {
|
||||
assert.equal(parseMeshcoreSenderPrefix(null), null);
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: undefined input returns null', () => {
|
||||
assert.equal(parseMeshcoreSenderPrefix(undefined), null);
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: non-string input returns null', () => {
|
||||
assert.equal(parseMeshcoreSenderPrefix(42), null);
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: colon first (empty sender) returns null', () => {
|
||||
assert.equal(parseMeshcoreSenderPrefix(':body'), null);
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: whitespace-only sender returns null', () => {
|
||||
assert.equal(parseMeshcoreSenderPrefix(' : body'), null);
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: only first colon separates sender from body', () => {
|
||||
const result = parseMeshcoreSenderPrefix('A:B:C');
|
||||
assert.deepEqual(result, { senderName: 'A', bodyText: 'B:C' });
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: colons in body are preserved intact', () => {
|
||||
const result = parseMeshcoreSenderPrefix('BGruenauBot/OBS+: ack @[T114-Zeh] | 80,42,68 (3 hops) | 62.6km');
|
||||
assert.deepEqual(result, {
|
||||
senderName: 'BGruenauBot/OBS+',
|
||||
bodyText: 'ack @[T114-Zeh] | 80,42,68 (3 hops) | 62.6km',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMeshcoreSenderPrefix: sender with slash and plus preserved', () => {
|
||||
const result = parseMeshcoreSenderPrefix('mEDI | Linux: Pong! T114-Zeh');
|
||||
assert.deepEqual(result, { senderName: 'mEDI | Linux', bodyText: 'Pong! T114-Zeh' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findNodeByLongName
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shared single-entry map used across the findNodeByLongName tests below. */
|
||||
function makeAliceMap(nodeOverride = {}) {
|
||||
const node = { node_id: '!aabbccdd', long_name: 'Alice', ...nodeOverride };
|
||||
return { node, map: new Map([['!aabbccdd', node]]) };
|
||||
}
|
||||
|
||||
test('findNodeByLongName: exact match on snake_case long_name', () => {
|
||||
const { node, map } = makeAliceMap();
|
||||
assert.equal(findNodeByLongName('Alice', map), node);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: exact match on camelCase longName', () => {
|
||||
const node = { node_id: '!aabbccdd', longName: 'Alice', role: 'CLIENT' };
|
||||
const map = new Map([['!aabbccdd', node]]);
|
||||
assert.equal(findNodeByLongName('Alice', map), node);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: no match returns null', () => {
|
||||
const { map } = makeAliceMap();
|
||||
assert.equal(findNodeByLongName('Unknown', map), null);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: null longName returns null', () => {
|
||||
const { map } = makeAliceMap();
|
||||
assert.equal(findNodeByLongName(null, map), null);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: undefined longName returns null', () => {
|
||||
const { map } = makeAliceMap();
|
||||
assert.equal(findNodeByLongName(undefined, map), null);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: empty string longName returns null', () => {
|
||||
const { map } = makeAliceMap();
|
||||
assert.equal(findNodeByLongName('', map), null);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: non-Map nodesById returns null', () => {
|
||||
assert.equal(findNodeByLongName('Alice', {}), null);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: array nodesById returns null', () => {
|
||||
assert.equal(findNodeByLongName('Alice', []), null);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: empty Map returns null', () => {
|
||||
assert.equal(findNodeByLongName('Alice', new Map()), null);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: case-sensitive — lowercase mismatch returns null', () => {
|
||||
const { map } = makeAliceMap();
|
||||
assert.equal(findNodeByLongName('alice', map), null);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: multiple nodes — returns correct one', () => {
|
||||
const nodeA = { node_id: '!11111111', long_name: 'Alpha' };
|
||||
const nodeB = { node_id: '!22222222', long_name: 'Beta' };
|
||||
const map = new Map([['!11111111', nodeA], ['!22222222', nodeB]]);
|
||||
assert.equal(findNodeByLongName('Beta', map), nodeB);
|
||||
assert.equal(findNodeByLongName('Alpha', map), nodeA);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: prefers snake_case when both properties exist', () => {
|
||||
const node = { node_id: '!aabbccdd', long_name: 'Alice', longName: 'Different' };
|
||||
const map = new Map([['!aabbccdd', node]]);
|
||||
// long_name takes precedence via the ?? chain; should match 'Alice'
|
||||
assert.equal(findNodeByLongName('Alice', map), node);
|
||||
assert.equal(findNodeByLongName('Different', map), null);
|
||||
});
|
||||
|
||||
test('findNodeByLongName: node with null long_name is skipped', () => {
|
||||
const node = { node_id: '!aabbccdd', long_name: null };
|
||||
const map = new Map([['!aabbccdd', node]]);
|
||||
assert.equal(findNodeByLongName('Alice', map), null);
|
||||
});
|
||||
@@ -151,3 +151,131 @@ test('buildMessageBody appends reaction counts for REACTION_APP packets without
|
||||
|
||||
assert.equal(body, 'EMOJI(🌶) ESC(×2)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildMessageBody — renderMentionHtml callback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Shared mock helpers reused across the mention-callback tests below.
|
||||
const esc = v => `ESC(${v})`;
|
||||
const emoji = v => `EMOJI(${v})`;
|
||||
const badge = name => `BADGE(${name})`;
|
||||
|
||||
test('buildMessageBody throws TypeError when renderMentionHtml is not a function', () => {
|
||||
assert.throws(
|
||||
() => buildMessageBody({
|
||||
message: { text: 'hello' },
|
||||
escapeHtml: v => v,
|
||||
renderEmojiHtml: v => v,
|
||||
renderMentionHtml: 42,
|
||||
}),
|
||||
{ name: 'TypeError', message: 'renderMentionHtml must be a function when provided' }
|
||||
);
|
||||
});
|
||||
|
||||
test('buildMessageBody without renderMentionHtml escapes @[Name] literally', () => {
|
||||
const body = buildMessageBody({
|
||||
message: { text: 'hello @[Alice]' },
|
||||
escapeHtml: esc,
|
||||
renderEmojiHtml: emoji,
|
||||
});
|
||||
assert.equal(body, 'ESC(hello @[Alice])');
|
||||
});
|
||||
|
||||
test('buildMessageBody with renderMentionHtml replaces single @[Name] mention', () => {
|
||||
const body = buildMessageBody({
|
||||
message: { text: 'hi @[Alice] there' },
|
||||
escapeHtml: esc,
|
||||
renderEmojiHtml: emoji,
|
||||
renderMentionHtml: badge,
|
||||
});
|
||||
assert.equal(body, 'ESC(hi )BADGE(Alice)ESC( there)');
|
||||
});
|
||||
|
||||
test('buildMessageBody with renderMentionHtml handles multiple mentions', () => {
|
||||
const calls = [];
|
||||
const body = buildMessageBody({
|
||||
message: { text: '@[A] and @[B]' },
|
||||
escapeHtml: esc,
|
||||
renderEmojiHtml: emoji,
|
||||
renderMentionHtml: (name) => { calls.push(name); return `BADGE(${name})`; },
|
||||
});
|
||||
assert.deepEqual(calls, ['A', 'B']);
|
||||
assert.equal(body, 'BADGE(A)ESC( and )BADGE(B)');
|
||||
});
|
||||
|
||||
test('buildMessageBody with renderMentionHtml escapes literal segments', () => {
|
||||
const body = buildMessageBody({
|
||||
message: { text: '<b> @[Alice]' },
|
||||
escapeHtml: v => v.replace(/</g, '<').replace(/>/g, '>'),
|
||||
renderEmojiHtml: emoji,
|
||||
renderMentionHtml: badge,
|
||||
});
|
||||
assert.equal(body, '<b> BADGE(Alice)');
|
||||
});
|
||||
|
||||
test('buildMessageBody with renderMentionHtml at start of text', () => {
|
||||
const body = buildMessageBody({
|
||||
message: { text: '@[Alice] hello' },
|
||||
escapeHtml: esc,
|
||||
renderEmojiHtml: emoji,
|
||||
renderMentionHtml: badge,
|
||||
});
|
||||
assert.equal(body, 'BADGE(Alice)ESC( hello)');
|
||||
});
|
||||
|
||||
test('buildMessageBody with renderMentionHtml at end of text', () => {
|
||||
const body = buildMessageBody({
|
||||
message: { text: 'hello @[Alice]' },
|
||||
escapeHtml: esc,
|
||||
renderEmojiHtml: emoji,
|
||||
renderMentionHtml: badge,
|
||||
});
|
||||
assert.equal(body, 'ESC(hello )BADGE(Alice)');
|
||||
});
|
||||
|
||||
test('buildMessageBody with renderMentionHtml: no mentions, callback not invoked', () => {
|
||||
let called = false;
|
||||
const body = buildMessageBody({
|
||||
message: { text: 'plain text' },
|
||||
escapeHtml: esc,
|
||||
renderEmojiHtml: emoji,
|
||||
renderMentionHtml: () => { called = true; return 'BADGE'; },
|
||||
});
|
||||
assert.equal(called, false);
|
||||
assert.equal(body, 'ESC(plain text)');
|
||||
});
|
||||
|
||||
test('buildMessageBody with renderMentionHtml: null renderMentionHtml behaves like no callback', () => {
|
||||
const body = buildMessageBody({
|
||||
message: { text: 'hi @[Alice]' },
|
||||
escapeHtml: esc,
|
||||
renderEmojiHtml: emoji,
|
||||
renderMentionHtml: null,
|
||||
});
|
||||
assert.equal(body, 'ESC(hi @[Alice])');
|
||||
});
|
||||
|
||||
test('buildMessageBody reaction path unaffected by renderMentionHtml', () => {
|
||||
const reaction = { text: '1', emoji: '👍', portnum: 'REACTION_APP' };
|
||||
let called = false;
|
||||
const body = buildMessageBody({
|
||||
message: reaction,
|
||||
escapeHtml: esc,
|
||||
renderEmojiHtml: emoji,
|
||||
renderMentionHtml: () => { called = true; return 'BADGE'; },
|
||||
});
|
||||
assert.equal(called, false);
|
||||
assert.equal(body, 'EMOJI(👍)');
|
||||
});
|
||||
|
||||
test('buildMessageBody with renderMentionHtml: unclosed @[ treated as literal', () => {
|
||||
const body = buildMessageBody({
|
||||
message: { text: 'hello @[unclosed' },
|
||||
escapeHtml: esc,
|
||||
renderEmojiHtml: emoji,
|
||||
renderMentionHtml: () => 'BADGE',
|
||||
});
|
||||
// @[ without closing ] does not match the pattern — treated as literal
|
||||
assert.equal(body, 'ESC(hello @[unclosed)');
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ import { renderChatTabs } from './chat-tabs.js';
|
||||
import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js';
|
||||
import { filterChatModel, normaliseChatFilterQuery } from './chat-search.js';
|
||||
import { buildMessageBody, buildMessageIndex, resolveReplyPrefix } from './message-replies.js';
|
||||
import { parseMeshcoreSenderPrefix, findNodeByLongName } from './meshcore-chat-helpers.js';
|
||||
import {
|
||||
SNAPSHOT_WINDOW,
|
||||
aggregateNeighborSnapshots,
|
||||
@@ -2002,7 +2003,22 @@ export function initializeApp(config) {
|
||||
if (!short) {
|
||||
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>? </span>`;
|
||||
}
|
||||
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, ' ');
|
||||
// Centre the label within a 4-column badge. padStart alone only adds
|
||||
// leading spaces, producing " C" for a 1-char name with no trailing
|
||||
// space. Instead distribute padding evenly: 1-char → " C ", 2-char →
|
||||
// " AB ", 3-char → " ABC", 4-char → unchanged. Names already at 4+
|
||||
// chars are left as-is (meshtastic always stores exactly 4; the Ruby
|
||||
// COMPANION override also produces exactly 4).
|
||||
const raw = String(short);
|
||||
let centred;
|
||||
if (raw.length >= 4) {
|
||||
centred = raw;
|
||||
} else {
|
||||
const leading = Math.ceil((4 - raw.length) / 2);
|
||||
const trailing = 4 - raw.length - leading;
|
||||
centred = ' '.repeat(leading) + raw + ' '.repeat(trailing);
|
||||
}
|
||||
const padded = escapeHtml(centred).replace(/ /g, ' ');
|
||||
const protocol = nodeData?.protocol ?? null;
|
||||
const color = getRoleColor(roleValue, protocol);
|
||||
const textColor = getRoleTextColor(roleValue, protocol);
|
||||
@@ -3163,8 +3179,40 @@ export function initializeApp(config) {
|
||||
);
|
||||
const tsDate = tsSeconds != null ? new Date(tsSeconds * 1000) : null;
|
||||
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
|
||||
const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
|
||||
const messageProtocol = pickFirstProperty([m, m?.node], ['protocol']);
|
||||
|
||||
// MeshCore channel messages use "SenderName: body" text format. The
|
||||
// ingestor tries to resolve from_id via the contacts roster; when it
|
||||
// succeeds m.node is hydrated normally. When it fails (contact not yet
|
||||
// known), m.node is null and we fall back to a name-based lookup here.
|
||||
// Detection: channel messages always have to_id "^all".
|
||||
const toId = m.to_id ?? m.toId;
|
||||
const isMeshcoreChannelMsg = isMeshcoreProtocol(messageProtocol) && toId === '^all';
|
||||
|
||||
let meshcoreSenderNode = null;
|
||||
let parsedMeshcorePrefix = null;
|
||||
if (isMeshcoreChannelMsg && m?.text) {
|
||||
parsedMeshcorePrefix = parseMeshcoreSenderPrefix(String(m.text));
|
||||
// Only attempt the name lookup when the ingestor couldn't resolve the
|
||||
// sender (m.node is null). If it's already hydrated, m.node is used.
|
||||
if (parsedMeshcorePrefix && !m.node) {
|
||||
meshcoreSenderNode = findNodeByLongName(parsedMeshcorePrefix.senderName, nodesById);
|
||||
}
|
||||
}
|
||||
|
||||
let short;
|
||||
if (isMeshcoreChannelMsg && !m.node && meshcoreSenderNode) {
|
||||
// Fallback: ingestor couldn't resolve sender, but JS found the node by name.
|
||||
short = renderShortHtml(
|
||||
meshcoreSenderNode.short_name ?? meshcoreSenderNode.shortName,
|
||||
meshcoreSenderNode.role,
|
||||
meshcoreSenderNode.long_name ?? meshcoreSenderNode.longName,
|
||||
meshcoreSenderNode
|
||||
);
|
||||
} else {
|
||||
short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
|
||||
}
|
||||
|
||||
const nodeProtocolPrefix = protocolIconPrefixHtml(messageProtocol);
|
||||
const replyPrefix = resolveReplyPrefix({
|
||||
message: m,
|
||||
@@ -3184,11 +3232,39 @@ export function initializeApp(config) {
|
||||
messageBodyHtml = '';
|
||||
}
|
||||
} else {
|
||||
// Mention rendering is active for all MeshCore messages (channel + DM):
|
||||
// @[Name] patterns are replaced with a short-name badge when the named
|
||||
// node is present in nodesById.
|
||||
const isMeshcoreMsg = isMeshcoreProtocol(messageProtocol);
|
||||
const renderMentionHtml = isMeshcoreMsg
|
||||
? (mentionedName) => {
|
||||
const mentionNode = findNodeByLongName(mentionedName, nodesById);
|
||||
if (mentionNode) {
|
||||
return renderShortHtml(
|
||||
mentionNode.short_name ?? mentionNode.shortName,
|
||||
mentionNode.role,
|
||||
mentionNode.long_name ?? mentionNode.longName,
|
||||
mentionNode
|
||||
);
|
||||
}
|
||||
// Node not found — render as escaped plain text fallback.
|
||||
return `@[${escapeHtml(mentionedName)}]`;
|
||||
}
|
||||
: null;
|
||||
|
||||
// For channel messages, strip the "SenderName: " prefix before building
|
||||
// the body so we can prepend a linked version of the sender name instead.
|
||||
const bodyMsg = (isMeshcoreChannelMsg && parsedMeshcorePrefix)
|
||||
? { ...m, text: parsedMeshcorePrefix.bodyText }
|
||||
: m;
|
||||
|
||||
messageBodyHtml = buildMessageBody({
|
||||
message: m || {},
|
||||
message: bodyMsg || {},
|
||||
escapeHtml,
|
||||
renderEmojiHtml
|
||||
renderEmojiHtml,
|
||||
renderMentionHtml,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
const combinedSegments = [];
|
||||
@@ -4650,7 +4726,7 @@ export function initializeApp(config) {
|
||||
* Inner closures exposed for unit tests. Production callers should ignore
|
||||
* this return value.
|
||||
*
|
||||
* @returns {{ _testUtils: { buildMapPopupHtml: Function, normalizeOverlaySource: Function, createAnnouncementEntry: Function, createMessageChatEntry: Function, buildDisplayContext: Function } }}
|
||||
* @returns {{ _testUtils: { buildMapPopupHtml: Function, normalizeOverlaySource: Function, createAnnouncementEntry: Function, createMessageChatEntry: Function, buildDisplayContext: Function, rebuildNodeIndex: Function } }}
|
||||
*/
|
||||
return {
|
||||
_testUtils: {
|
||||
@@ -4659,6 +4735,7 @@ export function initializeApp(config) {
|
||||
createAnnouncementEntry,
|
||||
createMessageChatEntry,
|
||||
buildDisplayContext,
|
||||
rebuildNodeIndex,
|
||||
makeRoleFilterKey,
|
||||
normalizeFilterProtocol,
|
||||
matchesRoleFilter,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse the ``"SenderName: body"`` prefix that MeshCore embeds in channel
|
||||
* message text. MeshCore channel messages do not carry a sender node ID, so
|
||||
* the ingestor stores ``from_id = null`` and encodes the sender long name as
|
||||
* the leading ``"SenderName: "`` prefix of the message text.
|
||||
*
|
||||
* Only the first colon is treated as the separator; colons that appear in the
|
||||
* body are preserved unchanged.
|
||||
*
|
||||
* @param {string|null|undefined} text Raw message text from the database.
|
||||
* @returns {{ senderName: string, bodyText: string }|null} Parsed components,
|
||||
* or ``null`` when the text does not match the expected format.
|
||||
*/
|
||||
export function parseMeshcoreSenderPrefix(text) {
|
||||
if (text == null || typeof text !== 'string') return null;
|
||||
const colonIdx = text.indexOf(':');
|
||||
if (colonIdx < 0) return null;
|
||||
const senderName = text.slice(0, colonIdx).trim();
|
||||
if (!senderName) return null;
|
||||
const bodyText = text.slice(colonIdx + 1).trim();
|
||||
return { senderName, bodyText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a node in the provided ``nodesById`` Map by its long name.
|
||||
*
|
||||
* The comparison is case-sensitive because both the ingestor and the MeshCore
|
||||
* firmware emit the name verbatim; normalising case would risk false matches
|
||||
* between nodes whose names differ only in capitalisation.
|
||||
*
|
||||
* Both the snake_case (``long_name``) and camelCase (``longName``) property
|
||||
* variants are checked to accommodate different serialisation paths.
|
||||
*
|
||||
* This is an O(n) scan over all nodes. For the typical node counts seen in
|
||||
* practice (hundreds) this is negligible; a long-name index is not maintained
|
||||
* in the client-side Map because insertions and lookups occur at different
|
||||
* phases of the rendering pipeline.
|
||||
*
|
||||
* @param {string} longName Long name to search for.
|
||||
* @param {Map<string, object>} nodesById Loaded node registry keyed by node ID.
|
||||
* @returns {object|null} The first matching node, or ``null`` when not found.
|
||||
*/
|
||||
export function findNodeByLongName(longName, nodesById) {
|
||||
if (!longName || typeof longName !== 'string') return null;
|
||||
if (!(nodesById instanceof Map)) return null;
|
||||
for (const node of nodesById.values()) {
|
||||
const candidate = node.long_name ?? node.longName;
|
||||
if (typeof candidate === 'string' && candidate === longName) return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -338,28 +338,61 @@ function resolveMessageTextSegment(message, isReaction) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a text segment, replacing ``@[Name]`` mention patterns with the
|
||||
* output of ``renderMentionHtml`` when provided. Segments between mentions
|
||||
* are passed through ``escapeHtml`` to prevent XSS.
|
||||
*
|
||||
* When ``renderMentionHtml`` is ``null`` the function behaves identically to
|
||||
* ``escapeHtml(text)``, preserving backward compatibility.
|
||||
*
|
||||
* @param {string} text Raw message text segment.
|
||||
* @param {Function} escapeHtml HTML-escape function.
|
||||
* @param {Function|null} renderMentionHtml Called with the mention name (the
|
||||
* string between ``@[`` and ``]``); should return an HTML snippet.
|
||||
* @returns {string} HTML string safe for insertion into the DOM.
|
||||
*/
|
||||
function renderTextWithMentions(text, escapeHtml, renderMentionHtml) {
|
||||
if (typeof renderMentionHtml !== 'function') return escapeHtml(text);
|
||||
// split() with a capturing group interleaves literal segments (even indices)
|
||||
// and captured mention names (odd indices): ["before", "Alice", "after", ...]
|
||||
const parts = text.split(/@\[([^\]]+)\]/);
|
||||
return parts.map((part, i) => {
|
||||
if (i % 2 === 1) return renderMentionHtml(part);
|
||||
// Empty literal segments (e.g. when a mention is at the start or end) can
|
||||
// be skipped to avoid unnecessary escapeHtml calls on empty strings.
|
||||
return part ? escapeHtml(part) : '';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the rendered message body containing text and optional emoji.
|
||||
*
|
||||
* @param {{
|
||||
* message: Object,
|
||||
* escapeHtml: Function,
|
||||
* renderEmojiHtml: Function
|
||||
* }} params Rendering dependencies.
|
||||
* renderEmojiHtml: Function,
|
||||
* renderMentionHtml?: Function|null
|
||||
* }} params Rendering dependencies. When ``renderMentionHtml`` is provided it
|
||||
* is called for each ``@[Name]`` mention found in the message text so the
|
||||
* caller can substitute a badge or link in place of the raw mention string.
|
||||
* @returns {string} HTML snippet describing the message body.
|
||||
*/
|
||||
export function buildMessageBody({ message, escapeHtml, renderEmojiHtml }) {
|
||||
export function buildMessageBody({ message, escapeHtml, renderEmojiHtml, renderMentionHtml = null }) {
|
||||
if (typeof escapeHtml !== 'function') {
|
||||
throw new TypeError('escapeHtml must be a function');
|
||||
}
|
||||
if (typeof renderEmojiHtml !== 'function') {
|
||||
throw new TypeError('renderEmojiHtml must be a function');
|
||||
}
|
||||
if (renderMentionHtml !== null && typeof renderMentionHtml !== 'function') {
|
||||
throw new TypeError('renderMentionHtml must be a function when provided');
|
||||
}
|
||||
if (!message || typeof message !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const segments = [];
|
||||
const segments = [];
|
||||
const reaction = isReactionMessage(message);
|
||||
const textSegment = resolveMessageTextSegment(message, reaction);
|
||||
const reactionCount = reaction && textSegment && /^×\d+$/.test(textSegment) ? textSegment : null;
|
||||
@@ -368,7 +401,7 @@ export function buildMessageBody({ message, escapeHtml, renderEmojiHtml }) {
|
||||
let reactionEmoji = reaction && !emojiIsNumericPlaceholder ? emoji : null;
|
||||
|
||||
if (!reaction && textSegment) {
|
||||
segments.push(escapeHtml(textSegment));
|
||||
segments.push(renderTextWithMentions(textSegment, escapeHtml, renderMentionHtml));
|
||||
}
|
||||
|
||||
if (reaction) {
|
||||
|
||||
@@ -372,4 +372,201 @@ RSpec.describe PotatoMesh::App::DataProcessing do
|
||||
db.close
|
||||
end
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# upsert_node — synthetic flag + merge
|
||||
# ---------------------------------------------------------------------------
|
||||
describe "#upsert_node — synthetic node handling", :db do
|
||||
include_context "with isolated db"
|
||||
|
||||
let(:now) { Time.now.to_i }
|
||||
|
||||
def seed_message(db, from_id:)
|
||||
db.execute(
|
||||
"INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)",
|
||||
[42, now, "2025-01-01T00:00:00Z", from_id, "^all", "meshcore"],
|
||||
)
|
||||
end
|
||||
|
||||
it "stores synthetic=1 when user.synthetic is true" do
|
||||
db = open_db
|
||||
dp.upsert_node(db, "!synth111", {
|
||||
"lastHeard" => now,
|
||||
"protocol" => "meshcore",
|
||||
"user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION", "synthetic" => true },
|
||||
}, protocol: "meshcore")
|
||||
row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!synth111'").first
|
||||
expect(row[0]).to eq(1)
|
||||
db.close
|
||||
end
|
||||
|
||||
it "stores synthetic=0 when user.synthetic is false" do
|
||||
db = open_db
|
||||
dp.upsert_node(db, "!real1111", {
|
||||
"lastHeard" => now,
|
||||
"protocol" => "meshcore",
|
||||
"user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION", "synthetic" => false },
|
||||
}, protocol: "meshcore")
|
||||
row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!real1111'").first
|
||||
expect(row[0]).to eq(0)
|
||||
db.close
|
||||
end
|
||||
|
||||
it "does not overwrite a real node with a synthetic upsert" do
|
||||
db = open_db
|
||||
# Insert real node first.
|
||||
dp.upsert_node(db, "!aabbccdd", {
|
||||
"lastHeard" => now - 100,
|
||||
"user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION" },
|
||||
}, protocol: "meshcore")
|
||||
# Attempt to overwrite with synthetic upsert at a later time.
|
||||
dp.upsert_node(db, "!aabbccdd", {
|
||||
"lastHeard" => now,
|
||||
"user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION", "synthetic" => true },
|
||||
}, protocol: "meshcore")
|
||||
row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!aabbccdd'").first
|
||||
expect(row[0]).to eq(0)
|
||||
db.close
|
||||
end
|
||||
|
||||
it "real wins over synthetic — synthetic=0 is never overwritten by synthetic=1" do
|
||||
db = open_db
|
||||
# Insert synthetic first, then real.
|
||||
dp.upsert_node(db, "!synth222", {
|
||||
"lastHeard" => now - 200,
|
||||
"user" => { "longName" => "Bob", "shortName" => " B ", "role" => "COMPANION", "synthetic" => true },
|
||||
}, protocol: "meshcore")
|
||||
dp.upsert_node(db, "!synth222", {
|
||||
"lastHeard" => now,
|
||||
"user" => { "longName" => "Bob", "shortName" => " B ", "role" => "COMPANION", "synthetic" => false },
|
||||
}, protocol: "meshcore")
|
||||
row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!synth222'").first
|
||||
expect(row[0]).to eq(0)
|
||||
db.close
|
||||
end
|
||||
|
||||
it "migrates messages from synthetic node to real node on name match" do
|
||||
db = open_db
|
||||
synth_id = "!synth333"
|
||||
real_id = "!real3333"
|
||||
# Create synthetic node and a message from it.
|
||||
dp.upsert_node(db, synth_id, {
|
||||
"lastHeard" => now - 500,
|
||||
"user" => { "longName" => "Carol", "shortName" => " C ", "role" => "COMPANION", "synthetic" => true },
|
||||
}, protocol: "meshcore")
|
||||
seed_message(db, from_id: synth_id)
|
||||
# Upsert the real node with the same long name.
|
||||
dp.upsert_node(db, real_id, {
|
||||
"lastHeard" => now,
|
||||
"user" => { "longName" => "Carol", "shortName" => " C ", "role" => "COMPANION", "publicKey" => "cc" * 32 },
|
||||
}, protocol: "meshcore")
|
||||
# Message should now point to the real node.
|
||||
msg_from = db.execute("SELECT from_id FROM messages WHERE id = 42").first[0]
|
||||
expect(msg_from).to eq(real_id)
|
||||
# Synthetic node should be gone.
|
||||
synth_row = db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first
|
||||
expect(synth_row).to be_nil
|
||||
db.close
|
||||
end
|
||||
|
||||
it "does not delete real nodes during merge" do
|
||||
db = open_db
|
||||
real_id = "!real4444"
|
||||
# Insert a real node with the same long name as the incoming real node.
|
||||
dp.upsert_node(db, real_id, {
|
||||
"lastHeard" => now - 100,
|
||||
"user" => { "longName" => "Dave", "shortName" => " D ", "role" => "COMPANION" },
|
||||
}, protocol: "meshcore")
|
||||
# Upsert same node again — should not delete itself.
|
||||
dp.upsert_node(db, real_id, {
|
||||
"lastHeard" => now,
|
||||
"user" => { "longName" => "Dave", "shortName" => " D ", "role" => "COMPANION" },
|
||||
}, protocol: "meshcore")
|
||||
row = db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [real_id]).first
|
||||
expect(row).not_to be_nil
|
||||
db.close
|
||||
end
|
||||
|
||||
it "migrates messages from multiple synthetic nodes to a single real node" do
|
||||
db = open_db
|
||||
synth_a = "!synth5a5a"
|
||||
synth_b = "!synth5b5b"
|
||||
real_id = "!real5555"
|
||||
# Two synthetic nodes with the same long name (could happen from two
|
||||
# ingestors or a race).
|
||||
dp.upsert_node(db, synth_a, {
|
||||
"lastHeard" => now - 600,
|
||||
"user" => { "longName" => "Eve", "shortName" => " E ", "role" => "COMPANION", "synthetic" => true },
|
||||
}, protocol: "meshcore")
|
||||
dp.upsert_node(db, synth_b, {
|
||||
"lastHeard" => now - 500,
|
||||
"user" => { "longName" => "Eve", "shortName" => " E ", "role" => "COMPANION", "synthetic" => true },
|
||||
}, protocol: "meshcore")
|
||||
db.execute(
|
||||
"INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)",
|
||||
[51, now - 600, "2025-01-01T00:00:00Z", synth_a, "^all", "meshcore"],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)",
|
||||
[52, now - 500, "2025-01-01T00:00:00Z", synth_b, "^all", "meshcore"],
|
||||
)
|
||||
# Upsert real node.
|
||||
dp.upsert_node(db, real_id, {
|
||||
"lastHeard" => now,
|
||||
"user" => { "longName" => "Eve", "shortName" => " E ", "role" => "COMPANION", "publicKey" => "ee" * 32 },
|
||||
}, protocol: "meshcore")
|
||||
# Both messages should now reference the real node.
|
||||
from_ids = db.execute("SELECT from_id FROM messages WHERE id IN (51,52) ORDER BY id").map { |r| r[0] }
|
||||
expect(from_ids).to all(eq(real_id))
|
||||
# Both synthetic nodes gone.
|
||||
remaining = db.execute("SELECT node_id FROM nodes WHERE node_id IN (?,?)", [synth_a, synth_b]).flatten
|
||||
expect(remaining).to be_empty
|
||||
db.close
|
||||
end
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# merge_synthetic_nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
describe "#merge_synthetic_nodes" do
|
||||
include_context "with isolated db"
|
||||
|
||||
let(:now) { Time.now.to_i }
|
||||
|
||||
it "is a no-op when no synthetic nodes match the long name" do
|
||||
db = open_db
|
||||
dp.upsert_node(db, "!real6666", {
|
||||
"lastHeard" => now - 100,
|
||||
"user" => { "longName" => "Frank", "shortName" => " F " },
|
||||
}, protocol: "meshcore")
|
||||
# Should not raise and should leave the real node intact.
|
||||
dp.merge_synthetic_nodes(db, "!real6666", "Frank")
|
||||
row = db.execute("SELECT node_id FROM nodes WHERE node_id = '!real6666'").first
|
||||
expect(row).not_to be_nil
|
||||
db.close
|
||||
end
|
||||
|
||||
it "does not migrate messages from a synthetic node on a different protocol" do
|
||||
db = open_db
|
||||
# A synthetic meshtastic node that happens to share the same long name as
|
||||
# an incoming real meshcore contact must NOT be merged.
|
||||
synth_id = "!synth7777"
|
||||
real_id = "!real7777"
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)",
|
||||
[synth_id, "Grace", "meshtastic", 1, now - 100, now - 100],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)",
|
||||
[61, now - 100, "2025-01-01T00:00:00Z", synth_id, "^all", "meshtastic"],
|
||||
)
|
||||
dp.merge_synthetic_nodes(db, real_id, "Grace")
|
||||
# meshtastic synthetic node must be untouched.
|
||||
synth_row = db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first
|
||||
expect(synth_row).not_to be_nil
|
||||
msg_from = db.execute("SELECT from_id FROM messages WHERE id = 61").first[0]
|
||||
expect(msg_from).to eq(synth_id)
|
||||
db.close
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -180,27 +180,27 @@ RSpec.describe PotatoMesh::App::Helpers do
|
||||
|
||||
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} ")
|
||||
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} ")
|
||||
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} ")
|
||||
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} ")
|
||||
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} ")
|
||||
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
|
||||
|
||||
@@ -457,7 +457,7 @@ RSpec.describe PotatoMesh::App::Queries do
|
||||
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} ")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user