A wait you can see is a wait you can leave

Every feature paced its own bursts privately, so nothing knew what anything else
had just put on the air, and a wait was a frozen screen -- which on a radio app
reads as a dead link rather than as courtesy.

There is a shared clock now. Every real transmission marks it, in the five send
methods that are the only places the radio is actually spoken to (the simulator
marks the same points, so the whole thing is walkable with no hardware). Anything
about to transmit can ask what is still owed.

Two clocks, because two kinds of transmission cost the mesh very differently. The
general one is the transmit cooldown, whose minimum is now 0.1s -- a floor, not an
off switch; there is no zero for duty-cycle courtesy. Flood adverts get their own,
floored at 5s and defaulting to a minute: every repeater in range rebroadcasts one,
and it waits out both clocks.

Under two seconds the wait just happens -- a dialog that flashes up is itself the
interruption. Over it, a countdown floats over whatever asked: the seconds left,
why it is waiting, and a Cancel chip. Enter and Esc both take it, sharing one hint
atom because they do the one thing on offer, and cancelling abandons the action
rather than merely the wait. It resolves itself at zero.

The suite resets the clock between tests. It is process-wide, which is right for a
session and wrong for a run: one test sending something left a cooldown standing
for the next, whose scripted flow then stopped on a countdown nobody wrote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-05 16:19:42 -04:00
1 parent 9de428f6ca
commit 1e4fad4d2d
9 files changed
+646 -3

No files matched your search

