Files
MeshTerm/tests/test_cooldown.py
T
Jean-Pierre MartineauandClaude Opus 5 5b7481af8a Python 3.14 is supported, and CI proves it
JP moved to 3.14. Every dependency installs on it -- the Windows Bluetooth packages
included -- and the suite passes: 2705 passed, 1 skipped, on 3.14.7 as on 3.10.

- pyproject gains the 3.14 classifier. requires-python stays >=3.10: the uConsole on
  bookworm runs 3.11, and a pip or pipx install on an older system should keep working.
- CI keeps its shape, which is Linux over the whole range and the other two runners at
  its ends. Linux gains a 3.14 leg, and 3.14 replaces 3.13 as the top end on Windows and
  macOS.
- Three tests reached a loop through asyncio.get_event_loop_policy(), which 3.14
  deprecates and 3.16 removes, and never closed the loop they made. The two countdown
  tests now make one with new_event_loop() and close it; the sweep test uses
  asyncio.run(), which does both. On 3.14 the suite is back to no warnings.

The standalone installers still bundle 3.10; moving their build to 3.14 is for 0.9.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:44:37 -04:00

287 lines
11 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""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
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_the_clock_its_reason_and_the_chip_are_all_centered() -> None:
"""Centered like every other dialog's body — a chip against the left border read as odd."""
dialog = CountdownDialog("Flood advert", 47.0, reason="reaches the whole mesh")
width = dialog.dialog_width - 8
for line in _lines(dialog):
if not line.strip():
continue
left = len(line) - len(line.lstrip(" "))
right = width - len(line.rstrip(" "))
assert abs(left - right) <= 1, line
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)
loop = asyncio.new_event_loop()
try:
dialog.future = 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
finally:
loop.close()
@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)
loop = asyncio.new_event_loop()
try:
dialog.future = loop.create_future()
dialog.handle(key)
assert dialog.future.done() and dialog.future.result() is CANCEL
finally:
loop.close()
# -- 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: _Session | None) -> None:
self.session = session
class _Ctx:
"""The slice of :class:`~meshterm.context.AppContext` the wait helper touches."""
def __init__(self, session: _Session | None) -> 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