Files
MeshTerm/tests/test_courier.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

630 lines
25 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""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]