The terminal draws some icons in one cell (↻ ⌨ 🗑 ✓ ✗ ⚠) and most emoji in two, so a row written icon-plus-space started its words a column left of its siblings. The fix kept landing one screen at a time. menus.align_icons now pads every icon in a list out to the widest one, and menu_rows applies it itself, so a list built there cannot misalign. Hand-built lists are brought along: the courier outbox and entry actions, the watchtower actions and rule popover, the record card's actions, the main menu's Quit row and the device splash's action rows. The repeater admin's regression test, dropped by a concurrent rewrite of its file, is restored. tests/test_icon_alignment.py sweeps every gallery screen on both platforms and fails, naming the rows and their start cells, when a list's icon-led rows start their words in different cells. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
629 lines
25 KiB
Python
629 lines
25 KiB
Python
"""Courier tests: the outbox store, delivery eligibility, and the attempt flow.
|
|
|
|
The service is driven synchronously (eligibility) and through stubbed chat sends
|
|
(attempts), so no timers or hardware are involved.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from rich.cells import cell_len
|
|
from rich.text import Text
|
|
|
|
from meshterm.core import exitcodes
|
|
from meshterm.core.courier_store import DONE_CAP, QUEUED, CourierStore
|
|
from meshterm.core.models import ChatMessage, Contact, utcnow
|
|
from meshterm.core.watch_store import WatchStore
|
|
from meshterm.services.courier import FRESH_S, MAX_ATTEMPTS, CourierService
|
|
from meshterm.ui.courier_screen import (
|
|
CANCEL_SCHEDULE,
|
|
WHEN_HEARD,
|
|
_entry_actions,
|
|
_pick_schedule,
|
|
parse_clock,
|
|
)
|
|
|
|
NODE = "3d" * 6
|
|
CONTACT = Contact(name="Hub", public_key="3d" * 32)
|
|
|
|
|
|
class _StubChat:
|
|
"""Scripted chat service: pops the next ack outcome per send."""
|
|
|
|
def __init__(self, outcomes: list[bool]) -> None:
|
|
self.outcomes = list(outcomes)
|
|
self.sent: list[tuple[str, str]] = []
|
|
|
|
async def send_direct(self, contact: Contact, text: str) -> ChatMessage:
|
|
self.sent.append((contact.name, text))
|
|
acked = self.outcomes.pop(0) if self.outcomes else False
|
|
return ChatMessage(text=text, outbound=True, acked=acked)
|
|
|
|
|
|
class _StubDevice:
|
|
async def get_contacts(self) -> list[Contact]:
|
|
return [CONTACT]
|
|
|
|
|
|
class _StubContext:
|
|
"""Minimal stand-in for :class:`~meshterm.context.AppContext`."""
|
|
|
|
def __init__(self, tmp_path: Path, outcomes: list[bool]) -> None:
|
|
self.courier_store = CourierStore(tmp_path / "courier.json")
|
|
self.watch_store = WatchStore(tmp_path / "watchtower.json")
|
|
self.chat = _StubChat(outcomes)
|
|
self.is_connected = True
|
|
self.log = logging.getLogger("test.courier")
|
|
self._device = _StubDevice()
|
|
|
|
async def device(self) -> _StubDevice:
|
|
return self._device
|
|
|
|
|
|
def _service(tmp_path: Path, outcomes: list[bool]) -> CourierService:
|
|
return CourierService(_StubContext(tmp_path, outcomes))
|
|
|
|
|
|
# --- the store --------------------------------------------------------------------------
|
|
|
|
|
|
def test_store_queue_round_trips_and_persists(tmp_path: Path) -> None:
|
|
"""Queued entries survive a fresh store instance with their schedule intact."""
|
|
path = tmp_path / "courier.json"
|
|
store = CourierStore(path)
|
|
when = utcnow() + timedelta(hours=8)
|
|
queued = store.queue(NODE, "Hub", "hello there", not_before=when)
|
|
assert queued.ident == 1 and queued.status == QUEUED
|
|
|
|
again = CourierStore(path)
|
|
entry = again.get(1)
|
|
assert entry is not None and entry.text == "hello there"
|
|
assert entry.not_before == when
|
|
assert again.pending_count() == 1
|
|
|
|
|
|
def test_store_lifecycle_attempts_finish_cancel_clear(tmp_path: Path) -> None:
|
|
"""Attempt marks, delivery, cancellation, and clearing all behave."""
|
|
store = CourierStore(tmp_path / "courier.json")
|
|
a = store.queue(NODE, "Hub", "one")
|
|
b = store.queue(NODE, "Hub", "two")
|
|
store.note_attempt(a.ident)
|
|
assert store.get(a.ident).attempts == 1
|
|
store.mark_delivered(a.ident)
|
|
assert store.pending() == [store.get(b.ident)]
|
|
assert store.cancel(b.ident) is True
|
|
assert store.pending_count() == 0
|
|
assert store.get(a.ident) is not None # delivered history remains
|
|
store.clear_done()
|
|
assert store.entries() == []
|
|
|
|
|
|
def test_store_caps_the_finished_history(tmp_path: Path) -> None:
|
|
"""Old finished entries fall off; the waiting queue is never trimmed."""
|
|
store = CourierStore(tmp_path / "courier.json")
|
|
keeper = store.queue(NODE, "Hub", "still waiting")
|
|
for i in range(DONE_CAP + 5):
|
|
entry = store.queue(NODE, "Hub", f"m{i}")
|
|
store.mark_delivered(entry.ident)
|
|
done = [m for m in store.entries() if m.status != QUEUED]
|
|
assert len(done) == DONE_CAP
|
|
assert store.get(keeper.ident) is not None
|
|
|
|
|
|
# --- eligibility -------------------------------------------------------------------------
|
|
|
|
|
|
def test_eligibility_waits_for_freshness_and_schedule(tmp_path: Path) -> None:
|
|
"""Plain entries need the contact heard; scheduled ones hold until their time."""
|
|
service = _service(tmp_path, [])
|
|
store = service._ctx.courier_store
|
|
now = utcnow()
|
|
|
|
plain = store.queue(NODE, "Hub", "hi")
|
|
assert not service.eligible(plain, now) # never heard this session
|
|
service._heard[NODE] = now - timedelta(seconds=FRESH_S + 1)
|
|
assert not service.eligible(plain, now) # heard, but too long ago
|
|
service._heard[NODE] = now
|
|
assert service.eligible(plain, now)
|
|
|
|
scheduled = store.queue(NODE, "Hub", "later", not_before=now + timedelta(hours=1))
|
|
assert not service.eligible(scheduled, now) # the schedule holds it
|
|
# Past its time, a scheduled entry's *first* shot fires even unheard-of.
|
|
service._heard.clear()
|
|
assert service.eligible(scheduled, now + timedelta(hours=2))
|
|
|
|
|
|
def test_eligibility_backs_off_after_failures(tmp_path: Path) -> None:
|
|
"""A failed attempt waits out its (doubling) backoff even when the contact is fresh."""
|
|
service = _service(tmp_path, [])
|
|
store = service._ctx.courier_store
|
|
now = utcnow()
|
|
entry = store.queue(NODE, "Hub", "hi")
|
|
service._heard[NODE] = now
|
|
store.note_attempt(entry.ident, when=now)
|
|
entry = store.get(entry.ident)
|
|
assert not service.eligible(entry, now + timedelta(minutes=2))
|
|
assert service.eligible(entry, now + timedelta(minutes=6)) # 5-min base elapsed
|
|
assert service.next_retry_s(entry, now + timedelta(minutes=2)) is not None
|
|
|
|
|
|
# --- the attempt flow ---------------------------------------------------------------------
|
|
|
|
|
|
async def test_attempt_delivers_and_raises_the_good_news(tmp_path: Path) -> None:
|
|
"""An acknowledged send settles the entry and lights the Watchtower badge."""
|
|
service = _service(tmp_path, [True])
|
|
ctx = service._ctx
|
|
entry = ctx.courier_store.queue(NODE, "Hub", "hello")
|
|
service._heard[NODE] = utcnow()
|
|
|
|
await service._pass()
|
|
assert ctx.chat.sent == [("Hub", "hello")]
|
|
settled = ctx.courier_store.get(entry.ident)
|
|
assert settled.status == "delivered" and settled.attempts == 1
|
|
alerts = ctx.watch_store.alerts()
|
|
assert len(alerts) == 1 and alerts[0].kind == "courier"
|
|
assert "delivered" in alerts[0].message
|
|
|
|
|
|
async def test_attempt_gives_up_after_the_budget(tmp_path: Path) -> None:
|
|
"""Unacknowledged attempts exhaust the budget and say so, exactly once."""
|
|
service = _service(tmp_path, [False] * MAX_ATTEMPTS)
|
|
ctx = service._ctx
|
|
entry = ctx.courier_store.queue(NODE, "Hub", "hello")
|
|
for _ in range(MAX_ATTEMPTS):
|
|
message = ctx.courier_store.get(entry.ident)
|
|
await service._attempt(message)
|
|
settled = ctx.courier_store.get(entry.ident)
|
|
assert settled.status == "gave-up" and settled.attempts == MAX_ATTEMPTS
|
|
gave = [a for a in ctx.watch_store.alerts() if "gave up" in a.message]
|
|
assert len(gave) == 1
|
|
|
|
|
|
async def test_pass_attempts_at_most_one_entry(tmp_path: Path) -> None:
|
|
"""A backlog drains one message per pass — the courier never bursts."""
|
|
service = _service(tmp_path, [True, True])
|
|
ctx = service._ctx
|
|
ctx.courier_store.queue(NODE, "Hub", "first")
|
|
ctx.courier_store.queue(NODE, "Hub", "second")
|
|
service._heard[NODE] = utcnow()
|
|
await service._pass()
|
|
assert len(ctx.chat.sent) == 1
|
|
await service._pass()
|
|
assert len(ctx.chat.sent) == 2
|
|
assert [t for _n, t in ctx.chat.sent] == ["first", "second"] # oldest first
|
|
|
|
|
|
async def test_unknown_contact_spends_no_budget(tmp_path: Path) -> None:
|
|
"""A recipient the device doesn't know yet stays queued, untouched."""
|
|
service = _service(tmp_path, [True])
|
|
ctx = service._ctx
|
|
entry = ctx.courier_store.queue("ff" * 6, "Stranger", "hello")
|
|
outcome = await service._attempt(ctx.courier_store.get(entry.ident))
|
|
assert outcome == "unknown contact"
|
|
settled = ctx.courier_store.get(entry.ident)
|
|
assert settled.status == QUEUED and settled.attempts == 0
|
|
assert ctx.chat.sent == []
|
|
|
|
|
|
async def test_attempt_now_forces_a_send(tmp_path: Path) -> None:
|
|
"""The screen's Send now works regardless of freshness or schedule."""
|
|
service = _service(tmp_path, [True])
|
|
ctx = service._ctx
|
|
entry = ctx.courier_store.queue(NODE, "Hub", "hello", not_before=utcnow() + timedelta(hours=8))
|
|
assert await service.attempt_now(entry.ident) == "delivered"
|
|
assert await service.attempt_now(entry.ident) == "gone" # already settled
|
|
|
|
|
|
# --- the scripted CLI actions -------------------------------------------------------------
|
|
|
|
|
|
class _NoteUi:
|
|
"""A UI surface that just collects notes and shown renderables."""
|
|
|
|
def __init__(self) -> None:
|
|
self.notes: list[str] = []
|
|
self.shown: list[object] = []
|
|
|
|
def note(self, markup: str) -> None:
|
|
self.notes.append(markup)
|
|
|
|
def ack(self, markup: str) -> None:
|
|
# The menu's answer: an acknowledgement is a note. (The scripted CLI drops it.)
|
|
self.note(markup)
|
|
|
|
def show(self, *renderables: object) -> None:
|
|
self.shown.extend(renderables)
|
|
|
|
|
|
class _StubCourier:
|
|
"""A courier service whose ``attempt_now`` returns a canned outcome."""
|
|
|
|
def __init__(self, store: CourierStore, outcome: str) -> None:
|
|
self._store = store
|
|
self._outcome = outcome
|
|
|
|
async def attempt_now(self, ident: int) -> str:
|
|
message = self._store.get(ident)
|
|
if message is None or message.status != QUEUED:
|
|
return "gone"
|
|
if self._outcome == "delivered":
|
|
self._store.mark_delivered(ident)
|
|
return self._outcome
|
|
|
|
|
|
class _ToolCtx:
|
|
"""Minimal context for the courier tool's scripted CLI actions (no device needed)."""
|
|
|
|
def __init__(self, tmp_path: Path, *, outcome: str = "delivered") -> None:
|
|
self.courier_store = CourierStore(tmp_path / "courier.json")
|
|
self.ui = _NoteUi()
|
|
self.courier = _StubCourier(self.courier_store, outcome)
|
|
|
|
|
|
async def test_cli_list_renders_waiting_and_finished(tmp_path: Path) -> None:
|
|
"""`courier list` shows the outbox without needing a device."""
|
|
from meshterm.tools.courier import CourierTool
|
|
|
|
ctx = _ToolCtx(tmp_path)
|
|
ctx.courier_store.queue(NODE, "Hub", "still waiting")
|
|
done = ctx.courier_store.queue(NODE, "Hub", "landed")
|
|
ctx.courier_store.mark_delivered(done.ident)
|
|
|
|
result = await CourierTool().run(ctx, {"cli_action": "list"})
|
|
assert result.summary == {"entries": 2}
|
|
# The outbox is *stated*, not printed: the tool hands back the rows and the CLI
|
|
# boundary picks a renderer for them (see meshterm.ui.report).
|
|
listing = result.report[0]
|
|
assert sorted(row["state"] for row in listing.rows) == ["delivered", "waiting"]
|
|
assert sorted(row["text"] for row in listing.rows) == ["landed", "still waiting"]
|
|
|
|
|
|
async def test_cli_list_empty_notes_and_counts_zero(tmp_path: Path) -> None:
|
|
"""An empty outbox reports zero entries and shows no table."""
|
|
from meshterm.tools.courier import CourierTool
|
|
|
|
ctx = _ToolCtx(tmp_path)
|
|
result = await CourierTool().run(ctx, {"cli_action": "list"})
|
|
assert result.summary == {"entries": 0}
|
|
assert result.exit_code == exitcodes.NO_RESULT
|
|
# An empty listing, not an absent one: the plain face draws nothing from it and the
|
|
# machine face gets `[]`, which is a document a consumer can read.
|
|
assert result.report[0].rows == []
|
|
|
|
|
|
async def test_cli_cancel_removes_a_waiting_entry(tmp_path: Path) -> None:
|
|
"""`courier cancel` drops a waiting entry; a second cancel is a no-op."""
|
|
from meshterm.tools.courier import CourierTool
|
|
|
|
ctx = _ToolCtx(tmp_path)
|
|
entry = ctx.courier_store.queue(NODE, "Hub", "nope")
|
|
tool = CourierTool()
|
|
|
|
result = await tool.run(ctx, {"cli_action": "cancel", "id": entry.ident})
|
|
assert result.summary == {"id": entry.ident, "cancelled": True}
|
|
assert ctx.courier_store.get(entry.ident) is None
|
|
|
|
again = await tool.run(ctx, {"cli_action": "cancel", "id": entry.ident})
|
|
assert again.summary == {"id": entry.ident, "cancelled": False}
|
|
|
|
|
|
async def test_cli_send_forces_one_attempt(tmp_path: Path) -> None:
|
|
"""`courier send` forces one delivery attempt and reports the outcome."""
|
|
from meshterm.tools.courier import CourierTool
|
|
|
|
ctx = _ToolCtx(tmp_path, outcome="delivered")
|
|
entry = ctx.courier_store.queue(NODE, "Hub", "hello")
|
|
result = await CourierTool().run(ctx, {"cli_action": "send", "id": entry.ident})
|
|
assert result.summary == {"id": entry.ident, "outcome": "delivered"}
|
|
|
|
|
|
async def test_cli_send_unknown_entry_is_a_bad_argument(tmp_path: Path) -> None:
|
|
"""An id nobody has is a *usage* error, and it used to claim the device had failed.
|
|
|
|
The outbox is a local file: nothing was transmitted, and no retry will make an id
|
|
exist. Exit 4 tells a caller "the radio was reached and the operation failed, try
|
|
again" — which sent a scheduled sender into a retry loop over a typo. Exit 2 says fix
|
|
the command, which is the truth.
|
|
"""
|
|
import typer
|
|
|
|
from meshterm.tools.courier import CourierTool
|
|
|
|
ctx = _ToolCtx(tmp_path)
|
|
with pytest.raises(typer.BadParameter):
|
|
await CourierTool().run(ctx, {"cli_action": "send", "id": 999})
|
|
|
|
|
|
async def test_cli_clear_drops_finished_only(tmp_path: Path) -> None:
|
|
"""`courier clear` removes finished entries and leaves the waiting queue."""
|
|
from meshterm.tools.courier import CourierTool
|
|
|
|
ctx = _ToolCtx(tmp_path)
|
|
ctx.courier_store.queue(NODE, "Hub", "still here")
|
|
done = ctx.courier_store.queue(NODE, "Hub", "gone soon")
|
|
ctx.courier_store.mark_delivered(done.ident)
|
|
|
|
result = await CourierTool().run(ctx, {"cli_action": "clear"})
|
|
assert result.summary == {"cleared": 1}
|
|
assert [m.text for m in ctx.courier_store.entries()] == ["still here"]
|
|
|
|
|
|
# --- the clock parser ---------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_clock_finds_the_next_occurrence() -> None:
|
|
"""HH:MM resolves to the next future occurrence, local, returned as UTC."""
|
|
now = utcnow()
|
|
when = parse_clock("07:00", now)
|
|
assert when is not None and when > now
|
|
assert (when - now) <= timedelta(days=1)
|
|
assert when.astimezone().hour == 7 and when.astimezone().minute == 0
|
|
assert parse_clock("7h30", now) is not None
|
|
assert parse_clock("25:00", now) is None
|
|
assert parse_clock("soonish", now) is None
|
|
|
|
|
|
# --- the schedule picker ------------------------------------------------------------------
|
|
|
|
|
|
class _StubScheduleSession:
|
|
"""Scripts the when-to-send picker: ``choose`` maps the offered rows to a selection."""
|
|
|
|
def __init__(self, choose) -> None:
|
|
self._choose = choose
|
|
|
|
async def select(self, title, items, **kwargs):
|
|
return self._choose(items)
|
|
|
|
async def text(self, *args, **kwargs): # pragma: no cover - unused on these paths
|
|
return ""
|
|
|
|
|
|
class _ScheduleCtx:
|
|
def __init__(self, session) -> None:
|
|
self.ui = SimpleNamespace(session=session)
|
|
|
|
|
|
async def test_pick_schedule_when_next_heard_returns_no_schedule() -> None:
|
|
"""Picking *When it's next heard* returns ``None`` (queue with no hold), not a cancel.
|
|
|
|
Regression: the row once carried the value ``None``, which ``session.select`` also
|
|
returns on Esc, so choosing it read as a cancel and the message was never queued.
|
|
"""
|
|
ctx = _ScheduleCtx(_StubScheduleSession(lambda items: items[0].value))
|
|
result = await _pick_schedule(ctx, "Hub")
|
|
assert result is None
|
|
assert result is not CANCEL_SCHEDULE
|
|
assert WHEN_HEARD is not None # the row's own sentinel, distinct from Esc's None
|
|
|
|
|
|
async def test_pick_schedule_esc_cancels_the_queueing() -> None:
|
|
"""Esc on the picker (``select`` returns ``None``) backs out without queueing anything."""
|
|
ctx = _ScheduleCtx(_StubScheduleSession(lambda items: None))
|
|
assert await _pick_schedule(ctx, "Hub") is CANCEL_SCHEDULE
|
|
|
|
|
|
async def test_pick_schedule_a_fixed_delay_holds_until_its_time() -> None:
|
|
"""A concrete offset row (e.g. In 1 h) comes back as its aware future datetime."""
|
|
ctx = _ScheduleCtx(_StubScheduleSession(lambda items: items[1].value)) # In 1 h
|
|
result = await _pick_schedule(ctx, "Hub")
|
|
assert result is not None and result is not CANCEL_SCHEDULE
|
|
assert result > utcnow()
|
|
|
|
|
|
# --- the live outbox screen --------------------------------------------------------------
|
|
|
|
|
|
def _outbox_ctx(tmp_path: Path) -> _StubContext:
|
|
ctx = _StubContext(tmp_path, [])
|
|
ctx.courier = CourierService(ctx)
|
|
return ctx
|
|
|
|
|
|
def _outbox_plain(screen, width: int = 100) -> str:
|
|
import re
|
|
|
|
return "\n".join(re.sub(r"\x1b\[[0-9;]*m", "", ln) for ln in screen.render_body(width))
|
|
|
|
|
|
def test_outbox_refresh_moves_a_delivered_entry_without_a_keypress(tmp_path: Path) -> None:
|
|
"""A delivery lands its row in Finished on refresh(), no action required."""
|
|
from meshterm.ui.courier_screen import CourierOutboxScreen
|
|
|
|
ctx = _outbox_ctx(tmp_path)
|
|
entry = ctx.courier_store.queue(NODE, "Hub", "hold this")
|
|
screen = CourierOutboxScreen(ctx)
|
|
before = _outbox_plain(screen)
|
|
assert "⏳ Hub" in before and "Finished" not in before
|
|
|
|
ctx.courier_store.mark_delivered(entry.ident)
|
|
screen.refresh()
|
|
after = _outbox_plain(screen)
|
|
assert "Finished" in after and re.search(r"✓ +Hub", after) and "⏳" not in after
|
|
|
|
|
|
def test_outbox_refresh_keeps_the_highlight_on_its_entry(tmp_path: Path) -> None:
|
|
"""A row finishing above the cursor doesn't drag the highlight off its entry."""
|
|
from meshterm.ui.courier_screen import CourierOutboxScreen
|
|
|
|
ctx = _outbox_ctx(tmp_path)
|
|
first = ctx.courier_store.queue(NODE, "Hub", "first")
|
|
second = ctx.courier_store.queue(NODE, "Hub", "second")
|
|
screen = CourierOutboxScreen(ctx, default=("msg", second.ident))
|
|
assert screen._current_choice().value == ("msg", second.ident)
|
|
|
|
ctx.courier_store.mark_delivered(first.ident)
|
|
screen.refresh()
|
|
assert screen._current_choice().value == ("msg", second.ident)
|
|
|
|
|
|
def test_outbox_refresh_without_change_recomposes_nothing(tmp_path: Path) -> None:
|
|
"""An unchanged store shape leaves the row objects alone (no churn per tick)."""
|
|
from meshterm.ui.courier_screen import CourierOutboxScreen
|
|
|
|
ctx = _outbox_ctx(tmp_path)
|
|
ctx.courier_store.queue(NODE, "Hub", "steady")
|
|
screen = CourierOutboxScreen(ctx)
|
|
items = screen._items
|
|
screen.refresh()
|
|
assert screen._items is items
|
|
|
|
|
|
def test_outbox_rows_recompute_live_state_per_repaint(tmp_path: Path) -> None:
|
|
"""The waiting row's text is a callable: a state change shows on the next paint."""
|
|
from meshterm.ui.courier_screen import CourierOutboxScreen
|
|
|
|
ctx = _outbox_ctx(tmp_path)
|
|
entry = ctx.courier_store.queue(NODE, "Hub", "patience")
|
|
screen = CourierOutboxScreen(ctx)
|
|
assert "waiting to hear the contact" in _outbox_plain(screen)
|
|
|
|
# An attempt just happened: the same row now shows the retry countdown —
|
|
# no refresh() needed, the callable title re-reads the entry on repaint.
|
|
ctx.courier_store.note_attempt(entry.ident)
|
|
assert "try 1" in _outbox_plain(screen)
|
|
|
|
|
|
def _title_plain(choice) -> str:
|
|
"""A row's label as plain text, resolving a live (callable) title the way a paint does."""
|
|
title = choice.title() if callable(choice.title) else choice.title
|
|
return title.plain if isinstance(title, Text) else title
|
|
|
|
|
|
def _outbox_titles(tmp_path: Path) -> list[str]:
|
|
"""Every row of an outbox holding one waiting, one delivered and one given-up entry."""
|
|
from meshterm.ui.courier_screen import CourierOutboxScreen
|
|
|
|
ctx = _outbox_ctx(tmp_path)
|
|
ctx.courier_store.queue(NODE, "Waiting", "hold this")
|
|
landed = ctx.courier_store.queue(NODE, "Landed", "got it")
|
|
ctx.courier_store.mark_delivered(landed.ident)
|
|
lost = ctx.courier_store.queue(NODE, "Lost", "never")
|
|
ctx.courier_store.mark_gave_up(lost.ident)
|
|
return [_title_plain(choice) for choice in CourierOutboxScreen(ctx)._choices()]
|
|
|
|
|
|
async def _entry_action_titles(tmp_path: Path) -> list[str]:
|
|
"""The labels a waiting entry's action menu offers (the menu is dismissed unanswered)."""
|
|
offered: list = []
|
|
|
|
class _Session:
|
|
async def select(self, title, items, **kwargs):
|
|
offered.extend(items)
|
|
return None
|
|
|
|
store = CourierStore(tmp_path / "courier.json")
|
|
entry = store.queue(NODE, "Hub", "hello")
|
|
ctx = SimpleNamespace(ui=SimpleNamespace(session=_Session()), courier_store=store)
|
|
await _entry_actions(ctx, entry.ident)
|
|
return [_title_plain(choice) for choice in offered]
|
|
|
|
|
|
def _word_starts(rows: list[str], leads: dict[str, str]) -> set[int]:
|
|
"""The display cell each row's first word starts in, checking the mark it leads with."""
|
|
starts = set()
|
|
for word, mark in leads.items():
|
|
row = next(row for row in rows if word in row)
|
|
assert row.startswith(mark), f"{word!r} lost its mark: {row!r}"
|
|
starts.add(cell_len(row[: row.index(word)]))
|
|
return starts
|
|
|
|
|
|
async def test_courier_rows_start_every_label_in_the_same_cell(tmp_path: Path) -> None:
|
|
"""Both of the courier's lists share one icon column, whatever each mark's width.
|
|
|
|
The outbox mixes two-cell marks (⏳ a waiting entry, 📨 the queue row) with one-cell ones
|
|
(✓ ✗ a finished entry, 🗑 the clear row), and wrote each as ``mark + " "`` — so a finished
|
|
recipient sat a column left of a waiting one and *Clear finished* a column left of
|
|
*Queue a message…*. An entry's actions did the same with 📤 over ✗. Cells, not
|
|
characters, are the measure: that is what the terminal lines up.
|
|
"""
|
|
outbox = {"Waiting": "⏳", "Queue": "📨", "Landed": "✓", "Lost": "✗", "Clear": "🗑"}
|
|
actions = {"Send": "📤", "Cancel": "✗"}
|
|
assert {cell_len(mark) for mark in outbox.values()} == {1, 2}, "a mixed list, or no proof"
|
|
assert {cell_len(mark) for mark in actions.values()} == {1, 2}
|
|
|
|
assert len(_word_starts(_outbox_titles(tmp_path), outbox)) == 1
|
|
assert len(_word_starts(await _entry_action_titles(tmp_path), actions)) == 1
|
|
|
|
|
|
async def test_courier_command_rows_go_bare_where_the_platform_draws_no_icons(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""No icon lane: the command icons go with no padding left, and the entry marks stay.
|
|
|
|
An entry's ✓/✗/⏳ is its outcome, not decoration, so the PicoCalc keeps it — and keeps
|
|
those rows aligned with one another — while the queue, clear, send and cancel rows lose
|
|
their icons exactly as every other command row does there.
|
|
"""
|
|
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
|
|
|
set_platform(PICOCALC)
|
|
try:
|
|
rows = _outbox_titles(tmp_path / "outbox")
|
|
assert "Queue a message…" in rows and "Clear finished" in rows
|
|
marks = {"Waiting": "⏳", "Landed": "✓", "Lost": "✗"}
|
|
assert len(_word_starts(rows, marks)) == 1
|
|
assert await _entry_action_titles(tmp_path / "actions") == [
|
|
"Send now — one forced attempt",
|
|
"Cancel this message",
|
|
]
|
|
finally:
|
|
set_platform(REGULAR)
|
|
|
|
|
|
# --- the recipient picker ----------------------------------------------------------------
|
|
|
|
|
|
def test_recipient_picker_rides_the_shared_node_list(tmp_path: Path) -> None:
|
|
"""The picker draws the full lanes, opens freshest-heard first, and Enter commits."""
|
|
from meshterm.ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING
|
|
from meshterm.ui.courier_screen import _PICK_HINT, CourierRecipientScreen
|
|
from meshterm.ui.widgets import ContactsSort
|
|
|
|
fresh = Contact(name="Fresh", public_key="aa" * 32, last_seen=utcnow())
|
|
stale = Contact(name="Stale", public_key="bb" * 32, last_seen=utcnow() - timedelta(days=2))
|
|
counts = {"aa" * 6: 7}
|
|
screen = CourierRecipientScreen(
|
|
contacts=[stale, fresh],
|
|
prefix_bytes=1,
|
|
counts=counts,
|
|
sort=ContactsSort.from_name("heard", SORT_COLUMNS, SORT_OPENS_ASCENDING),
|
|
)
|
|
import re
|
|
|
|
body = "\n".join(re.sub(r"\x1b\[[0-9;]*m", "", ln) for ln in screen.render_body(80))
|
|
assert "NAME" in body and "HEARD" in body and "PKTS" in body and "KEY" in body
|
|
assert body.index("Fresh") < body.index("Stale") # heard opens freshest-first
|
|
assert " 7" in body # Fresh's overheard tally fills the PKTS lane
|
|
assert screen.footer_hint == _PICK_HINT
|
|
|
|
# Enter resolves the highlighted row's value — the Contact itself.
|
|
resolved: list = []
|
|
screen.resolve = resolved.append # type: ignore[method-assign]
|
|
screen.handle("enter")
|
|
assert resolved == [fresh]
|
|
|
|
|
|
def test_recipient_picker_sort_keys_walk_the_ring(tmp_path: Path) -> None:
|
|
"""The same ^←→ keys the Nodes list uses re-sort the picker's columns."""
|
|
from meshterm.ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING
|
|
from meshterm.ui.courier_screen import CourierRecipientScreen
|
|
from meshterm.ui.widgets import ContactsSort
|
|
|
|
a = Contact(name="Alpha", public_key="aa" * 32, last_seen=utcnow())
|
|
z = Contact(name="Zulu", public_key="bb" * 32)
|
|
sort = ContactsSort.from_name("heard", SORT_COLUMNS, SORT_OPENS_ASCENDING)
|
|
screen = CourierRecipientScreen(contacts=[z, a], prefix_bytes=0, counts={}, sort=sort)
|
|
screen.handle("ctrl_left") # heard -> name (opens ascending)
|
|
assert sort.column == "name" and sort.ascending
|
|
values = [c.value for c in screen._choices()]
|
|
assert values == [a, z]
|