Files
richandClaude f7f0036d0f Send a long chat message as numbered parts
A message that is too long for one packet now goes out as up to
message_parts_max parts (default 3, 1 turns it off), each ending with
its part number: [1/3], [2/3], [3/3]. Each part, with its part number,
fits in the byte limit of the conversation (130 for a channel, 150 for
a direct message). A cut goes at a space, and a word with no space to
cut at is cut between characters, never inside an emoji sequence or
after a combining accent.

Channel parts wait the transmit cooldown between them, so that the
floods do not go out as a burst. A direct part waits for the
acknowledgement of the part before it, or the end of its wait. An
unacknowledged part still lets the rest go out, and ^R retries each
one. A hard failure stops the rest and puts their text back in the
compose line. The byte gauge shows the number of parts and the bytes
of the last part. Rooms are not changed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 21:26:49 -04:00

250 lines
8.8 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for the parts of a long message: the cut, and how the chat screen sends them."""
from __future__ import annotations
import asyncio
import pytest
from meshterm.core import parts
from meshterm.core.models import ChatMessage, Contact, Conversation
from meshterm.core.preferences import Preferences
from meshterm.ui.chat import ChatScreen
LIMIT = 130
_TEXT = " ".join(f"word{i:03d}" for i in range(40)) # 319 bytes, words of 7 characters
def _bodies(cut: list[str]) -> list[str]:
return [parts.unnumbered(p, i, len(cut)) for i, p in enumerate(cut, 1)]
# --- the cut -----------------------------------------------------------------------------
def test_a_text_that_fits_is_one_part_with_no_part_number() -> None:
"""A text in the limit goes out as it is, with no part number."""
assert parts.split("hello", LIMIT, 3) == ["hello"]
assert parts.split("x" * LIMIT, LIMIT, 3) == ["x" * LIMIT]
def test_each_part_fits_and_ends_with_its_part_number() -> None:
"""Each part, with its part number, fits in the limit."""
cut = parts.split(_TEXT, LIMIT, 3)
assert cut is not None and len(cut) == 3
for i, part in enumerate(cut, 1):
assert len(part.encode("utf-8")) <= LIMIT
assert part.endswith(f" [{i}/3]")
def test_the_parts_cut_at_spaces_and_keep_all_the_words() -> None:
"""Each cut is at a space, and the parts together hold all the words."""
cut = parts.split(_TEXT, LIMIT, 3)
assert cut is not None
bodies = _bodies(cut)
assert " ".join(bodies) == _TEXT
for body in bodies:
assert body == body.strip()
assert all(word.startswith("word") and len(word) == 7 for word in body.split(" "))
def test_a_word_longer_than_a_part_is_cut_between_characters() -> None:
"""A word with no space to cut at is cut between two characters."""
url = "https://example.com/" + "a" * 200
cut = parts.split(url, LIMIT, 3)
assert cut is not None and len(cut) == 2
assert "".join(_bodies(cut)) == url
assert all(len(p.encode("utf-8")) <= LIMIT for p in cut)
def test_a_cut_never_divides_an_emoji_sequence_or_an_accent() -> None:
"""A cut does not divide a joined emoji, or an accent from its letter."""
family = "👨‍👩‍👧" # three emoji and two zero-width joiners: one symbol
cut = parts.split(family * 20, LIMIT, 9)
assert cut is not None
for body in _bodies(cut):
assert body == family * (len(body) // len(family))
accented = "é" * 100 # e and a combining acute accent, 3 bytes for each pair
cut = parts.split(accented, LIMIT, 9)
assert cut is not None
for body in _bodies(cut):
assert not body.startswith("́")
assert body.endswith("́")
def test_a_text_that_needs_too_many_parts_is_refused() -> None:
"""A text that needs more than the largest number of parts has no cut."""
assert parts.split("x" * 400, LIMIT, 3) is None
assert parts.split("x" * (LIMIT + 1), LIMIT, 1) is None
def test_the_part_number_grows_with_the_total() -> None:
"""The part number shows the index and the total."""
assert parts.part_number(1, 3) == " [1/3]"
assert parts.part_number(2, 9) == " [2/9]"
def test_overflow_at_finds_the_text_after_the_last_part() -> None:
"""The overflow starts where the text after the last part starts."""
assert parts.overflow_at("x" * LIMIT, LIMIT, 1) is None
assert parts.overflow_at("x" * (LIMIT + 5), LIMIT, 1) == LIMIT # the old rule
assert parts.overflow_at(_TEXT, LIMIT, 3) is None
long = "y" * 500
cut_at = parts.overflow_at(long, LIMIT, 3)
budget = LIMIT - len(parts.part_number(3, 3))
assert cut_at == 3 * budget
def test_unnumbered_removes_only_its_own_part_number() -> None:
"""Only the part number of the part itself is removed."""
assert parts.unnumbered("abc [2/3]", 2, 3) == "abc"
assert parts.unnumbered("abc [2/3]", 1, 3) == "abc [2/3]"
assert parts.unnumbered("abc", 1, 1) == "abc"
def test_the_preference_permits_three_parts_by_default() -> None:
"""The default of the preference is three parts."""
assert Preferences().message_parts_max == 3
# --- the chat screen ---------------------------------------------------------------------
class _Session:
def invalidate(self) -> None:
pass
def run_detached(self, work): # noqa: ANN001, ANN201
return asyncio.ensure_future(work)
def _screen(send, *, channel: bool, max_parts: int = 3, gap: float = 0.0) -> ChatScreen:
if channel:
conv = Conversation(label="#public", is_channel=True, channel_idx=0)
else:
conv = Conversation(
label="Alice",
is_channel=False,
contact=Contact(name="Alice", public_key="d4" + "0" * 62, key_prefix="d4e5f6a7"),
)
return ChatScreen(
conv,
[],
send=send,
names={},
session=_Session(),
max_parts=max_parts,
part_gap_s=gap,
)
async def _enter(screen: ChatScreen, text: str) -> None:
screen.handle("text", text)
screen.handle("enter")
await asyncio.gather(*list(screen._tasks))
async def test_a_channel_sends_each_part_in_order_with_the_gap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A channel sends the parts in order, with the gap between them."""
sent: list[str] = []
waits: list[float] = []
real_sleep = asyncio.sleep
async def sleep(delay: float) -> None:
waits.append(delay)
await real_sleep(0)
async def send(text: str) -> ChatMessage:
sent.append(text)
return ChatMessage(text=text, outbound=True, is_channel=True, channel_idx=0)
screen = _screen(send, channel=True, gap=5.0)
monkeypatch.setattr("meshterm.ui.chat.asyncio.sleep", sleep)
await _enter(screen, _TEXT)
assert sent == parts.split(_TEXT, LIMIT, 3)
assert waits == [5.0, 5.0] # between the parts, not before the first
assert [m.text for m in screen._messages] == sent
assert screen._status == ""
assert not screen._sending
async def test_a_direct_message_sends_each_part_after_the_one_before() -> None:
"""A direct part goes out only after the part before it."""
sent: list[str] = []
in_flight = 0
async def send(text: str) -> ChatMessage:
nonlocal in_flight
in_flight += 1
assert in_flight == 1 # one part at a time
await asyncio.sleep(0)
sent.append(text)
in_flight -= 1
return ChatMessage(text=text, outbound=True, peer="d4e5f6a7", acked=False)
screen = _screen(send, channel=False, max_parts=4)
await _enter(screen, _TEXT + " more")
assert len(sent) == 3
assert [m.text for m in screen._messages] == sent
assert all(m.acked is False for m in screen._messages) # an unacked part did not stop the rest
async def test_a_failed_part_stops_the_rest_and_keeps_their_text() -> None:
"""A part that fails stops the parts after it, and their text goes back."""
calls = 0
async def send(text: str) -> ChatMessage:
nonlocal calls
calls += 1
if calls == 2:
raise RuntimeError("radio busy")
return ChatMessage(text=text, outbound=True, peer="d4e5f6a7", acked=True)
screen = _screen(send, channel=False)
await _enter(screen, _TEXT)
cut = parts.split(_TEXT, 150, 3) # the limit of a direct message
assert cut is not None
assert calls == 2
assert len(screen._messages) == 1 # the failed bubble is gone
assert "radio busy" in screen._status
assert screen._editor.text == " ".join(_bodies(cut)[1:]) # parts 2 and 3, unnumbered
async def test_too_many_parts_is_refused_and_the_text_stays() -> None:
"""A text with too many parts is refused, and the text stays."""
sent: list[str] = []
async def send(text: str) -> ChatMessage:
sent.append(text)
return ChatMessage(text=text, outbound=True)
screen = _screen(send, channel=True, max_parts=2)
await _enter(screen, _TEXT)
assert sent == []
assert screen._editor.text == _TEXT
assert screen._status == "Too long for 2 parts — trim to send."
def test_the_gauge_counts_the_parts() -> None:
"""The gauge shows the number of parts and the bytes of the last part."""
screen = _screen(None, channel=True)
screen.handle("text", _TEXT)
gauge = screen._byte_counter(LIMIT).plain
cut = parts.split(_TEXT, LIMIT, 3)
assert cut is not None
assert gauge == f"3 parts · {len(cut[-1].encode('utf-8'))}/{LIMIT}"
assert screen._overflow_at(LIMIT) is None # nothing is marked red
def test_one_part_keeps_the_old_gauge_and_the_old_overflow() -> None:
"""With one part, the gauge and the overflow are as before."""
screen = _screen(None, channel=True, max_parts=1)
screen.handle("text", "x" * (LIMIT + 2))
assert screen._byte_counter(LIMIT).plain == f"{LIMIT + 2}/{LIMIT}"
assert screen._overflow_at(LIMIT) == LIMIT