Files
MeshTerm/tests/test_chat.py
T
Jean-Pierre MartineauandClaude Opus 4.8 8906e4ed53 Enliven the conversation list and keep section headings in view
Color-code the chat picker rows: a contact's dot takes that person's
chat hue (filled once talked-to, hollow before), previews light their
sender names and @mentions in-hue, and a relative age column sits
between the unread badge and the message text.

Pin a grouped list's section heading to the top row once it scrolls off
(sticky header in the frame), add PageUp/PageDown to SelectScreen, and
drop arrow wraparound in the conversation picker so it doesn't jump
across the section dividers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:03:31 -04:00

1001 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the chat feature: device send, persistence, and the chat service.
All run against the :class:`MockDevice` simulator and a temporary database; no hardware.
"""
from __future__ import annotations
import asyncio
import logging
import re
from pathlib import Path
import pytest
#: Strip ANSI SGR escapes so rendered transcript lines can be asserted as plain text.
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
def _strip_ansi(text: str) -> str:
"""Remove ANSI color escapes from rendered lines for plain-text assertions."""
return _ANSI.sub("", text)
from meshterm.core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
from meshterm.core.connection import MockDevice
from meshterm.core.events import MeshEvent
from meshterm.core.models import (
ChatMessage,
Contact,
Conversation,
Message,
conversation_key,
)
from meshterm.persistence.repository import Repository
from meshterm.services.chat_service import ChatService
from meshterm.services.event_hub import EventHub
from meshterm.tools.chat import _PREVIEW_WIDTH, _LiveLasts, _preview_text, _title
from meshterm.ui.chat import ChatScreen
from meshterm.ui.tui.screen import CANCEL
class _StubSession:
"""A session stand-in that just counts repaint requests."""
def __init__(self) -> None:
self.invalidations = 0
def invalidate(self) -> None:
self.invalidations += 1
class _StubContext:
"""Minimal :class:`~meshterm.context.AppContext` stand-in for chat tests."""
def __init__(self, device: MockDevice, repo: Repository) -> None:
self._device = device
self.repo = repo
self.log = logging.getLogger("test.chat")
self.profile_name = None
self.events = EventHub(self)
async def device(self) -> MockDevice:
await self._device.connect()
return self._device
@pytest.fixture()
def repo(tmp_path: Path) -> Repository:
r = Repository(tmp_path / "chat.db")
yield r
r.close()
# -- domain models ------------------------------------------------------------
def test_conversation_key_distinguishes_channels_and_directs() -> None:
"""Channel keys use the channel's identity; direct keys are case-insensitive on the peer."""
assert conversation_key(True, "deadbeef", None) == "chan:deadbeef"
assert conversation_key(False, None, "AABBCC") == "dm:aabbcc"
contact = Contact(name="Alice", public_key="d4e5" + "0" * 60, key_prefix="d4e5f6a7")
conv = Conversation(label="Alice", is_channel=False, contact=contact)
assert conv.key == "dm:d4e5f6a7"
assert conv.peer == "d4e5f6a7"
chan = Conversation(label="#public", is_channel=True, channel_idx=2, channel_id="deadbeef")
assert chan.key == "chan:deadbeef" # keyed by identity, not the slot index
assert chan.peer is None
def test_chat_message_from_inbound_message() -> None:
"""An inbound direct Message maps to a received ChatMessage on the right thread."""
message = Message(text="hi", sender="a1b2c3d4", is_channel=False, snr=5.5)
chat = ChatMessage.from_message(message, peer_name="Yagi")
assert chat.outbound is False
assert chat.is_channel is False
assert chat.peer == "a1b2c3d4"
assert chat.peer_name == "Yagi"
assert chat.snr == 5.5
assert chat.key == "dm:a1b2c3d4"
# -- device send --------------------------------------------------------------
async def test_mock_send_direct_returns_ack() -> None:
"""The simulator acknowledges direct messages so they show as delivered."""
device = MockDevice()
await device.connect()
contact = (await device.get_contacts())[0]
ack = await device.send_direct_message(contact, "hello")
assert ack is not None
await device.send_channel_message(0, "hi channel") # no return, must not raise
await device.disconnect()
async def test_message_pump_drains_until_empty() -> None:
"""The RX pump pulls get_msg() until the queue is empty (the pull model).
MeshCore never pushes message bodies, so the client must pull them; without this,
sending works but nothing is received. Guards the immediate drain, the loop until
NO_MORE_MSGS, the MESSAGES_WAITING subscription, and clean teardown.
"""
from meshcore import EventType
from meshterm.core.connection import MeshCoreDevice
class _Ev:
def __init__(self, t) -> None: # noqa: ANN001
self.type = t
class _FakeCommands:
def __init__(self, script: list) -> None:
self.calls = 0
self._script = script
async def get_msg(self, timeout=None): # noqa: ANN001, ANN201
i = min(self.calls, len(self._script) - 1)
self.calls += 1
return _Ev(self._script[i])
class _FakeMC:
def __init__(self, script: list) -> None:
self.commands = _FakeCommands(script)
self.subs: list = []
def subscribe(self, etype, cb): # noqa: ANN001, ANN201
self.subs.append(etype)
return object()
# One real message, then the empty sentinel: the drain loop pulls twice.
mc = _FakeMC([EventType.CONTACT_MSG_RECV, EventType.NO_MORE_MSGS])
device = MeshCoreDevice(port="COM_TEST")
subs: list = []
stop = device._message_pump(mc, subs, mc.subscribe)
try:
await asyncio.sleep(0.01) # let the immediate drain task run
assert mc.commands.calls == 2 # CONTACT_MSG_RECV then NO_MORE_MSGS
assert EventType.MESSAGES_WAITING in mc.subs # low-latency push subscription
finally:
stop()
# -- persistence --------------------------------------------------------------
def test_record_and_load_direct_conversation(repo: Repository) -> None:
"""Direct messages round-trip and come back in chronological order."""
repo.record_chat_message(ChatMessage(text="hi", outbound=True, peer="D4E5F6A7", acked=True))
repo.record_chat_message(ChatMessage(text="yo", outbound=False, peer="d4e5f6a7", snr=3.0))
got = repo.recent_chat_messages(is_channel=False, peer="d4e5f6a7")
assert [m.text for m in got] == ["hi", "yo"]
assert got[0].outbound is True and got[0].acked is True
assert got[1].outbound is False and got[1].snr == 3.0
def test_record_and_load_channel_conversation(repo: Repository) -> None:
"""Channel messages are keyed by channel identity, isolated from direct messages."""
repo.record_chat_message(ChatMessage(text="c0", is_channel=True, channel_id="aa00"))
repo.record_chat_message(ChatMessage(text="c1", is_channel=True, channel_id="bb11"))
assert [m.text for m in repo.recent_chat_messages(is_channel=True, channel_id="aa00")] == ["c0"]
assert [m.text for m in repo.recent_chat_messages(is_channel=True, channel_id="bb11")] == ["c1"]
def test_last_chat_messages_returns_latest_per_conversation(repo: Repository) -> None:
"""The picker preview shows the newest message in each conversation."""
repo.record_chat_message(ChatMessage(text="old", peer="aa"))
repo.record_chat_message(ChatMessage(text="new", peer="aa"))
repo.record_chat_message(ChatMessage(text="chan", is_channel=True, channel_id="aa00"))
lasts = repo.last_chat_messages()
assert lasts["dm:aa"].text == "new"
assert lasts["chan:aa00"].text == "chan"
# -- picker row rendering -----------------------------------------------------
class _FakeChat:
"""Stand-in for the chat service exposing just the unread lookup a row title reads."""
def __init__(self, unread: dict[str, int]) -> None:
self._unread = unread
def unread(self, key: str) -> int:
return self._unread.get(key, 0)
class _RowCtx:
"""Minimal ctx exposing only what the picker-row helpers touch (repo + chat)."""
def __init__(self, repo: Repository, unread: dict[str, int] | None = None) -> None:
self.repo = repo
self.chat = _FakeChat(unread or {})
def test_live_lasts_refreshes_preview_after_ttl(repo: Repository) -> None:
"""A new message becomes visible through _LiveLasts once the cache TTL lapses."""
repo.record_chat_message(ChatMessage(text="first", is_channel=True, channel_id="c0"))
live = _LiveLasts(_RowCtx(repo), seed=repo.last_chat_messages(), ttl=0) # 0 => always fresh
assert live.get("chan:c0").text == "first"
repo.record_chat_message(ChatMessage(text="second", is_channel=True, channel_id="c0"))
assert live.get("chan:c0").text == "second" # picked up live, not stuck on the seed
def test_live_lasts_serves_seed_within_ttl(repo: Repository) -> None:
"""Within the TTL the seeded snapshot is served without re-querying the repository."""
live = _LiveLasts(_RowCtx(repo), seed={"chan:c0": ChatMessage(text="seed")}, ttl=999)
repo.record_chat_message(ChatMessage(text="later", is_channel=True, channel_id="c0"))
assert live.get("chan:c0").text == "seed"
def test_preview_prefixes_own_messages_only() -> None:
"""Only outbound messages get a ``you:`` prefix; inbound text is shown verbatim.
Channel senders are embedded inline in the message text by the firmware, so no author is
synthesized (that would double it), and a direct message's author is the row label.
"""
chan_in = ChatMessage(text="Bob: hi", is_channel=True, channel_idx=0) # sender inline
assert _preview_text(chan_in).plain == "Bob: hi"
dm_in = ChatMessage(text="hey", peer="aa", peer_name="Bob")
assert _preview_text(dm_in).plain == "hey"
mine = ChatMessage(text="yo", outbound=True, is_channel=True, channel_idx=0)
assert _preview_text(mine).plain == "you: yo"
def test_preview_colours_channel_sender_and_mentions() -> None:
"""A channel preview lights its inline sender name and any ``@[Name]`` mention in a hue."""
from meshterm.ui.chat import _sender_hue
msg = ChatMessage(text="Bob: hi @[Alice]", is_channel=True, channel_idx=0)
preview = _preview_text(msg)
assert preview.plain == "Bob: hi @Alice" # the mention's brackets are dropped for display
styles = {span.style for span in preview.spans}
assert _sender_hue("Bob") in styles and _sender_hue("Alice") in styles
def test_preview_ellipsizes_long_text() -> None:
"""An over-long preview is clipped to the width budget with a trailing ellipsis."""
long = ChatMessage(text="x" * 100, is_channel=True, channel_idx=0)
out = _preview_text(long).plain
assert len(out) == _PREVIEW_WIDTH and out.endswith("…")
def test_title_shows_badge_and_author_preview(repo: Repository) -> None:
"""A channel row renders its live unread badge and its author-prefixed preview."""
conv = Conversation(label="General", is_channel=True, channel_idx=0, channel_id="c0")
ctx = _RowCtx(repo, unread={"chan:c0": 3})
last = ChatMessage(text="Bob: hi there", is_channel=True, channel_id="c0") # sender inline
title = _title(ctx, conv, {"chan:c0": last})
line = title.plain # a Text, since there is unread
assert line.startswith("🔒 General") # a private channel leads with its openness glyph
assert "● 3" in line
assert "Bob: hi there" in line
def test_title_reddens_only_the_unread_dot(repo: Repository) -> None:
"""With unread the row's ``●`` glyph (only) is styled red; with none there is no dot."""
from rich.text import Text
conv = Conversation(label="General", is_channel=True, channel_idx=0, channel_id="c0")
unread = _title(_RowCtx(repo, unread={"chan:c0": 2}), conv, {})
assert isinstance(unread, Text)
dot = unread.plain.index("●")
reddened = [
span for span in unread.spans if span.style == "err" and span.start <= dot < span.end
]
assert reddened and all(span.end - span.start == 1 for span in reddened) # just the glyph
read = _title(_RowCtx(repo, unread={}), conv, {})
assert isinstance(read, Text) # always a Text now, so its spans can carry the row's colour
assert "●" not in read.plain # nothing unread -> no badge dot
assert not any(span.style == "err" for span in read.spans)
def test_title_preview_column_aligns_regardless_of_label_length(repo: Repository) -> None:
"""The preview starts at the same column whether the label is short or (clipped) long."""
ctx = _RowCtx(repo)
short = Conversation(label="A", is_channel=True, channel_idx=0, channel_id="c0")
long = Conversation(label="A much longer channel name here", is_channel=True, channel_idx=1, channel_id="c1")
m0 = ChatMessage(text="X: hello", is_channel=True, channel_id="c0")
m1 = ChatMessage(text="Y: hello", is_channel=True, channel_id="c1")
l0 = _title(ctx, short, {"chan:c0": m0}).plain
l1 = _title(ctx, long, {"chan:c1": m1}).plain
assert l0.index("X: hello") == l1.index("Y: hello")
def test_title_leads_with_openness_glyph(repo: Repository) -> None:
"""Channel rows lead with an openness glyph: # name-derived, 🌐 fixed-key public, 🔒 private."""
ctx = _RowCtx(repo)
head = lambda conv: _title(ctx, conv, {}).plain.split(" ", 1)[0]
named = Conversation(
label="#general", is_channel=True, channel_id="c0", secret=derive_secret("#general")
)
public = Conversation(
label="Public", is_channel=True, channel_id="c1", secret=DEFAULT_PUBLIC_SECRET
)
private = Conversation(
label="Ops", is_channel=True, channel_id="c2", secret=bytes(range(16))
)
assert head(named) == "#"
assert head(public) == "🌐"
assert head(private) == "🔒"
def test_title_contact_dot_reflects_conversation_history(repo: Repository) -> None:
"""A contact's dot is filled ● once we've talked, hollow ○ before — always in her hue."""
from meshterm.ui.chat import _sender_hue
ctx = _RowCtx(repo)
contact = Conversation(
label="Alice", is_channel=False, contact=Contact(name="Alice", public_key="d4" + "0" * 62)
)
head = lambda lasts: _title(ctx, contact, lasts).plain.split(" ", 1)[0]
def dot_is_hued(row) -> bool:
return any(
span.style == _sender_hue("Alice") and span.start == 0 and span.end == 1
for span in row.spans
)
# No history yet — a hollow ring, but still tinted in Alice's stable chat hue.
fresh = _title(ctx, contact, {})
assert fresh.plain.startswith("○") and dot_is_hued(fresh)
# Once we've exchanged messages the same-hued dot fills in.
last = ChatMessage(text="hi", peer=contact.peer)
talked = _title(ctx, contact, {contact.key: last})
assert talked.plain.startswith("●") and dot_is_hued(talked)
# -- chat service -------------------------------------------------------------
async def test_service_records_inbound_and_tracks_unread(repo: Repository) -> None:
"""Inbound messages are persisted and bump the conversation's unread count."""
device = MockDevice()
ctx = _StubContext(device, repo)
chat = ChatService(ctx)
await chat.start()
try:
ctx.events.publish(
MeshEvent.message_event(Message(text="ping", sender="ffeeddcc", is_channel=False))
)
await chat._queue.join() # let the inbound worker resolve and record the message
assert chat.unread("dm:ffeeddcc") == 1
assert chat.unread_total() >= 1
stored = repo.recent_chat_messages(is_channel=False, peer="ffeeddcc")
assert [m.text for m in stored] == ["ping"]
finally:
await chat.stop()
await device.disconnect()
async def test_service_active_conversation_suppresses_unread(repo: Repository) -> None:
"""The open conversation clears and stops accruing unread while it stays active."""
device = MockDevice()
ctx = _StubContext(device, repo)
chat = ChatService(ctx)
await chat.start()
try:
chat.set_active("dm:ffeeddcc")
ctx.events.publish(
MeshEvent.message_event(Message(text="ping", sender="ffeeddcc", is_channel=False))
)
await chat._queue.join() # let the inbound worker resolve and record the message
assert chat.unread("dm:ffeeddcc") == 0 # active thread doesn't accrue unread
assert repo.recent_chat_messages(is_channel=False, peer="ffeeddcc") # still recorded
finally:
await chat.stop()
await device.disconnect()
async def test_service_files_channel_message_by_current_slot_occupant(repo: Repository) -> None:
"""A channel message is filed under whatever channel is in its slot *now*.
The wire reports only a slot index, which the firmware assigns from the channel's current
position. Reordering the slots (here simulated out of band, without notifying the service)
must not misroute later messages: resolution reads the slot fresh, so a message on slot 0
lands in whatever channel occupies slot 0 at that moment — never a stale cached identity.
"""
from meshterm.core.channels import channel_identity
device = MockDevice()
ctx = _StubContext(device, repo)
await device.connect()
secret_a, secret_b = bytes(range(16)), bytes(range(16, 32))
await device.set_channel(0, "Alpha", secret_a)
await device.set_channel(1, "Bravo", secret_b)
id_a = channel_identity("Alpha", secret_a)
id_b = channel_identity("Bravo", secret_b)
chat = ChatService(ctx)
await chat.start() # primes slot 0 -> Alpha, slot 1 -> Bravo
try:
ctx.events.publish(
MeshEvent.message_event(Message(text="from alpha", is_channel=True, channel=0))
)
await chat._queue.join()
# Swap the occupants of slots 0 and 1 out of band — the service is never told.
await device.set_channel(0, "Bravo", secret_b)
await device.set_channel(1, "Alpha", secret_a)
ctx.events.publish(
MeshEvent.message_event(Message(text="from bravo", is_channel=True, channel=0))
)
await chat._queue.join()
alpha = repo.recent_chat_messages(is_channel=True, channel_id=id_a)
bravo = repo.recent_chat_messages(is_channel=True, channel_id=id_b)
assert [m.text for m in alpha] == ["from alpha"] # unaffected by the reorder
assert [m.text for m in bravo] == ["from bravo"] # not misfiled under Alpha's identity
finally:
await chat.stop()
await device.disconnect()
async def test_service_records_channel_messages_in_arrival_order(repo: Repository) -> None:
"""A burst of channel messages is recorded in arrival order despite async resolution.
Each channel message resolves its identity with a device read; the serial inbound worker
guarantees they still land in the transcript (ordered by insertion) in the order received.
"""
from meshterm.core.channels import channel_identity
device = MockDevice()
ctx = _StubContext(device, repo)
await device.connect()
secret = bytes(range(16))
await device.set_channel(0, "Alpha", secret)
channel_id = channel_identity("Alpha", secret)
chat = ChatService(ctx)
await chat.start()
try:
for i in range(5):
ctx.events.publish(
MeshEvent.message_event(Message(text=f"m{i}", is_channel=True, channel=0))
)
await chat._queue.join()
stored = repo.recent_chat_messages(is_channel=True, channel_id=channel_id)
assert [m.text for m in stored] == ["m0", "m1", "m2", "m3", "m4"]
finally:
await chat.stop()
await device.disconnect()
# -- chat screen --------------------------------------------------------------
def _screen(session: _StubSession, send, *, messages=None) -> ChatScreen:
"""Build a ChatScreen for a direct conversation with a stub session and send hook."""
conv = Conversation(
label="Alice",
is_channel=False,
contact=Contact(name="Alice", public_key="d4" + "0" * 62, key_prefix="d4e5f6a7"),
)
return ChatScreen(conv, messages or [], send=send, names={"d4e5f6a7": "Alice"}, session=session)
def test_chat_screen_renders_transcript_and_input() -> None:
"""The body shows each message and always ends with the input line."""
session = _StubSession()
messages = [ChatMessage(text="hi there", outbound=True, peer="d4e5f6a7", acked=True)]
screen = _screen(session, send=None, messages=messages)
lines = screen.render_body(60)
joined = "\n".join(lines)
assert "hi there" in joined
assert "›" in joined # the input editor's prompt marker
async def test_chat_screen_enter_sends_and_appends() -> None:
"""Pressing Enter sends the line and appends the returned message to the transcript."""
session = _StubSession()
sent: list[str] = []
async def send(text: str) -> ChatMessage:
sent.append(text)
return ChatMessage(text=text, outbound=True, peer="d4e5f6a7", acked=True)
screen = _screen(session, send=send)
for ch in "hello":
screen.handle("text", ch)
screen.handle("enter")
await asyncio.sleep(0) # let the scheduled send task run
assert sent == ["hello"]
assert screen._messages[-1].text == "hello"
assert session.invalidations > 0
async def test_direct_send_spins_until_the_ack_resolves(monkeypatch) -> None:
"""A pending direct message spins while the send is in flight, then settles on ✅."""
from meshterm.ui.tui.spinner import Spinner
monkeypatch.setattr("meshterm.ui.chat._SPINNER_INTERVAL", 0.005)
session = _StubSession()
release = asyncio.Event()
async def send(text: str) -> ChatMessage:
await release.wait() # hold the send open so we can watch the glyph spin
return ChatMessage(text=text, outbound=True, peer="d4e5f6a7", acked=True)
screen = _screen(session, send=send)
for ch in "hi":
screen.handle("text", ch)
screen.handle("enter")
# While the send is held open the trailing glyph is a live spinner frame, and it advances.
await asyncio.sleep(0.03)
first = screen._spinner.frame
joined = "\n".join(screen.render_body(60))
assert first in Spinner.BRAILLE and first in joined and "⏳" not in joined
await asyncio.sleep(0.03)
assert screen._spinner.frame != first # the animation is actually running
# Once the ack lands, the spinner is gone and the message shows its delivered glyph.
release.set()
for _ in range(100): # let _send_direct unwind (ticker cancel + swap) before asserting
await asyncio.sleep(0.005)
if screen._messages[-1].acked is not None:
break
assert screen._messages[-1].acked is True
assert "✅" in "\n".join(screen.render_body(60))
def test_byte_counter_shows_used_over_limit_and_colors_only_used() -> None:
"""The compose bar shows ``used/limit`` with only the used count styled (the max is muted)."""
from rich.text import Text
screen = _screen(_StubSession(), send=None)
for ch in "hello":
screen.handle("text", ch)
counter = screen._byte_counter(80, screen._used_bytes(), screen._byte_limit())
assert isinstance(counter, Text)
assert counter.plain.strip() == "5/150" # 5 bytes used of the 150-byte direct-message cap
# Only the "5" carries a color; the "/150" tail stays muted (it never changes).
used_at = counter.plain.index("5")
slash_at = counter.plain.index("/")
used_spans = [s for s in counter.spans if s.start <= used_at < s.end]
tail_spans = [s for s in counter.spans if s.start <= slash_at < s.end]
assert used_spans and used_spans[0].style == "ok" # green with room to spare
assert tail_spans and tail_spans[0].style == "muted"
def test_byte_style_escalates_as_budget_runs_out() -> None:
"""The used-byte color steps green → yellow → orange → red as fewer bytes remain."""
from meshterm.ui.chat import _BYTES_ORANGE, _BYTES_YELLOW
style = ChatScreen._byte_style
assert style(80) == "ok" # plenty left → green
assert style(20) == _BYTES_YELLOW # within the tight band → yellow
assert style(10) == _BYTES_ORANGE # within the low band → orange
assert style(0) == "err" # limit reached → red
assert style(-5) == "err" # over the limit → still red
def test_channel_byte_limit_is_lower_than_direct() -> None:
"""A channel broadcast has a tighter byte budget than a direct message."""
from meshterm.ui.chat import _CHANNEL_BYTE_LIMIT, _DM_BYTE_LIMIT
direct = _screen(_StubSession(), send=None)
channel = _channel_screen([])
assert direct._byte_limit() == _DM_BYTE_LIMIT == 150
assert channel._byte_limit() == _CHANNEL_BYTE_LIMIT == 130
def test_overflow_counts_multibyte_characters_by_byte() -> None:
"""An emoji (4 UTF-8 bytes) counts as 4 toward the budget, and overflow marks whole chars."""
screen = _channel_screen([]) # 130-byte limit
# 32 emojis = 128 bytes (under), a 33rd tips to 132 (over) at that whole character.
for _ in range(33):
screen.handle("text", "😀")
assert screen._used_bytes() == 33 * 4
assert screen._overflow_at(screen._byte_limit()) == 32 # the 33rd emoji is the first over
async def test_over_limit_message_is_not_sent_and_buffer_is_kept() -> None:
"""Enter on an over-budget line reports the overage and sends nothing, keeping the text."""
session = _StubSession()
sent: list[str] = []
async def send(text: str) -> ChatMessage:
sent.append(text)
return ChatMessage(text=text, outbound=True, peer="d4e5f6a7", acked=True)
screen = _screen(session, send=send)
for ch in "x" * 151: # one byte past the 150-byte direct cap
screen.handle("text", ch)
screen.handle("enter")
await asyncio.sleep(0) # nothing should have been scheduled, but let the loop turn
assert sent == [] # the send was refused
assert screen._editor.text == "x" * 151 # buffer kept intact so the user can trim it
assert "Too long by 1 byte" in screen._status
# Trimming back under the limit clears the notice.
screen.handle("backspace")
assert screen._status == ""
def test_chat_screen_shows_delivery_glyphs() -> None:
"""Outbound direct messages end with delivery glyphs: a spinner, then ✅ / ❌."""
from meshterm.ui.tui.spinner import Spinner
messages = [
ChatMessage(text="delivered", outbound=True, peer="d4e5f6a7", acked=True),
ChatMessage(text="dropped", outbound=True, peer="d4e5f6a7", acked=False),
ChatMessage(text="inflight", outbound=True, peer="d4e5f6a7", acked=None),
]
screen = _screen(_StubSession(), send=None, messages=messages)
joined = "\n".join(screen.render_body(60))
# A message still awaiting its ack spins (a Braille frame) rather than showing the old
# static hourglass; resolved ones show ✅ / ❌.
assert "✅" in joined and "❌" in joined
assert Spinner.BRAILLE[0] in joined and "⏳" not in joined
# A failed message advertises the retry shortcut in the footer hint.
assert "Ctrl-R" in screen.footer_hint
def test_wrapped_body_hangs_under_the_first_line() -> None:
"""A body too long for the width wraps with a hanging indent under its own first line."""
from datetime import datetime, timezone
base = datetime(2026, 7, 5, 14, 24, tzinfo=timezone.utc)
# A run of short words guarantees several wrap points at a narrow width.
text = " ".join(["word"] * 20)
messages = [ChatMessage(text=text, peer="d4e5f6a7", created_at=base)]
screen = _screen(_StubSession(), send=None, messages=messages)
body_lines = [_strip_ansi(l) for l in screen._body_lines(text, messages[0], 30)]
assert len(body_lines) > 1 # it actually wrapped
stamp = base.astimezone().strftime("%H:%M")
indent = body_lines[0].index(stamp) + len(stamp) + 2 # gutter: " HH:MM "
first_word = body_lines[0].index("word")
assert first_word == indent
# Continuation lines start their text at the same column as the first line's body.
for cont in body_lines[1:]:
assert cont.startswith(" " * indent)
assert cont[indent] != " " # text resumes exactly under the body, not the gutter
def test_at_mention_renders_as_name_in_sender_hue() -> None:
"""An ``@[Name]`` token renders as a bare ``@Name`` colored in that sender's hue."""
from datetime import datetime, timezone
from meshterm.ui.chat import _SENDER_COLORS
base = datetime(2026, 7, 5, 14, 24, tzinfo=timezone.utc)
conv = Conversation(label="#public", is_channel=True, channel_idx=0)
message = ChatMessage(
text="Bob: @[Alice] you around?", is_channel=True, channel_idx=0, created_at=base
)
screen = ChatScreen(conv, [message], send=None, names={}, session=_StubSession())
_, body = screen._sender_and_body(message)
text = screen._render_mentions(body, selected=False)
assert "@Alice" in text.plain # bracketed token collapsed to a bare mention
assert "@[Alice]" not in text.plain and "[Alice]" not in text.plain
# The "@Alice" run carries Alice's palette hue (the same the header would use).
hue = screen._sender_style("Alice")
assert hue in _SENDER_COLORS
at = text.plain.index("@Alice")
hue_spans = [
s for s in text.spans if s.style == hue and s.start <= at and at + len("@Alice") <= s.end
]
assert hue_spans
def test_direct_transcript_groups_under_sender_headers() -> None:
"""Direct chats use the same grouped layout as channels: one header per sender run."""
from datetime import datetime, timezone
base = datetime(2026, 7, 5, 14, 24, tzinfo=timezone.utc)
messages = [
ChatMessage(text="hi", peer="d4e5f6a7", created_at=base),
ChatMessage(text="you there?", peer="d4e5f6a7", created_at=base),
ChatMessage(text="yes!", outbound=True, peer="d4e5f6a7", acked=True, created_at=base),
]
screen = _screen(_StubSession(), send=None, messages=messages)
rendered = _strip_ansi("\n".join(screen._render_grouped(80)))
# Inbound sender resolves to the contact name (from the names map), not the raw key.
assert rendered.count("Alice") == 1 # the two inbound messages share one header
assert "d4e5f6a7" not in rendered # the key is never shown when a name is known
assert "you" in rendered # our own reply gets its own header
assert "hi" in rendered and "you there?" in rendered and "yes!" in rendered
assert "✅" in rendered # the outbound message keeps its delivery glyph
async def test_chat_screen_retry_resends_failed_message() -> None:
"""Ctrl-R re-attempts the latest unacknowledged message, flipping it in place."""
session = _StubSession()
failed = ChatMessage(text="oops", outbound=True, peer="d4e5f6a7", acked=False, row_id=7)
async def resend(message: ChatMessage) -> ChatMessage:
message.acked = True # the retry gets through this time
return message
conv = Conversation(
label="Alice",
is_channel=False,
contact=Contact(name="Alice", public_key="d4" + "0" * 62, key_prefix="d4e5f6a7"),
)
screen = ChatScreen(
conv, [failed], send=None, names={}, session=session, resend=resend
)
screen.handle("retry")
await asyncio.sleep(0) # let the scheduled resend task run
assert screen._messages[-1].acked is True # same object, now acknowledged
assert "✅" in "\n".join(screen.render_body(60))
def test_chat_screen_scroll_detaches_and_end_reattaches() -> None:
"""Scrolling up detaches from the live tail; End re-sticks to the bottom."""
screen = _screen(_StubSession(), send=None)
assert screen._stick is True
screen.handle("up")
assert screen._stick is False
screen.handle("end")
assert screen._stick is True
def test_split_channel_sender_extracts_name_prefix() -> None:
"""A ``Name: message`` channel line splits into sender and cleaned body."""
from meshterm.ui.chat import _split_channel_sender
assert _split_channel_sender("Alice: hey there") == ("Alice", "hey there")
assert _split_channel_sender("Yagi Repeater: online") == ("Yagi Repeater", "online")
# No plausible prefix: left untouched.
assert _split_channel_sender("just a message") == (None, "just a message")
assert _split_channel_sender("https://example.com") == (None, "https://example.com")
assert _split_channel_sender("14:30 standup") == (None, "14:30 standup")
def test_channel_transcript_groups_by_sender() -> None:
"""Consecutive same-sender channel messages share one header; the body is cleaned."""
from datetime import datetime, timezone
conv = Conversation(label="#public", is_channel=True, channel_idx=0)
base = datetime(2026, 7, 5, 14, 24, tzinfo=timezone.utc)
def at(minutes: int) -> datetime:
return base.replace(minute=24 + minutes)
messages = [
ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0, created_at=at(0)),
ChatMessage(text="Alice: again", is_channel=True, channel_idx=0, created_at=at(6)),
ChatMessage(text="Bob: yo", is_channel=True, channel_idx=0, created_at=at(8)),
ChatMessage(text="hello all", outbound=True, is_channel=True, channel_idx=0, created_at=at(9)),
]
screen = ChatScreen(conv, messages, send=None, names={}, session=_StubSession())
rendered = _strip_ansi("\n".join(screen._render_grouped(80)))
assert rendered.count("Alice") == 1 # the two Alice messages share one header
assert "Bob" in rendered and "you" in rendered
assert "hi" in rendered and "again" in rendered # bodies present, prefix stripped
assert "Alice: hi" not in rendered # the raw name prefix is lifted into the header
# Each message keeps its own timestamp on its line, even when grouped under one sender.
stamps = [at(m).astimezone().strftime("%H:%M") for m in (0, 6, 8, 9)]
for stamp in stamps:
assert stamp in rendered
assert stamps[0] != stamps[1] # grouped Alice messages show distinct times
def test_channel_self_style_keyed_on_concept_not_label() -> None:
"""Our white 'self' style follows the message being outbound, not the 'you' label.
A remote sender who happens to be named 'you' must still get a palette hue, never the
white style reserved for us.
"""
from meshterm.ui.chat import _SENDER_COLORS
screen = _channel_screen([])
assert screen._sender_style("you", is_self=True) == "you" # us → white
remote = screen._sender_style("you", is_self=False)
assert remote != "you" and remote in _SENDER_COLORS # remote 'you' → a normal hue
def test_channel_own_messages_do_not_merge_with_remote_namesake() -> None:
"""A remote sender literally named 'you' groups separately from our own messages."""
from datetime import datetime, timezone
base = datetime(2026, 7, 5, 14, 24, tzinfo=timezone.utc)
messages = [
ChatMessage(text="you: impostor", is_channel=True, channel_idx=0, created_at=base),
ChatMessage(text="mine", outbound=True, is_channel=True, channel_idx=0, created_at=base),
]
screen = _channel_screen(messages)
rendered = _strip_ansi("\n".join(screen._render_grouped(80)))
assert rendered.count("you") == 2 # two separate headers, not one merged group
def _channel_screen(messages, session=None) -> ChatScreen:
"""Build a channel ChatScreen over ``messages`` for selection/reply tests."""
conv = Conversation(label="#public", is_channel=True, channel_idx=0)
return ChatScreen(
conv, messages, send=None, names={}, session=session or _StubSession()
)
def _channel_messages():
"""Three inbound channel messages (Alice, Alice, Bob) for reply-selection tests."""
from datetime import datetime, timezone
base = datetime(2026, 7, 5, 14, 24, tzinfo=timezone.utc)
return [
ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0, created_at=base),
ChatMessage(text="Alice: still here", is_channel=True, channel_idx=0, created_at=base),
ChatMessage(text="Bob: yo", is_channel=True, channel_idx=0, created_at=base),
]
def test_channel_up_enters_selection_from_newest() -> None:
"""No message is selected until ↑ picks the newest, then steps toward older ones."""
screen = _channel_screen(_channel_messages())
assert screen._selected is None # compose focus: nothing selected initially
screen.handle("up")
assert screen._selected == 2 # newest message
assert screen._stick is False
screen.handle("up")
assert screen._selected == 1 # steps to the previous message
def test_channel_down_past_newest_clears_selection() -> None:
"""Moving ↓ past the newest message returns focus to compose (nothing selected)."""
screen = _channel_screen(_channel_messages())
screen.handle("up") # select newest (index 2)
assert screen._selected == 2
screen.handle("down") # past the newest → deselect, re-stick to the tail
assert screen._selected is None
assert screen._stick is True
def test_channel_selected_line_tracked_for_scroll() -> None:
"""Rendering records the selected message's body line so the frame keeps it in view."""
screen = _channel_screen(_channel_messages())
screen.handle("up") # select Bob (newest)
screen.render_body(80)
assert screen._selected_line is not None
# No selection → no cursor line, so the transcript free-scrolls as before.
screen.handle("end")
screen.render_body(80)
assert screen._selected_line is None
def test_channel_enter_on_selection_primes_at_mention() -> None:
"""Enter on a picked message seeds the compose line with the sender's @mention."""
screen = _channel_screen(_channel_messages())
screen.handle("up")
screen.handle("up") # select an Alice message (index 1)
screen.handle("enter")
assert screen._editor.text == "@[Alice] "
assert screen._selected is None # focus returns to compose after starting the reply
def test_channel_typing_clears_selection() -> None:
"""Editing the compose line drops any reply selection (compose has focus)."""
screen = _channel_screen(_channel_messages())
screen.handle("up")
assert screen._selected is not None
screen.handle("text", "x")
assert screen._selected is None
assert screen._editor.text == "x"
def test_chat_screen_escape_cancels() -> None:
"""Esc resolves the screen's future with CANCEL so the caller pops it."""
screen = _screen(_StubSession(), send=None)
loop = asyncio.new_event_loop()
try:
screen.future = loop.create_future()
screen.handle("escape")
assert screen.future.result() is CANCEL
finally:
loop.close()
async def test_service_send_records_outbound(repo: Repository) -> None:
"""Sending through the service records the outbound message with its ack state."""
device = MockDevice()
ctx = _StubContext(device, repo)
chat = ChatService(ctx)
await chat.start()
try:
contact = next(c for c in await device.get_contacts() if c.name == "Alice")
sent = await chat.send_direct(contact, "hey")
assert sent.outbound is True and sent.acked is True
await chat.send_channel(0, "hello all", label="#public")
channel_id = await chat.channel_id_for(0)
stored = repo.recent_chat_messages(is_channel=True, channel_id=channel_id)
assert [m.text for m in stored] == ["hello all"]
assert stored[0].outbound is True
finally:
await chat.stop()
await device.disconnect()
async def test_service_resend_updates_ack_in_place(repo: Repository) -> None:
"""Resending a failed message flips its stored ack rather than adding a duplicate row."""
device = MockDevice()
ctx = _StubContext(device, repo)
chat = ChatService(ctx)
await chat.start()
try:
contact = next(c for c in await device.get_contacts() if c.name == "Alice")
peer = contact.key_prefix or contact.public_key[:12]
failed = ChatMessage(
text="retry me", outbound=True, peer=peer, peer_name=contact.name, acked=False
)
failed.row_id = repo.record_chat_message(failed)
await chat.resend_direct(contact, failed)
assert failed.acked is True # the mock always acknowledges
stored = repo.recent_chat_messages(is_channel=False, peer=peer)
assert len(stored) == 1 # updated in place, not duplicated
assert stored[0].acked is True
finally:
await chat.stop()
await device.disconnect()
# -- end-to-end through the real session --------------------------------------
async def test_open_chat_sends_through_real_session(tmp_path: Path) -> None:
"""Driving open_chat with piped keys sends a message and records it, end to end."""
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output import DummyOutput
from rich.console import Console
from meshterm.context import AppContext
from meshterm.core.admin_store import AdminStore
from meshterm.core.config import Settings
from meshterm.core.device_store import DeviceStore
from meshterm.ui.chat import open_chat
from meshterm.ui.surface import TuiUi
from meshterm.ui.tui.session import TuiSession
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "e2e.db")
ctx = AppContext(
console=Console(),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
conv = Conversation(
label="Alice",
is_channel=False,
contact=Contact(name="Alice", public_key="d4e5f6a7" + "0" * 56, key_prefix="d4e5f6a7"),
)
try:
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
ctx.ui = TuiUi(session)
async def main() -> None:
inp.send_text("hi\r\x1b") # type "hi", Enter (send), Esc (leave)
await open_chat(ctx, conv)
await asyncio.wait_for(session.run(main()), timeout=5)
stored = ctx.repo.recent_chat_messages(is_channel=False, peer="d4e5f6a7")
assert [m.text for m in stored] == ["hi"]
assert stored[0].outbound is True
finally:
await ctx.aclose()