Files
MeshTerm/tests/test_reactions.py
richandClaude Opus 5.5 d9426166c8
CI / lint (push) Canceled after 0s
CI / test macos-latest py3.14 (push) Canceled after 0s
CI / test ubuntu-24.04 py3.10 (push) Canceled after 0s
CI / test ubuntu-latest py3.11 (push) Canceled after 0s
CI / test ubuntu-latest py3.12 (push) Canceled after 0s
CI / test ubuntu-latest py3.13 (push) Canceled after 0s
CI / test ubuntu-latest py3.14 (push) Canceled after 0s
CI / test windows-latest py3.10 (push) Canceled after 0s
CI / test windows-latest py3.14 (push) Canceled after 0s
CI / build (push) Canceled after 0s
Issue trailers / check (push) Canceled after 0s
Search every emoji by name in the reaction dialog
^E still lists the reaction_emoji preference first, under "Your reactions". Under
"All emoji" it now lists each other emoji with its Unicode name, and the list
filters as you type: "fire" finds 🔥, "heart" finds the hearts. The catalog comes
from the Unicode database of the Python that runs MeshTerm and from Rich's table of
text-default emoji, so it needs no data file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:47:13 -04:00

437 lines
19 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Emoji reactions: the wire format of MeshCore One, the fold, the send path, and the chat.
The parse cases come from the tests of MeshCore One (``ReactionParserTests.swift`` and
``MeshCoreOpenReactionParserTests.swift``). Thus MeshTerm reads the same bodies as the app
whose format it shares. The hash test calculates the expected value a second way, with the
Base32 of the standard library, so that the test does not only repeat the code.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
from datetime import datetime, timezone
from pathlib import Path
import pytest
from meshterm.core import reactions
from meshterm.core.config import Settings
from meshterm.core.models import ChatMessage, Contact, Conversation, Delivery, Message
from meshterm.core.preferences import Preferences
from meshterm.core.reactions import MC1, MCO, MCO_V1, ReactionFold, parse
from meshterm.persistence.repository import Repository
from meshterm.services.chat_service import ChatService
from meshterm.ui.chat import ChatScreen
from tests.conftest import plain
_T = 1_704_067_200 # 2024-01-01 00:00:00 UTC
def _at(seconds: int) -> datetime:
return datetime.fromtimestamp(seconds, tz=timezone.utc)
# -- the hash -----------------------------------------------------------------------------
def test_the_hash_is_crockford_base32_of_the_first_five_bytes_of_sha256() -> None:
"""SHA-256 of the UTF-8 body and the little-endian timestamp, five bytes, Crockford Base32."""
digest = hashlib.sha256(b"Hello" + _T.to_bytes(4, "little")).digest()[:5]
rfc = base64.b32encode(digest).decode() # five bytes make exactly eight characters
expected = rfc.translate(
str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", "0123456789abcdefghjkmnpqrstvwxyz")
)
assert reactions.message_hash("Hello", _T) == expected
assert reactions.message_hash("Hello", _T + 1) != expected
assert reactions.message_hash("World", _T) != expected
def test_the_texts_that_meshterm_sends_name_their_target() -> None:
"""A channel reaction names the author first. A direct one has the emoji and the hash."""
digest = reactions.message_hash("hi all", _T)
assert reactions.channel_text("👍", "Alice", "hi all", _T) == f"@[Alice]👍\n{digest}"
assert reactions.direct_text("👍", "hi all", _T) == f"👍\n{digest}"
assert parse(reactions.channel_text("👍", "Alice", "hi all", _T), channel=True) == (
reactions.Reaction("👍", MC1, digest, author="Alice")
)
# -- the parse ----------------------------------------------------------------------------
@pytest.mark.parametrize(
("body", "emoji", "author", "digest"),
[
("@[AlphaNode]👍\n7f3a9c12", "👍", "AlphaNode", "7f3a9c12"),
("❤️@[BetaNode]\ne4d8b1a0", "❤️", "BetaNode", "e4d8b1a0"), # before 1.4.1
("@[Node]👍\nOOOOOOOO", "👍", "Node", "00000000"),
("@[Node]👍\niiiiiiii", "👍", "Node", "11111111"),
("@[Node]👍\nLLLLLLLL", "👍", "Node", "11111111"),
("@[Node:Alpha]👍\na1b2c3d4", "👍", "Node:Alpha", "a1b2c3d4"),
("@[Alice]👍\nreceived", "👍", "Alice", "rece1ved"),
("@[A]👍🏽\na1b2c3d4", "👍🏽", "A", "a1b2c3d4"),
("@[A]👨‍👩‍👧\na1b2c3d4", "👨‍👩‍👧", "A", "a1b2c3d4"),
("@[A]🇨🇦\na1b2c3d4", "🇨🇦", "A", "a1b2c3d4"),
],
)
def test_a_channel_reaction_parses(body: str, emoji: str, author: str, digest: str) -> None:
"""The two orders, the Crockford substitutions, a colon in a name, and each glyph shape."""
assert parse(body, channel=True) == reactions.Reaction(emoji, MC1, digest, author=author)
@pytest.mark.parametrize(
"body",
[
"Just a normal message",
"@[Node]👍",
"👍 [Node]\na1b2c3d4",
"👍@Node\na1b2c3d4",
"@[Node]👍\nabc",
"@[Node]👍\nuuuuuuuu",
"@[]👍\na1b2c3d4",
"@[Node]\na1b2c3d4",
"@[Alice]🎉 on my way\npassword",
"🎉 on my way@[Alice]\npassword",
"A@[Node]\na1b2c3d4",
],
)
def test_a_usual_channel_message_is_not_a_reaction(body: str) -> None:
"""A body that only starts with an emoji, or is short of a part, stays a message."""
assert parse(body, channel=True) is None
def test_a_direct_reaction_has_no_author() -> None:
"""A direct reaction is the emoji and the hash. The channel shape is not one."""
assert parse("👍\nb45pc4ek", channel=False) == reactions.Reaction("👍", MC1, "b45pc4ek")
assert parse("@[Node]👍\na1b2c3d4", channel=False) is None
assert parse("🎉 on my way\npassword", channel=False) is None
assert parse("1\na1b2c3d4", channel=False) is None
def test_the_reactions_of_meshcore_open_parse() -> None:
"""The v3 form indexes a table of 184 emoji. The v1 form carries the time and two hashes."""
assert len(reactions.MCO_EMOJI) == 184
assert parse("r:a1b2:00", channel=True) == reactions.Reaction("👍", MCO, "a1b2")
assert parse("r:0000:45", channel=False).emoji == "😶" # the last smiley
assert parse("r:0000:66", channel=False).emoji == "💪" # the last gesture
assert parse("r:0000:67", channel=False).emoji == "❤️" # the first heart
assert parse("r:0000:b7", channel=False).emoji == "🚀" # the last object
for bad in ("r:a1b2:b8", "r:A1B2:00", "r:a1b2:0A", "r:a1b:00", "x:a1b2:00", ""):
assert parse(bad, channel=True) is None
old = parse("r:1704067200000_12345_67890:👍", channel=True)
assert old == reactions.Reaction(
"👍", MCO_V1, "1704067200000_12345_67890", timestamp=_T, author_hash=12345, body_hash=67890
)
def test_the_dart_hash_is_thirty_bits_and_never_zero() -> None:
"""The ``String.hashCode`` of Dart: 1 for no input, and always under 2**30."""
assert reactions.dart_hash([]) == 1
value = reactions.dart_hash([ord(c) for c in "test string"])
assert 0 < value < 1 << 30
assert reactions.dart_hash([ord(c) for c in "hello"]) != reactions.dart_hash(
[ord(c) for c in "world"]
)
def test_the_reaction_set_keeps_only_single_emoji() -> None:
"""A word that is not one emoji is ignored. With nothing left, the default set is used."""
assert reactions.reaction_set("🔥 ok 👍 🔥 👍👍") == ["🔥", "👍"]
assert reactions.reaction_set("") == reactions.DEFAULT_REACTIONS.split()
assert reactions.reaction_set("abc") == reactions.DEFAULT_REACTIONS.split()
# -- the fold -----------------------------------------------------------------------------
def _channel(text: str, seconds: int = _T, *, outbound: bool = False) -> ChatMessage:
return ChatMessage(text=text, outbound=outbound, is_channel=True, created_at=_at(seconds))
def test_a_channel_reaction_folds_onto_its_target_once_for_each_node() -> None:
"""The target stays. The reaction leaves the transcript. A copy from the same node is one."""
fold = ReactionFold(channel=True, self_name="Me")
target = _channel("Alice: hi all")
assert fold.take(target) is False
react = reactions.channel_text("👍", "Alice", "hi all", _T)
assert fold.take(_channel(f"Bob: {react}", _T + 5)) is True
assert fold.take(_channel(f"Bob: {react}", _T + 6)) is True # a relayed copy
assert fold.take(_channel(f"Carol: {react}", _T + 7)) is True
assert fold.take(_channel(reactions.channel_text("❤️", "Alice", "hi all", _T), outbound=True))
badges = fold.badges(target)
assert [(b.emoji, [r.name for r in b.reactors]) for b in badges] == [
("👍", ["Bob", "Carol"]),
("❤️", [None]),
]
assert badges[1].ours and fold.reacted(target, "❤️") and not fold.reacted(target, "👍")
assert fold.count(target) == 3
def test_a_reaction_to_our_channel_message_names_our_node() -> None:
"""Our channel messages have no prefix in the history. Our name is their author."""
fold = ReactionFold(channel=True, self_name="Me")
ours = _channel("lunch?", outbound=True)
fold.take(ours)
fold.take(_channel("Bob: " + reactions.channel_text("😂", "Me", "lunch?", _T)))
assert [b.emoji for b in fold.badges(ours)] == ["😂"]
assert fold.target_of(ours) == ("Me", "lunch?", _T)
def test_a_reaction_that_arrives_first_waits_for_its_target() -> None:
"""On a mesh, a reaction can arrive before its target. It attaches when the target comes."""
fold = ReactionFold(channel=True)
assert fold.take(_channel("Bob: " + reactions.channel_text("👍", "Alice", "late", _T)))
target = _channel("Alice: late")
assert fold.take(target) is False
assert [b.emoji for b in fold.badges(target)] == ["👍"]
def test_the_author_tells_two_identical_messages_apart() -> None:
"""Two nodes that send the same body in the same second get their own reactions."""
fold = ReactionFold(channel=True)
alice, carol = _channel("Alice: ok"), _channel("Carol: ok")
fold.take(alice)
fold.take(carol)
fold.take(_channel("Bob: " + reactions.channel_text("👍", "Carol", "ok", _T)))
assert fold.badges(alice) == [] and len(fold.badges(carol)) == 1
def test_a_direct_reaction_folds_by_hash_alone() -> None:
"""In a direct exchange, the peer is the only other node. Its reactor has no name."""
fold = ReactionFold(channel=False)
ours = ChatMessage(text="see you", outbound=True, created_at=_at(_T))
fold.take(ours)
assert fold.take(ChatMessage(text=reactions.direct_text("👍", "see you", _T), peer="d4e5"))
(badge,) = fold.badges(ours)
assert badge.emoji == "👍" and badge.reactors == [reactions.Reactor(None)]
def test_a_reaction_of_meshcore_open_finds_its_target_near_in_time() -> None:
"""The short hash of v3 is compared with the messages within the window of the reaction."""
fold = ReactionFold(channel=True)
target = _channel("Alice: hello there")
fold.take(target)
digest = reactions.mco_hash(_T, "Alice", "hello there")
assert fold.take(_channel(f"Bob: r:{digest}:05", _T + 30))
assert [b.emoji for b in fold.badges(target)] == ["🔥"]
far = _channel("Carol: hello there", _T + 10_000)
fold.take(far)
late = reactions.mco_hash(_T + 10_000, "Carol", "hello there")
assert fold.take(_channel(f"Bob: r:{late}:05", _T)) # out of the window: no target
assert fold.badges(far) == []
# -- the send path ------------------------------------------------------------------------
class _Recorder:
"""A device that records the timestamp and the try number of each send."""
def __init__(self, acks: list[bool]) -> None:
self._acks = list(acks)
self.direct: list[tuple[str, int | None, int]] = []
self.channel: list[tuple[str, int | None]] = []
async def send_direct_message(self, contact, text, *, timestamp=None, attempt=0): # noqa: ANN001, ANN201
self.direct.append((text, timestamp, attempt))
acked = self._acks.pop(0) if self._acks else False
code = f"{len(self.direct):08x}"
return Delivery(code, object() if acked else None)
async def send_channel_in_scope(self, index, text, scope, *, timestamp=None): # noqa: ANN001, ANN201
self.channel.append((text, timestamp))
class _Ctx:
def __init__(self, device: _Recorder, repo: Repository) -> None:
self._device = device
self.repo = repo
self.log = logging.getLogger("test.reactions")
self.settings = Settings()
self.preferences = Preferences()
async def device(self) -> _Recorder:
return self._device
@pytest.fixture()
def repo(tmp_path: Path) -> Repository:
"""A Repository on a scratch database. It closes when the test ends."""
r = Repository(tmp_path / "reactions.db")
yield r
r.close()
_ALICE = Contact(name="Alice", public_key="d4e5" + "0" * 60, key_prefix="d4e5f6a7")
async def test_a_sent_message_stores_the_timestamp_that_went_out(repo: Repository) -> None:
"""The stored time is the wire time to the second. A reaction to it then names it right."""
device = _Recorder([True])
chat = ChatService(_Ctx(device, repo))
sent = await chat.send_channel(0, "hello")
assert device.channel[0][1] == int(sent.created_at.timestamp())
assert sent.created_at.microsecond == 0
dm = await chat.send_direct(_ALICE, "hey")
assert device.direct[0][1] == int(dm.created_at.timestamp())
stored = repo.recent_chat_messages(is_channel=False, peer=_ALICE.key_prefix)
assert stored[-1].created_at == dm.created_at
async def test_retries_keep_the_timestamp_and_count_the_try(repo: Repository) -> None:
"""Soft retries and ^R send one message: the same timestamp, the next try number, up to 3."""
device = _Recorder([False, False, False, False, False])
ctx = _Ctx(device, repo)
ctx.preferences.direct_message_soft_retries = 1
chat = ChatService(ctx)
sent = await chat.send_direct(_ALICE, "hey")
await chat.resend_direct(_ALICE, sent)
await chat.resend_direct(_ALICE, sent)
stamps = {timestamp for _, timestamp, _ in device.direct}
assert stamps == {int(sent.created_at.timestamp())}
assert [attempt for *_, attempt in device.direct] == [0, 1, 2, 3, 3, 3]
async def test_a_reaction_does_not_raise_the_unread_badge(repo: Repository) -> None:
"""A reaction shows on its target, so a badge for it would point at nothing new."""
chat = ChatService(_Ctx(_Recorder([]), repo))
react = Message(text=reactions.direct_text("👍", "hey", _T), sender="d4e5f6a7")
assert await chat._notifies(react, None) is False
channel = Message(
text="Bob: " + reactions.channel_text("👍", "Alice", "hi", _T), channel=0, is_channel=True
)
assert await chat._notifies(channel, "abc") is False
# -- the chat screen ----------------------------------------------------------------------
class _Session:
"""A session stand-in that answers the reaction dialog with ``answer``."""
def __init__(self, answer: str | None) -> None:
self.answer = answer
self.asked: list[str] = []
self.items: list = []
self.filterable: bool | None = None
def invalidate(self) -> None:
pass
async def select(self, title, items, *, filterable=True, **_kw): # noqa: ANN001, ANN003, ANN201
self.asked.append(title)
self.items, self.filterable = items, filterable
return self.answer
def _screen(messages: list[ChatMessage], session: _Session, sent: list[str]) -> ChatScreen:
async def send(text: str) -> ChatMessage:
sent.append(text)
return ChatMessage(text=text, outbound=True, is_channel=True, created_at=_at(_T + 60))
return ChatScreen(
Conversation(label="#public", is_channel=True, channel_idx=0),
messages,
send=send,
names={},
session=session,
self_name="Me",
)
def test_the_transcript_shows_reactions_under_their_target() -> None:
"""The reaction leaves the transcript. Its emoji and count show under the target."""
react = "Bob: " + reactions.channel_text("👍", "Alice", "hi all", _T)
screen = _screen([_channel("Alice: hi all"), _channel(react, _T + 5)], _Session(None), [])
assert len(screen._messages) == 1
body = plain(screen.render_body(60))
assert "👍 1" in body
assert reactions.message_hash("hi all", _T) not in body and "@[Alice]" not in body
screen.handle("up") # the selection names the nodes instead of the count
assert "👍 Bob" in plain(screen.render_body(60))
async def test_ctrl_e_sends_the_reaction_and_folds_our_copy() -> None:
"""^E asks which emoji, sends the text of MeshCore One, and shows our reaction on the target."""
session, sent = _Session("😂"), []
target = _channel("Alice: hi all")
screen = _screen([target], session, sent)
screen.handle("up")
assert "^E react" in screen.footer_hint
screen.handle("react")
await asyncio.sleep(0)
await asyncio.gather(*screen._tasks)
assert session.asked == ["React — Alice"]
assert sent == [reactions.channel_text("😂", "Alice", "hi all", _T)]
assert screen._messages == [target]
assert screen._fold.reacted(target, "😂")
def test_a_room_shows_reaction_text_as_it_arrived() -> None:
"""A room has no reactions. Its messages stay messages, and ^E offers nothing."""
body = reactions.direct_text("👍", "x", _T)
room = Contact(name="Board", public_key="ab" * 32, key_prefix="ab" * 6, node_type=3)
screen = ChatScreen(
Conversation(label="Board", is_channel=False, contact=room),
[ChatMessage(text=body, peer="abababababab", created_at=_at(_T))],
send=None,
names={},
session=_Session(None),
)
assert len(screen._messages) == 1
screen.handle("up")
assert not screen._can_react()
def test_the_messages_list_previews_a_reaction_in_words() -> None:
"""The latest message of a conversation can be a reaction. Its preview says what it is."""
from meshterm.tools.chat import _preview_text
react = "Bob: " + reactions.channel_text("👍", "Alice", "hi", _T)
preview = _preview_text(_channel(react), lambda name: None).plain
assert "Bob" in preview and "reacted 👍" in preview and "@[" not in preview
ours = ChatMessage(text=reactions.direct_text("😂", "hi", _T), outbound=True)
assert "reacted 😂" in _preview_text(ours, lambda name: None).plain
def test_a_reaction_from_a_node_with_a_long_name_folds() -> None:
"""A node name can have 31 characters. A sender of 22 is still split off its reaction.
The two messages are from the hardware log that found the fault: the name limit was 20,
so the reaction of ``Hazelwood r0b3.Anduril`` showed as a new message.
"""
fold = ReactionFold(channel=True)
target = _channel("Hazelwood: ⚽ Union 2-1 at Chicago Fire (59')", 1_792_029_645)
fold.take(target)
react = _channel("Hazelwood r0b3.Anduril: @[Hazelwood]🔥\nmzjsjjpk", 1_792_029_654)
assert fold.take(react) is True
(badge,) = fold.badges(target)
assert badge.emoji == "🔥" and badge.reactors == [reactions.Reactor("Hazelwood r0b3.Anduril")]
async def test_the_reaction_dialog_searches_every_emoji_by_name() -> None:
"""The preferred emoji come first, then each other emoji. A typed name filters them all."""
from meshterm.ui.chat import emoji_catalog
from meshterm.ui.tui import Choice, Separator
session = _Session(None)
screen = _screen([_channel("Alice: hi all")], session, [])
screen.handle("up")
screen.handle("react")
await asyncio.sleep(0)
await asyncio.gather(*screen._tasks)
assert session.filterable is True
values = [item.value for item in session.items if isinstance(item, Choice)]
assert values[:6] == reactions.DEFAULT_REACTIONS.split()
assert "🔥" in values and values.count("👍") == 1 # a preferred emoji is listed once
assert isinstance(session.items[0], Separator) # "Your reactions", then "All emoji"
assert sum(isinstance(item, Separator) for item in session.items) == 2
fire = next(i for i in session.items if isinstance(i, Choice) and i.value == "🔥")
assert "fire" in fire.label.plain
assert len(emoji_catalog()) > 1000
assert all(reactions.is_reaction_emoji(emoji) for emoji in emoji_catalog())