+11
View File
@@ -22,6 +22,7 @@ from dataclasses import replace
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Callable, Optional
from . import transmit_gate
from .channels import CHANNEL_SLOT_PROBE_CAP
from .events import MeshEvent
from .frames import frame_addressing, trace_link_snrs
@@ -2038,6 +2039,7 @@ class MeshCoreDevice(Device):
async def send_remote_command( # noqa: D102 - inherited docstring
self, node: Contact, command: str, *, timeout: float = 8.0
) -> Optional[str]:
transmit_gate.mark()
return await self._send_admin_cmd(node, command, timeout=timeout)
async def get_remote_tx_power(self, node: Contact) -> Optional[int]: # noqa: D102
@@ -2089,6 +2091,7 @@ class MeshCoreDevice(Device):
path: Optional[str] = None,
timeout: Optional[float] = None,
) -> TraceResult:
transmit_gate.mark()
from meshcore import EventType # local import keeps mock path dependency-free
from ..services.trace_runner import path_hash_flags, trace_timeout
@@ -2426,6 +2429,7 @@ class MeshCoreDevice(Device):
async def send_direct_message( # noqa: D102 - inherited docstring
self, contact: Contact, text: str
) -> Optional[Ack]:
transmit_gate.mark()
from meshcore import EventType
mc = self._require()
@@ -2457,6 +2461,7 @@ class MeshCoreDevice(Device):
async def send_channel_message( # noqa: D102 - inherited docstring
self, index: int, text: str
) -> None:
transmit_gate.mark()
self._ok(await self._require().commands.send_chan_msg(index, text))
@staticmethod
@@ -2642,6 +2647,7 @@ class MeshCoreDevice(Device):
self._ok(await self._require().commands.set_time(epoch))
async def send_advert(self, flood: bool = False) -> None: # noqa: D102
transmit_gate.mark(flood_advert=flood)
self._ok(await self._require().commands.send_advert(flood))
async def reboot(self) -> None: # noqa: D102 - inherited docstring
@@ -2835,6 +2841,7 @@ class MockDevice(Device):
async def send_direct_message( # noqa: D102 - inherited docstring
self, contact: Contact, text: str
) -> Optional[Ack]:
transmit_gate.mark()
await asyncio.sleep(0)
# Firmware can only address a contact it holds, so a recipient this simulated device
# doesn't have is refused exactly as hardware refuses one — which is what makes the
@@ -2848,6 +2855,7 @@ class MockDevice(Device):
async def send_channel_message( # noqa: D102 - inherited docstring
self, index: int, text: str
) -> None:
transmit_gate.mark()
await asyncio.sleep(0)
async def admin_login(self, node: Contact, password: str) -> LoginResult: # noqa: D102
@@ -2871,6 +2879,7 @@ class MockDevice(Device):
async def send_remote_command( # noqa: D102 - inherited docstring
self, node: Contact, command: str, *, timeout: float = 8.0
) -> Optional[str]:
transmit_gate.mark()
await asyncio.sleep(0)
key = self._mock_key(node)
if key not in self._admin_sessions:
@@ -3088,6 +3097,7 @@ class MockDevice(Device):
self._clock_offset = epoch - int(_time.time())
async def send_advert(self, flood: bool = False) -> None: # noqa: D102
transmit_gate.mark(flood_advert=flood)
await asyncio.sleep(0)
async def reboot(self) -> None: # noqa: D102 - inherited docstring
@@ -3271,6 +3281,7 @@ class MockDevice(Device):
path: Optional[str] = None,
timeout: Optional[float] = None,
) -> TraceResult:
transmit_gate.mark()
await asyncio.sleep(0.05) # mimic radio latency so progress bars are visible
forced = [h for h in path.split(",") if h.strip()] if path else None
# Mirror the real device: the path-hash width is the byte length of a forced hop.
+12 -1
View File
@@ -132,10 +132,21 @@ PREFERENCES: tuple[PrefSpec, ...] = (
group="Sending",
value_type="float",
default=5.0,
minimum=0.0,
minimum=0.1, # a floor, not a switch: there is no "off" for duty-cycle courtesy
maximum=60.0,
unit="s",
),
PrefSpec(
key="flood_advert_cooldown_s",
label="Flood cooldown",
help="Extra wait before another mesh-wide advert",
group="Sending",
value_type="float",
default=60.0,
minimum=5.0, # every repeater in range rebroadcasts one; five seconds is the floor
maximum=3600.0,
unit="s",
),
PrefSpec(
key="direct_message_soft_retries",
label="Message retries",
+125
View File
@@ -0,0 +1,125 @@
"""The shared transmit clock: how long ago we last put something on the air.
MeshTerm has always paced its own bursts — a trace waits between samples, a TX sweep
between levels — but each did it privately, inside its own loop. Nothing knew what
*another* feature had just transmitted, so a trace could finish and an advert go out a
tenth of a second later, and no screen could say why it was making you wait.
This is that missing clock. Every real transmission marks it (see the send methods in
:mod:`meshterm.core.connection`, which are the only place the radio is actually spoken
to), and anything about to transmit can ask :func:`remaining` how long is left before it
politely may. The waiting itself is the UI's job — see
:func:`meshterm.ui.cooldown.wait_for_cooldown`, which turns a wait worth noticing into a
countdown you can back out of.
Two clocks, because two kinds of transmission cost the mesh very different amounts:
* **every transmission**, spaced by the ``trace_cooldown_s`` preference. This is duty-cycle
courtesy: our own airtime, spread out.
* **flood adverts**, spaced by the longer ``flood_advert_cooldown_s``. A flood advert is
the most expensive thing a node can ask for — every repeater in range rebroadcasts it —
so it waits out both clocks, and its own is floored well above the general one.
Timing is :func:`time.monotonic`, not the wall clock: this measures an elapsed interval,
and a clock sync mid-session (which MeshTerm can itself trigger) must not make a cooldown
appear to have elapsed, or to have years left.
Like :func:`meshterm.core.preferences.current`, the gate is a process-wide singleton
rather than something passed down: the layer that transmits and the layer that waits are
far apart, and threading a clock between them would touch every call in between for
nothing.
"""
from __future__ import annotations
import time
from typing import Optional
class TransmitGate:
"""The last-transmission clock, and the wait it implies.
:meth:`mark` records that something went out; :meth:`remaining` says how long a
caller should hold off. A fresh gate has transmitted nothing, so nothing is owed.
"""
def __init__(self) -> None:
"""Start an idle gate — nothing sent, nothing owed."""
self._last_sent: Optional[float] = None
self._last_flood_advert: Optional[float] = None
def mark(self, *, flood_advert: bool = False) -> None:
"""Record a transmission as having just finished.
Called *after* the send returns, so the cooldown counts from the moment the air
was free again rather than from when we started talking.
Args:
flood_advert: Whether the transmission was a flood advert, which starts its
own longer clock in addition to the general one.
"""
now = time.monotonic()
self._last_sent = now
if flood_advert:
self._last_flood_advert = now
def remaining(self, *, flood_advert: bool = False) -> float:
"""Seconds a caller should wait before transmitting; ``0.0`` when it may go now.
Args:
flood_advert: Whether the transmission being considered is a flood advert. A
flood advert waits out *both* clocks — the general cooldown since anything
was sent, and the flood cooldown since the last flood advert — so this
returns whichever is longer.
Returns:
The wait in seconds, never negative.
"""
from .preferences import current as current_preferences
preferences = current_preferences()
wait = _left(self._last_sent, preferences.trace_cooldown_s)
if flood_advert:
wait = max(
wait, _left(self._last_flood_advert, preferences.flood_advert_cooldown_s)
)
return wait
def reset(self) -> None:
"""Forget both clocks, as if nothing had ever been sent.
For a fresh session and for tests; there is no user-facing way to clear a cooldown,
which would rather defeat it.
"""
self._last_sent = None
self._last_flood_advert = None
def _left(last: Optional[float], cooldown: float) -> float:
"""Seconds left of ``cooldown`` since ``last``, or ``0.0`` if it never happened."""
if last is None or cooldown <= 0:
return 0.0
return max(0.0, cooldown - (time.monotonic() - last))
#: The clock for this process. A module-level singleton for the reason given in the module
#: docstring: the transmitting layer and the waiting layer never meet.
_gate = TransmitGate()
def current() -> TransmitGate:
"""The transmit clock in force for this process."""
return _gate
def mark(*, flood_advert: bool = False) -> None:
"""Record a transmission on the process-wide clock (see :meth:`TransmitGate.mark`)."""
_gate.mark(flood_advert=flood_advert)
def remaining(*, flood_advert: bool = False) -> float:
"""The wait owed on the process-wide clock (see :meth:`TransmitGate.remaining`)."""
return _gate.remaining(flood_advert=flood_advert)
__all__ = ["TransmitGate", "current", "mark", "remaining"]
+16 -2
View File
@@ -1003,7 +1003,10 @@ async def _run_now(
async def send_advert(ctx: "AppContext") -> None:
"""Run the Send advert flow, behind the main menu's ``advert`` popup tool.
Send an advert (zero-hop or flood) or show this node's shareable contact card. Only
Send an advert (zero-hop or flood) or show this node's shareable contact card. Either
advert waits out the transmit cooldown first — a flood advert its own, longer one —
which is a countdown the reader can cancel where the wait is long enough to notice.
Only
``SELF_INFO`` is read here: the advert command itself never consults the snapshot,
and the contact card needs just the name, public key, and advert type — so opening
this skips the tuning/path-hash reads of a full
@@ -1038,7 +1041,18 @@ async def send_advert(ctx: "AppContext") -> None:
if choice == "share":
await _show_contact_card(ctx, snapshot)
return
await _run_now(ctx, device, snapshot, [("advert", choice == "flood")], "Advert")
# An advert is the one transmission a reader fires by hand, over and over, so it is
# where the shared transmit cooldown is felt (see meshterm.ui.cooldown). A wait worth
# noticing becomes a countdown they can back out of; a short one just happens.
from .cooldown import wait_for_cooldown
flood = choice == "flood"
if not await wait_for_cooldown(
ctx, action="Flood advert" if flood else "Zero-hop advert", flood_advert=flood
):
return
await _run_now(ctx, device, snapshot, [("advert", flood)], "Advert")
def contact_share_url(name: str, public_key: str, node_type: int = 1) -> str:
+98
View File
@@ -0,0 +1,98 @@
"""Waiting out a transmit cooldown, visibly and cancellably.
MeshTerm holds itself back between transmissions (see
:mod:`meshterm.core.transmit_gate`). A short hold is nothing anyone needs told about; a
long one, spent frozen, reads as a hung app on the very screen where "did the radio die?"
is the reader's first thought. So the wait has a threshold: under
:data:`SILENT_WAIT_S` it simply happens, and over it becomes a
:class:`~meshterm.ui.tui.prompt.CountdownDialog` — the seconds ticking down over a Cancel
chip, floating on the screen that asked, which the reader can back out of to abandon the
action entirely.
One function, :func:`wait_for_cooldown`, and its answer is the only thing a caller tests:
``True`` means the air is clear and the action may go ahead, ``False`` means the reader
changed their mind.
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from ..core import transmit_gate
if TYPE_CHECKING:
from ..context import AppContext
#: A wait at or under this many seconds passes without a word. Long enough to cover the
#: ordinary spacing between two transmissions, short enough that a reader never sits in
#: front of an unexplained stillness — a dialog that flashes up for half a second would
#: itself be the interruption.
SILENT_WAIT_S = 2.0
#: How often the countdown redraws. Fast enough that the seconds look like they are
#: counting rather than jumping, slow enough to cost nothing.
_TICK_S = 0.2
async def wait_for_cooldown(
ctx: "AppContext", *, action: str, flood_advert: bool = False
) -> bool:
"""Hold until the transmit cooldown has passed; ``False`` if the reader gave up.
Args:
ctx: Shared application context (for the UI surface).
action: What is waiting, named as the thing about to happen — it becomes the
dialog's title, so it reads "Flood advert", not "Waiting to send a flood
advert".
flood_advert: Whether the pending transmission is a flood advert, which waits out
its own longer clock as well as the general one.
Returns:
``True`` when the wait is over and the caller may transmit; ``False`` if the
countdown was cancelled.
"""
remaining = transmit_gate.remaining(flood_advert=flood_advert)
if remaining <= 0:
return True
if remaining <= SILENT_WAIT_S:
await asyncio.sleep(remaining)
return True
session = getattr(ctx.ui, "session", None)
if session is None:
# No full-screen session to float a dialog over (the scripted CLI): the wait is
# still owed, so it is still taken — just silently, as every other CLI pause is.
await asyncio.sleep(remaining)
return True
from .tui import CANCEL
from .tui.prompt import CountdownDialog
reason = (
"a flood advert reaches the whole mesh"
if flood_advert
else "spacing out our own transmissions"
)
dialog = CountdownDialog(action, remaining, reason=reason)
ticker = asyncio.ensure_future(_tick(session, dialog, flood_advert))
try:
return await session.run_screen(dialog) is not CANCEL
finally:
ticker.cancel()
async def _tick(session, dialog, flood_advert: bool) -> None: # noqa: ANN001
"""Feed the dialog the live remaining time until it resolves itself at zero."""
try:
while True:
await asyncio.sleep(_TICK_S)
dialog.set_remaining(transmit_gate.remaining(flood_advert=flood_advert))
session.invalidate()
except asyncio.CancelledError:
# Swallowed so the task finishes on its own final cycle rather than being torn
# down while pending — the same courtesy the progress dialog's animator takes.
pass
__all__ = ["SILENT_WAIT_S", "wait_for_cooldown"]
+87
View File
@@ -603,6 +603,93 @@ class ButtonDialog(_KeylessDialog):
super().handle("escape")
class CountdownDialog(_KeylessDialog):
"""A wait you can watch, and back out of: the seconds left, over one Cancel chip.
Shown while a cooldown holds a transmission back (see
:func:`meshterm.ui.cooldown.wait_for_cooldown`). The point is that a wait long enough
to notice should say *why* it is waiting and leave a way out — a frozen screen says
neither, and on a radio app it reads as a lost connection rather than as courtesy.
It resolves itself: the caller ticks it down with :meth:`set_remaining` and the dialog
resolves ``True`` the moment the clock reaches zero. Esc — or Enter on its Cancel chip
— resolves CANCEL instead, and the caller abandons whatever was waiting.
One chip, not two: there is nothing to choose between, only something to abandon, so
it takes the lone-button hint shape (no ``←→``) with Cancel as its verb.
"""
def __init__(self, action: str, remaining: float, *, reason: str = "") -> None:
"""Build a countdown over one Cancel chip.
Args:
action: What is waiting, named as the thing that will happen ("Flood advert").
remaining: Seconds left when the dialog opens.
reason: One muted line under the clock saying why the wait exists; empty for
no line at all.
"""
super().__init__()
self.title = action
# Both keys do the one thing this dialog offers, so they share an atom rather
# than each claiming "cancel" in turn (the hint line's shared-key rule).
self.footer_hint = "Enter/Esc cancel"
self.border_style = "warn"
self._remaining = max(0.0, remaining)
self._reason = reason
def set_remaining(self, remaining: float) -> None:
"""Update the clock, resolving the dialog once it reaches zero.
Resolving twice is harmless — :meth:`~meshterm.ui.tui.screen.Screen.resolve` drops
a second result — so the tick never has to know whether Esc beat it to it.
"""
self._remaining = max(0.0, remaining)
if self._remaining <= 0:
self.resolve(True)
@property
def dialog_width(self) -> int:
"""Natural outer width, sized for the widest line it will ever draw.
Measured against the *opening* clock rather than the current one, so the box does
not shrink a cell as the seconds tick from 10 to 9 — a dialog that resizes while
you read it is harder to read than a slightly wide one.
"""
inner = max(
cell_len(self.title),
cell_len(self.footer_hint),
cell_len(self._clock_text(self._remaining)),
cell_len(self._reason),
len(" Cancel "),
)
return inner + 8
@staticmethod
def _clock_text(remaining: float) -> str:
"""The clock line: whole seconds, counted the way a person would say them."""
seconds = max(0, int(remaining + 0.999)) # 0.2s left still reads as "1s"
return f"Ready in {seconds}s"
def render_body(self, width: int) -> list[str]:
"""Draw the clock, its reason, and the Cancel chip."""
lines: list[Text] = [Text(self._clock_text(self._remaining), style="warn")]
if self._reason:
lines.append(Text(self._reason, style="muted"))
lines.append(Text(""))
lines.append(Text(" Cancel ", style="selected"))
rendered: list[str] = []
for line in lines:
# An empty Text renders to no lines at all on its own, so the spacer above the
# chip has to be asked for explicitly or the chip rides up against the reason.
rendered.extend(render_lines(line, width) or [""])
return rendered
def handle(self, action: str, data: str = "") -> None:
"""Enter and Esc both abandon: the only chip on the dialog is Cancel."""
if action in ("enter", "escape"):
super().handle("escape")
class TypedConfirmDialog(_KeylessDialog):
"""A destructive-action gate: the user must type a confirmation word to proceed.
+16
View File
@@ -71,6 +71,22 @@ def powerline(monkeypatch: pytest.MonkeyPatch) -> Callable[[bool], None]:
return _set
@pytest.fixture(autouse=True)
def _reset_transmit_gate() -> Iterator[None]:
"""Every test starts having transmitted nothing.
The transmit clock is process-wide (see :mod:`meshterm.core.transmit_gate`), which is
right for a session and wrong for a suite: a test that sends anything would otherwise
leave a cooldown standing for whatever ran next, and the next test's flow would stop
on a countdown nobody scripted.
"""
from meshterm.core.transmit_gate import current as transmit_clock
transmit_clock().reset()
yield
transmit_clock().reset()
@pytest.fixture(autouse=True)
def _reset_platform() -> Iterator[None]:
"""Every test starts and ends on :data:`~meshterm.platforms.REGULAR`.
+267
View File
@@ -0,0 +1,267 @@
"""Tests for the transmit cooldown: the shared clock, the countdown, and the way out.
Three layers again — what the clock owes (:mod:`meshterm.core.transmit_gate`), what the
dialog draws, and what :func:`~meshterm.ui.cooldown.wait_for_cooldown` decides at each
side of its threshold — plus the check that a real transmission is what starts the clock,
so the gate can never be a number nothing sets.
"""
from __future__ import annotations
import asyncio
from typing import Any, Optional
import pytest
from meshterm.core import transmit_gate
from meshterm.core.preferences import Preferences, get_spec, install
from meshterm.core.transmit_gate import TransmitGate
from meshterm.ui.cooldown import SILENT_WAIT_S, wait_for_cooldown
from meshterm.ui.tui.prompt import CountdownDialog
from meshterm.ui.tui.screen import CANCEL
from tests.conftest import plain as _plain
@pytest.fixture(autouse=True)
def _defaults():
"""Every test starts from an idle clock and the built-in preferences."""
install(Preferences())
transmit_gate.current().reset()
yield
install(Preferences())
transmit_gate.current().reset()
# -- the clock -------------------------------------------------------------------
def test_an_idle_gate_owes_nothing() -> None:
"""Nothing sent, nothing to wait for — a fresh session transmits at once."""
gate = TransmitGate()
assert gate.remaining() == 0.0
assert gate.remaining(flood_advert=True) == 0.0
def test_a_send_starts_the_general_clock() -> None:
"""Any transmission holds the next one off by the transmit cooldown."""
gate = TransmitGate()
gate.mark()
assert gate.remaining() == pytest.approx(5.0, abs=0.5)
def test_a_flood_advert_waits_out_both_clocks() -> None:
"""A flood advert owes its own longer wait; an ordinary send still owes only the short one."""
gate = TransmitGate()
gate.mark(flood_advert=True)
assert gate.remaining() == pytest.approx(5.0, abs=0.5)
assert gate.remaining(flood_advert=True) == pytest.approx(60.0, abs=0.5)
def test_an_ordinary_send_does_not_arm_the_flood_clock() -> None:
"""Sending a message shouldn't cost you a minute before you may advertise."""
gate = TransmitGate()
gate.mark()
assert gate.remaining(flood_advert=True) == pytest.approx(5.0, abs=0.5)
def test_the_clocks_follow_the_preferences() -> None:
"""Both waits are read live, so a change on the Preferences page applies at once."""
prefs = Preferences()
prefs.set("trace_cooldown_s", 0.1)
prefs.set("flood_advert_cooldown_s", 30.0)
install(prefs)
gate = TransmitGate()
gate.mark(flood_advert=True)
assert gate.remaining() == pytest.approx(0.1, abs=0.05)
assert gate.remaining(flood_advert=True) == pytest.approx(30.0, abs=0.5)
def test_reset_clears_both_clocks() -> None:
"""Reset returns a gate to never-sent (for a fresh session, and for these tests)."""
gate = TransmitGate()
gate.mark(flood_advert=True)
gate.reset()
assert gate.remaining(flood_advert=True) == 0.0
def test_the_bounds_say_what_may_be_asked_for() -> None:
"""The general cooldown has a floor rather than an off switch; a flood advert's is higher."""
assert get_spec("trace_cooldown_s").minimum == 0.1
assert get_spec("flood_advert_cooldown_s").minimum == 5.0
# -- the dialog ------------------------------------------------------------------
def _lines(dialog: CountdownDialog) -> list[str]:
"""The dialog's body as plain text."""
return _plain(dialog.render_body(dialog.dialog_width - 8)).splitlines()
def test_the_countdown_shows_the_clock_its_reason_and_the_way_out() -> None:
"""What is waiting, how long is left, why, and the one chip that abandons it."""
dialog = CountdownDialog("Flood advert", 47.0, reason="reaches the whole mesh")
assert dialog.title == "Flood advert"
body = _lines(dialog)
assert "Ready in 47s" in body[0]
assert "reaches the whole mesh" in body[1]
assert any("Cancel" in line for line in body)
# Both keys do the one thing on offer, so they share an atom instead of repeating it.
assert dialog.footer_hint == "Enter/Esc cancel"
def test_a_part_second_still_reads_as_a_second() -> None:
"""The clock counts the way a person would say it: 0.2s left is still "1s"."""
assert "Ready in 1s" in _lines(CountdownDialog("Advert", 0.2))[0]
assert "Ready in 3s" in _lines(CountdownDialog("Advert", 2.4))[0]
def test_the_box_does_not_resize_as_it_counts() -> None:
"""Sized once, from the opening clock — a box that shrinks under you is harder to read."""
dialog = CountdownDialog("Flood advert", 47.0, reason="reaches the whole mesh")
width = dialog.dialog_width
dialog.set_remaining(3.0)
assert dialog.dialog_width == width
def test_the_countdown_resolves_itself_at_zero() -> None:
"""Reaching zero is the dialog's own answer: the wait is over, the action goes ahead."""
dialog = CountdownDialog("Advert", 5.0)
dialog.future = asyncio.get_event_loop_policy().new_event_loop().create_future()
dialog.set_remaining(3.0)
assert not dialog.future.done()
dialog.set_remaining(0.0)
assert dialog.future.done() and dialog.future.result() is True
@pytest.mark.parametrize("key", ["enter", "escape"])
def test_either_key_abandons_the_wait(key: str) -> None:
"""There is nothing to choose between, so Enter and Esc both mean cancel."""
dialog = CountdownDialog("Advert", 30.0)
dialog.future = asyncio.get_event_loop_policy().new_event_loop().create_future()
dialog.handle(key)
assert dialog.future.done() and dialog.future.result() is CANCEL
# -- the wait --------------------------------------------------------------------
class _Session:
"""A session that answers ``run_screen`` from a script and records what it was shown."""
def __init__(self, answer: Any = True) -> None:
self.answer = answer
self.shown: list[CountdownDialog] = []
async def run_screen(self, screen: Any) -> Any:
self.shown.append(screen)
return self.answer
def invalidate(self) -> None:
pass
class _Ui:
"""A UI surface carrying nothing but a session (all the wait helper reads)."""
def __init__(self, session: Optional[_Session]) -> None:
self.session = session
class _Ctx:
"""The slice of :class:`~meshterm.context.AppContext` the wait helper touches."""
def __init__(self, session: Optional[_Session]) -> None:
self.ui = _Ui(session)
async def test_no_cooldown_goes_straight_through() -> None:
"""Nothing owed, nothing shown: the action fires with no dialog in the way."""
session = _Session()
assert await wait_for_cooldown(_Ctx(session), action="Advert") is True
assert session.shown == []
async def test_a_short_wait_passes_without_a_word() -> None:
"""Under the threshold the wait just happens: a dialog that flashes up *is* the interruption."""
prefs = Preferences()
prefs.set("trace_cooldown_s", 0.3) # comfortably under SILENT_WAIT_S
install(prefs)
transmit_gate.mark()
session = _Session()
assert await wait_for_cooldown(_Ctx(session), action="Advert") is True
assert session.shown == []
# And it really did wait it out. Not exactly zero: the sleep is for the wait read a
# few instructions earlier, so a millisecond or two of it is still nominally owed.
assert transmit_gate.remaining() < 0.05
async def test_a_long_wait_opens_a_countdown() -> None:
"""Over the threshold the reader is told what is happening, and for how long."""
transmit_gate.mark(flood_advert=True) # a minute owed on the flood clock
session = _Session(answer=True)
assert await wait_for_cooldown(_Ctx(session), action="Flood advert", flood_advert=True)
assert len(session.shown) == 1
dialog = session.shown[0]
assert dialog.title == "Flood advert"
assert "whole mesh" in _lines(dialog)[1]
async def test_cancelling_the_countdown_abandons_the_action() -> None:
"""Backing out of the wait is backing out of what was waiting."""
transmit_gate.mark(flood_advert=True)
session = _Session(answer=CANCEL)
assert await wait_for_cooldown(
_Ctx(session), action="Flood advert", flood_advert=True
) is False
async def test_the_threshold_is_where_it_says_it_is() -> None:
"""SILENT_WAIT_S is the whole rule: at or under it, silence; over it, a dialog."""
assert SILENT_WAIT_S == 2.0
prefs = Preferences()
prefs.set("trace_cooldown_s", SILENT_WAIT_S + 3)
install(prefs)
transmit_gate.mark()
session = _Session()
await wait_for_cooldown(_Ctx(session), action="Advert")
assert len(session.shown) == 1
async def test_a_scripted_run_waits_without_a_dialog() -> None:
"""The CLI has no session to float a dialog over, so it takes the wait silently."""
prefs = Preferences()
prefs.set("trace_cooldown_s", 0.2)
install(prefs)
transmit_gate.mark()
assert await wait_for_cooldown(_Ctx(None), action="Advert") is True
# -- what starts the clock -------------------------------------------------------
async def test_a_real_send_is_what_arms_the_gate() -> None:
"""The clock is set by transmitting, not by anything remembering to set it.
Driven against the simulator, which marks the gate at exactly the points the radio
does — so the countdown is walkable (and this is checkable) with no hardware.
"""
from meshterm.core.connection import MockDevice
device = MockDevice()
await device.connect()
assert transmit_gate.remaining() == 0.0
await device.send_advert(False)
assert transmit_gate.remaining() > 0
assert transmit_gate.remaining(flood_advert=True) == pytest.approx(
transmit_gate.remaining(), abs=0.1
) # a zero-hop advert does not arm the flood clock
transmit_gate.current().reset()
await device.send_advert(True)
assert transmit_gate.remaining(flood_advert=True) > transmit_gate.remaining()
transmit_gate.current().reset()
await device.run_trace("Alice")
assert transmit_gate.remaining() > 0
+14
View File
@@ -78,6 +78,7 @@ from meshterm.ui.node_detail_screen import NodeDetailScreen, _Action, _RoutesVie
from meshterm.ui.packet_viewer import PacketEntry, PacketViewer
from meshterm.ui.path_composer import PathComposerScreen
from meshterm.ui.preferences import _menu_items as _preference_items
from meshterm.ui.tui.prompt import CountdownDialog
from meshterm.ui.records_screen import RecordDialog
from meshterm.ui.remote_cli import RemoteCliScreen
from meshterm.ui.timemachine_screen import TimeMachineScreen
@@ -564,6 +565,18 @@ def _preferences(cols: int, rows: int) -> Screen:
)
def _cooldown_countdown(cols: int, rows: int) -> Screen:
"""The transmit-cooldown countdown at its widest: a flood advert's wait and its reason.
A dialog rather than a menu screen, and here for the one thing only this harness does:
its box is sized from its own content, so a longer reason line would overflow the
PicoCalc's 53 columns with nothing else to catch it.
"""
return CountdownDialog(
"Flood advert", 47.0, reason="a flood advert reaches the whole mesh"
)
def _about_meshterm(cols: int, rows: int) -> Screen:
return AboutPage("About MeshTerm", about_meshterm())
@@ -605,6 +618,7 @@ _ENTRIES: list[_Entry] = [
_Entry("config_editor", _config_editor),
_Entry("config_editor_revealed", _config_editor_revealed),
_Entry("preferences", _preferences),
_Entry("cooldown_countdown", _cooldown_countdown),
_Entry("about_meshterm", _about_meshterm),
_Entry("about_author", _about_author),
_Entry("join_discord", _join_discord),