Files
MeshTerm/tests/test_courier.py
T
Jean-Pierre MartineauandClaude Opus 5 61dc9dc110 Every icon-led list measures its icon column, so no label starts a column early
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>
2026-09-11 02:02:05 -04:00

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]