`ruff format .` across the repo: 153 files, no behaviour change. This is the second gate CONTRIBUTING.md promises, and until now it reported 154 files needing reformat — so a first contributor ran the command the guide told them to run and could not tell their own diff from the background. Landed on its own, with nothing else in it, because a whole-repo reformat is only safe to skip past in blame if it is provably nothing but layout. It was checked that way rather than assumed: every changed file was formatted in memory and compared by full `ast.dump()`, and 152 of 153 come out structurally identical (the one exception was a docstring opening on a quote character, fixed in the commit before this one). The string and comment token streams were diffed separately, so a quote flip or an edited comment could not hide inside a re-layout — five quote-style normalisations and sixteen implicit-concatenation joins, all value-identical, and one commented-out line gaining the space after its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite is 1888 green, gallery gate and `test_theme16` included. What actually changed, in order of how much of the diff it is: wrapped calls and signatures rejoin onto one line where they fit inside the hundred columns; the magic trailing comma expands what it is there to expand; complex slices gain their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are laid out to be read were fenced in the previous commit and are untouched here. The aligned trailing-comment column is gone from about ninety lanes that were not fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced where it sits. Every comment stays on the entry it describes, so nothing there reads wrong; it just no longer lines up. Add this commit to `.git-blame-ignore-revs`, which follows immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
518 lines
20 KiB
Python
518 lines
20 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
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
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,
|
|
_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 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}
|
|
assert ctx.ui.shown # a table was rendered
|
|
|
|
|
|
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 ctx.ui.shown == []
|
|
|
|
|
|
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_errors(tmp_path: Path) -> None:
|
|
"""Sending a non-existent entry is a clean device-command error, not a crash."""
|
|
from meshterm.core.connection import DeviceCommandError
|
|
from meshterm.tools.courier import CourierTool
|
|
|
|
ctx = _ToolCtx(tmp_path)
|
|
with pytest.raises(DeviceCommandError):
|
|
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 "✓ Hub" in 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)
|
|
|
|
|
|
# --- 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]
|