Three interrelated improvements to the chat/channel experience: Outgoing byte budget & unified transcript (ui/chat.py, ui/tui/prompt.py): - Cap outgoing messages at MeshCore's UTF-8 limits (150B direct, 130B channel) and block over-limit sends, keeping the buffer intact to trim. - Show a live `used/limit` gauge in the compose bar whose used count steps green→yellow→orange→red by bytes remaining, with truer yellow/orange hues. - Color the overflow characters red in the compose line (multibyte-safe). - Render direct chats with the same grouped, per-sender layout as channels (headers, day dividers) instead of one line per message. Direct-message delivery retry (services/chat_service.py, persistence, core/models.py, tools/chat.py, ui/tui/session.py): - Track outbound acked state (None awaiting / True / False) with a persisted row_id, add resend_direct + update_chat_ack, and wire Ctrl-R to retry the latest unacknowledged message in place with ⏳/✅/❌ delivery glyphs. Channel slot capacity discovery (core/channels.py, core/connection.py, ui/channels.py, tools): - Discover the firmware's real channel-slot count via a bounded read-only probe (Device.channel_capacity) instead of assuming 8; show used/total in the manager and refresh the chat slot→identity cache after any mutation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
808 lines
33 KiB
Python
808 lines
33 KiB
Python
"""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 meshtools.core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
|
||
from meshtools.core.connection import MockDevice
|
||
from meshtools.core.events import MeshEvent
|
||
from meshtools.core.models import (
|
||
ChatMessage,
|
||
Contact,
|
||
Conversation,
|
||
Message,
|
||
conversation_key,
|
||
)
|
||
from meshtools.persistence.repository import Repository
|
||
from meshtools.services.chat_service import ChatService
|
||
from meshtools.services.event_hub import EventHub
|
||
from meshtools.tools.chat import _LiveLasts, _preview, _title
|
||
from meshtools.ui.chat import ChatScreen
|
||
from meshtools.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:`~meshtools.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 meshtools.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(chan_in) == "Bob: hi"
|
||
dm_in = ChatMessage(text="hey", peer="aa", peer_name="Bob")
|
||
assert _preview(dm_in) == "hey"
|
||
mine = ChatMessage(text="yo", outbound=True, is_channel=True, channel_idx=0)
|
||
assert _preview(mine) == "you: yo"
|
||
|
||
|
||
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(long, width=10)
|
||
assert len(out) == 10 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 is a Text whose ``●`` glyph (only) is styled red; else a plain str."""
|
||
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, str) # no badge -> plain string, no styling
|
||
|
||
|
||
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})
|
||
l1 = _title(ctx, long, {"chan:c1": m1})
|
||
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, {}).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_glyph_reflects_conversation_history(repo: Repository) -> None:
|
||
"""A contact shows 💬 once we've exchanged messages, 👤 before any conversation exists."""
|
||
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).split(" ", 1)[0]
|
||
assert head({}) == "👤" # no history yet — a contact we haven't talked to
|
||
last = ChatMessage(text="hi", peer=contact.peer)
|
||
assert head({contact.key: last}) == "💬" # we've exchanged messages
|
||
|
||
|
||
# -- 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))
|
||
)
|
||
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))
|
||
)
|
||
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()
|
||
|
||
|
||
# -- 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
|
||
|
||
|
||
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 meshtools.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 meshtools.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:
|
||
"""Each outbound direct message ends with its delivery emoji (⏳ / ✅ / ❌)."""
|
||
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))
|
||
assert "✅" in joined and "❌" in joined and "⏳" in joined
|
||
# A failed message advertises the retry shortcut in the footer hint.
|
||
assert "Ctrl-R" in screen.footer_hint
|
||
|
||
|
||
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 meshtools.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 meshtools.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 meshtools.context import AppContext
|
||
from meshtools.core.admin_store import AdminStore
|
||
from meshtools.core.config import Settings
|
||
from meshtools.core.device_store import DeviceStore
|
||
from meshtools.ui.chat import open_chat
|
||
from meshtools.ui.surface import TuiUi
|
||
from meshtools.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()
|