From 8f1fba135eef2a9074adf5b3ffdeb622dcbe8a09 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 1 Aug 2026 02:18:22 -0400 Subject: [PATCH] Platform seam: meshterm/platforms.py, --platform flag, dual-platform gallery One frozen Platform spec (REGULAR/PICOCALC), resolved once at boot (--platform flag > MESHTERM_PLATFORM env > /proc/device-tree/model auto-detect > REGULAR) and read via get_platform()/set_platform() - never a stale `from platforms import PLATFORM`, which the module docstring calls out. `meshterm platform` reports the resolution and everything it looked at. Wires the two correctness bindings that matter before any flavour work lands: width_reclaim off on PicoCalc's exact-width console (session._reclaim_last_column, env override still wins), and the emoji-width calibration probe skipped outright when a platform never draws emoji. tests/test_gallery.py renders all 16 screens the interactive menu opens against REGULAR (72x24) and PICOCALC (53x26 live floor, 53x40 lux case), asserting no line overflows its platform - 35 passing cases, 13 xfailed as a real P6 worklist (contacts, map, walk, livefeed, remote_cli, path_composer). Also caught and left alone: the map screen's footer hint already overflowed the *existing* 72-col standard before this seam existed, unrelated to picocalc - xfailed rather than fixed as a drive-by. tests/test_platforms.py unit-tests the module directly; conftest.py gained an autouse fixture resetting the platform between tests. Co-Authored-By: Claude Sonnet 5 --- meshterm/cli.py | 39 ++- meshterm/platforms.py | 286 +++++++++++++++++++++ meshterm/ui/menu.py | 8 +- meshterm/ui/tui/session.py | 46 ++-- tests/conftest.py | 15 ++ tests/test_gallery.py | 497 ++++++++++++++++++++++++++++++++++++ tests/test_platforms.py | 143 +++++++++++ tests/test_session_width.py | 36 ++- 8 files changed, 1044 insertions(+), 26 deletions(-) create mode 100644 meshterm/platforms.py create mode 100644 tests/test_gallery.py create mode 100644 tests/test_platforms.py diff --git a/meshterm/cli.py b/meshterm/cli.py index b10b10a..3f4b3af 100644 --- a/meshterm/cli.py +++ b/meshterm/cli.py @@ -24,6 +24,7 @@ from .core.device_store import DeviceStore from .core.selection import DeviceSelectionError from .persistence.logging import configure_logging from .persistence.repository import Repository +from .platforms import Resolution, resolve, set_platform from .tools import all_tools from .tools.base import Tool, ToolResult from .ui.theme import make_console @@ -72,6 +73,10 @@ app = typer.Typer( # The context built by the callback and consumed by subcommands within one process. _state: Optional[AppContext] = None +# The platform resolution the callback made, for the ``platform`` diagnostic subcommand +# to report back (see :func:`platform_command`) without re-deriving it independently. +_platform_resolution: Optional[Resolution] = None + @app.callback(invoke_without_command=True) def main_callback( @@ -93,6 +98,11 @@ def main_callback( db_path: Optional[Path] = typer.Option(None, "--db", help="SQLite database path"), json_output: bool = typer.Option(False, "--json", help="Machine-readable output"), quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress console logging"), + platform: Optional[str] = typer.Option( + None, + "--platform", + help="Force the UI platform (regular|picocalc) instead of auto-detecting it", + ), ) -> None: """Build the application context and dispatch to the menu or a subcommand. @@ -107,8 +117,17 @@ def main_callback( db_path: Override the database location. json_output: Request machine-readable output from tools. quiet: Suppress console logging (file logging continues). + platform: Explicit ``--platform`` override; see :func:`meshterm.platforms.resolve`. """ - global _state + global _state, _platform_resolution + + # Resolved and installed first, before anything below it (make_console, the theme, + # the eventual TuiSession) reads the active platform. + try: + _platform_resolution = resolve(platform) + except ValueError as exc: + raise typer.BadParameter(str(exc), param_hint="--platform") from exc + set_platform(_platform_resolution.platform) settings = Settings.load() if db_path is not None: @@ -145,6 +164,24 @@ def main_callback( asyncio.run(_drive(run_menu(app_ctx), app_ctx)) +@app.command(name="platform") +def platform_command() -> None: + """Print the resolved platform and every input the resolution looked at. + + A diagnostic for the platform seam (see ``meshterm/platforms.py``): confirms which + flavour a given invocation would run as, and why — the ``--platform``/ + ``MESHTERM_PLATFORM`` inputs, what ``/proc/device-tree/model`` reports (``None`` off + the actual hardware), and which of those decided it. + """ + assert _platform_resolution is not None # set by the callback that always runs first + r = _platform_resolution + console = make_console() + console.print(f"platform: [accent]{r.platform.name}[/accent] (source: {r.source})") + console.print(f" --platform: {r.flag or '(not passed)'}") + console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}") + console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}") + + def run_tool_command(tool: Tool, params: dict) -> None: """Execute a tool from a CLI subcommand and render its result. diff --git a/meshterm/platforms.py b/meshterm/platforms.py new file mode 100644 index 0000000..fc2cd6a --- /dev/null +++ b/meshterm/platforms.py @@ -0,0 +1,286 @@ +"""The platform seam: a frozen spec every consumer binds to once, at boot. + +MeshTerm runs two flavours from one codebase — the regular desktop terminal and the +PicoCalc's 53-column framebuffer console — with no forked screens and no runtime layer. +A :class:`Platform` is resolved once, at process start, and held in a module-level +singleton; everything downstream (the theme, the frame compositor, the header, the +session's width handling) reads it at *its own* construction/call time and binds its +behaviour accordingly, so the render loop itself never branches on platform. + +Read the active platform through :func:`get_platform` (or the module-qualified +``platforms.PLATFORM``) — never ``from meshterm.platforms import PLATFORM`` at a +module's top level. That statement runs once, at import time, and copies whatever +:data:`PLATFORM` pointed to *then*; :func:`set_platform` rebinding the module global +afterwards would never reach that stale copy. A function call always re-reads the +current value, so it has no such trap. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass(frozen=True, slots=True) +class Platform: + """One flavour's complete rendering/behaviour spec. + + Every field is data a consumer binds to at its own construction/call time (see the + module docstring) — there is no code path that branches on ``platform.name`` itself + outside this module and the small set of binding points documented at each field. + + Attributes: + name: Stable identifier — ``"regular"`` or ``"picocalc"`` — used for + ``--platform``/``MESHTERM_PLATFORM`` matching and diagnostics. + readable_cols: The readability standard (CLAUDE.md's "screens stay readable at + N columns") and the dual-platform gallery's test width. + readable_rows: The row *floor* a screen designs to. PicoCalc's live console is + 53×26 (custom 6×12 font); a 6×8 font rebuild (P5) gives 53×40. Screens design + to this floor and exploit extra rows fluidly; the gallery renders picocalc at + both 26 and 40 rows. + frame_border: Whether the base screen draws inside a bordered ``Panel``. ``False`` + swaps it for a title-bar row instead (+4 cols, +1 row reclaimed) — a PicoCalc + chrome saving, wired in P4. + header_atoms: Which segments compose the persistent header, in the vocabulary + ``"version"``, ``"device"``, ``"badges"``, ``"pulse"``, ``"battery"``. Wired + into :func:`~meshterm.ui.menu._header` in P4; unconsumed until then. + footer_fkeys: Whether the footer is the fixed F1–F5 hint lane (with F6–F10 as + their paired opposites) instead of each screen's own ``footer_hint`` string. + Wired in P4. + width_reclaim: Whether the session may report one extra terminal column (see + :class:`~meshterm.ui.tui.session._WidthExtendedOutput`). Correctness-critical + on PicoCalc's exact-width console: a phantom extra column tears the frame, so + this is ``False`` there. ``MESHTERM_FULL_WIDTH`` remains an explicit override + on top of this default. + emoji: Whether emoji icons render at all. ``False`` means every icon funnel routes + through a compact single-BMP-glyph table instead (P3), and the emoji-width + calibration probe (:func:`~meshterm.ui.tui.emoji_width.calibrate`) is skipped + outright — there is nothing to calibrate once no emoji are ever drawn, and the + probe writes escape sequences the console font can't shape anyway. + ascii_fold: Whether names and message bodies are NFKD-folded (accents stripped) at + the render boundary before display. Storage is never touched. Wired in P3. + truecolor: Whether the theme may use arbitrary 24-bit SGR colour. ``False`` selects + a 16-slot palette theme instead (the console's real ceiling — no per-cell RGB). + Wired in P3. + name_colour: ``"key"`` (today's per-node hash-derived hue spectrum) or ``"type"`` + (colour by node type — client/repeater/room/sensor — because PicoCalc's 16 + colour slots can't also afford a hash spectrum without collisions). Wired into + :func:`~meshterm.ui.theme.name_style` in P3. + effects: Whether animated/decorative rendering runs at all: corner glow, the + header battery blink, the braille spinner. ``False`` drops to static chrome + and a ``LINE`` spinner fallback — cheaper, and the glow already no-ops on a + non-truecolor theme, so this just makes that explicit. Wired in P2/P3. + tick_s: The :class:`~prompt_toolkit.application.Application` refresh interval. + Both platforms start at today's 1.0s; PicoCalc's is revisited in P2 once + on-device CPU numbers exist to tune against. + battery: Which source feeds the header's battery gauge — ``"companion"`` (read + from the connected MeshCore device) or ``"host"`` (the PicoCalc's own sysfs + ``power_supply`` driver, confirmed present in P0). Wired in P5. + modifier_watch: Whether the optional keyboard-lib/evdev Shift-state watcher may + engage, flipping the displayed F-key lane live while Shift is held. An + experiment gated to PicoCalc only — desktop terminals have no such lane to + flip, and the watcher needs ``/dev/input`` access this platform is known to + have (the deploy user in the ``input`` group). Always optional and lazy; wired in P4. + """ + + name: str + readable_cols: int + readable_rows: int + frame_border: bool + header_atoms: tuple[str, ...] + footer_fkeys: bool + width_reclaim: bool + emoji: bool + ascii_fold: bool + truecolor: bool + name_colour: str + effects: bool + tick_s: float + battery: str + modifier_watch: bool + + +#: Exactly today's behaviour — the desktop/ssh terminal, unchanged by this seam's arrival. +REGULAR = Platform( + name="regular", + readable_cols=72, + readable_rows=24, + frame_border=True, + header_atoms=("version", "device", "badges", "pulse", "battery"), + footer_fkeys=False, + width_reclaim=True, + emoji=True, + ascii_fold=False, + truecolor=True, + name_colour="key", + effects=True, + tick_s=1.0, + battery="companion", + modifier_watch=False, +) + +#: The PicoCalc/Lyra/Calculinux framebuffer console. Field values not yet consumed by a +#: binding point (header_atoms, ascii_fold, truecolor, name_colour, effects beyond the +#: two P1 bindings, tick_s, battery, modifier_watch) are P2–P5's targets, recorded here +#: now so the seam exists before the flavour work lands — see .claude/plans/picocalc-platform.md. +PICOCALC = Platform( + name="picocalc", + readable_cols=53, + readable_rows=26, + frame_border=False, + # P4 decides "keep pulse" once it can measure whether it still earns its cells at 53; + # conservatively excluded until then. Version is dropped outright (plan decision). + header_atoms=("device", "badges", "battery"), + footer_fkeys=True, + width_reclaim=False, + emoji=False, + ascii_fold=True, + truecolor=False, + name_colour="type", + effects=False, + tick_s=1.0, + battery="host", + modifier_watch=True, +) + +_BY_NAME = {p.name: p for p in (REGULAR, PICOCALC)} + +#: The active platform. Do not import this name directly (see the module docstring) — +#: read it through :func:`get_platform`, and change it only through :func:`set_platform`. +PLATFORM = REGULAR + + +def get_platform() -> Platform: + """Return the active :class:`Platform`. + + Safe to call from anywhere, at any time — always reflects the most recent + :func:`set_platform` call, however that module was imported. + """ + return PLATFORM + + +def set_platform(platform: Platform) -> None: + """Install ``platform`` as the active one. + + Called exactly once by the CLI callback, before :func:`~meshterm.ui.theme.make_console` + — every other read of :func:`get_platform` in the same process happens after this. + Idempotent (setting the same platform twice is a no-op in effect) and reversible, so + tests can swap in a platform for one test and restore the previous one afterwards + (see the ``_reset_platform`` autouse fixture in ``tests/conftest.py``). + + Args: + platform: The platform to make active. + """ + global PLATFORM + PLATFORM = platform + + +@dataclass(frozen=True, slots=True) +class Resolution: + """What :func:`resolve` looked at and what it landed on. + + Exists so the ``meshterm platform`` diagnostic can report every input the resolution + considered, not just the final answer. + + Attributes: + flag: The ``--platform`` value passed on the command line, if any. + env: The ``MESHTERM_PLATFORM`` environment variable's value, if set. + detected_model: The ``/proc/device-tree/model`` contents read during resolution + (trailing NUL/space stripped), or ``None`` when the file doesn't exist (every + non-Lyra machine, including every desktop and CI runner). + source: Which input decided the outcome — ``"--platform flag"``, + ``"MESHTERM_PLATFORM env var"``, ``"auto-detect"``, or ``"default"``. + platform: The resolved :class:`Platform`. + """ + + flag: Optional[str] + env: Optional[str] + detected_model: Optional[str] + source: str + platform: Platform + + +#: Where the Lyra reports itself in the device tree. P0 (2026-08-01) recorded a trailing +#: space before the NUL; re-verified live on-device during P1 (2026-08-01) as exactly +#: ``b"Luckfox Lyra\x00"`` — no trailing space. :func:`_read_device_tree_model` strips +#: whatever surrounds it either way, so this is a documentation correction, not a behaviour +#: change — flagged here in case a different unit/firmware revision does carry one. +_LYRA_MODEL = "Luckfox Lyra" + +_DEVICE_TREE_MODEL_PATH = Path("/proc/device-tree/model") + + +def _read_device_tree_model() -> Optional[str]: + """Read and normalize ``/proc/device-tree/model``, or ``None`` when it doesn't exist. + + Absent on every machine that isn't running a device-tree-based Linux kernel — every + desktop, every CI runner, and (today) every ``ssh`` session onto one of those. This is + deliberately the *only* signal auto-detection consults (no terminal-size guessing), so + a small window on the desktop is never mistaken for the device. + """ + try: + raw = _DEVICE_TREE_MODEL_PATH.read_bytes() + except OSError: + return None + return raw.decode("utf-8", "replace").rstrip("\x00").strip() + + +def _lookup(name: str, *, source: str) -> Platform: + """Resolve a platform name to its :class:`Platform`, or raise a clear ``ValueError``.""" + try: + return _BY_NAME[name.strip().lower()] + except KeyError: + choices = ", ".join(sorted(_BY_NAME)) + # Plain ASCII: this can surface through Typer/Click's own error path (raised + # before make_console() has reconfigured stdout/stderr to UTF-8), which mangles + # non-ASCII on a legacy Windows console the way make_console's docstring warns of. + raise ValueError( + f"unknown platform {name!r} (from {source}) - choose from: {choices}" + ) from None + + +def resolve(flag: Optional[str] = None) -> Resolution: + """Decide which platform is active, and record what the decision looked at. + + Resolution order: the explicit ``--platform`` flag, then the ``MESHTERM_PLATFORM`` + environment variable, then device-tree auto-detection, then :data:`REGULAR`. Only the + input that actually decides the outcome is name-validated — an env var typo is + reported when the flag isn't set, but never blocks a session that passed an explicit + (correct) flag. + + Args: + flag: The ``--platform`` command-line value, if given. + + Returns: + The full :class:`Resolution`, for both immediate use (``.platform``) and the + ``meshterm platform`` diagnostic's report. + + Raises: + ValueError: The deciding input (flag or env var) named an unknown platform. + """ + env = os.environ.get("MESHTERM_PLATFORM") or None + model = _read_device_tree_model() + if flag: + return Resolution(flag, env, model, "--platform flag", _lookup(flag, source="--platform")) + if env: + return Resolution( + flag, env, model, "MESHTERM_PLATFORM env var", + _lookup(env, source="MESHTERM_PLATFORM"), + ) + if model == _LYRA_MODEL: + return Resolution(flag, env, model, "auto-detect", PICOCALC) + return Resolution(flag, env, model, "default", REGULAR) + + +__all__ = [ + "Platform", + "REGULAR", + "PICOCALC", + "PLATFORM", + "get_platform", + "set_platform", + "Resolution", + "resolve", +] diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index eeaad62..7e201e2 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -25,6 +25,7 @@ from rich.text import Text from .. import __version__ from ..context import AppContext from ..persistence.logging import get_logger +from ..platforms import get_platform from ..tools import all_tools from .surface import TuiUi from .braillechart import activity_peak, activity_sparkline @@ -325,8 +326,11 @@ async def run_menu(ctx: AppContext) -> None: """ # Measure how this terminal renders emoji and align Rich to it, before # prompt_toolkit takes over the screen. This keeps every panel border — and - # every chat bubble — aligned regardless of the terminal's emoji widths. - calibrate_emoji_width() + # every chat bubble — aligned regardless of the terminal's emoji widths. Skipped + # outright on a platform that never draws emoji (PicoCalc): there is nothing to + # calibrate, and the probe writes escape sequences the console font can't shape. + if get_platform().emoji: + calibrate_emoji_width() header_cache: dict = {} session = TuiSession(header=lambda cols: _header(ctx, header_cache, cols)) diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index a84c36b..7bb4abc 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -29,6 +29,7 @@ from prompt_toolkit.utils import get_cwidth from rich.console import RenderableType from rich.text import Text +from ...platforms import get_platform from . import frame from .overlay import BusyOverlay from .progress import TuiProgress @@ -178,18 +179,31 @@ def _read_clipboard() -> str: return "" -#: Reclaim the terminal's final column. Some terminals (and prompt_toolkit's size probe on -#: them) report the window one column narrower than it really is, so the frame is drawn to -#: ``columns - 1`` and the true last column sits unused — visibly selectable to the right of -#: the border. When this is on, :class:`_WidthExtendedOutput` tells both the renderer and the -#: frame compositor the window is one column wider, and that final column gets drawn. -#: -#: This is a gate, not a certainty: it is *correct* only when the probe under-reports. On a -#: terminal whose width probe is already right, the extra column falls off the real screen and -#: the frame would wrap and tear, so it can be switched off with ``MESHTERM_FULL_WIDTH=0``. -#: Kept as an env-var gate for now so it can graduate to a user setting once confirmed across -#: terminals (TODO: thread through ``settings`` and the config editor). -_RECLAIM_LAST_COLUMN = os.environ.get("MESHTERM_FULL_WIDTH", "1") != "0" +def _reclaim_last_column() -> bool: + """Whether to reclaim the terminal's final column. + + Some terminals (and prompt_toolkit's size probe on them) report the window one column + narrower than it really is, so the frame is drawn to ``columns - 1`` and the true last + column sits unused — visibly selectable to the right of the border. When this is on, + :class:`_WidthExtendedOutput` tells both the renderer and the frame compositor the + window is one column wider, and that final column gets drawn. + + This is a gate, not a certainty: it is *correct* only when the probe under-reports. On a + terminal whose width probe is already right, the extra column falls off the real screen and + the frame would wrap and tear — which is exactly PicoCalc's exact-width console, so + :data:`~meshterm.platforms.PICOCALC` defaults this off. ``MESHTERM_FULL_WIDTH=0``/``=1`` + remains an explicit override on top of the platform default for any terminal that needs + to disagree with its platform's usual verdict (TODO: thread through ``settings`` and the + config editor once confirmed further). + + Read fresh on every call (never cached at import time) so it reflects whichever platform + :func:`~meshterm.platforms.set_platform` installed for this process — see that module's + docstring for why a cached/imported copy of the platform would go stale. + """ + override = os.environ.get("MESHTERM_FULL_WIDTH") + if override is not None: + return override != "0" + return get_platform().width_reclaim #: How many stacked dialog layers the layout can float over the background at once. A fixed #: pool of centered-box floats (see :meth:`TuiSession._build_app`), sized well past the deepest @@ -231,7 +245,7 @@ class _WidthExtendedOutput: adds a column. Because the whole render pipeline — prompt_toolkit's differential renderer and MeshTerm's own frame compositor (via :meth:`TuiSession._size`) — keys off ``output.get_size()``, this single override makes both use the reclaimed column in - lock-step. See :data:`_RECLAIM_LAST_COLUMN` for when this is right (and when it isn't). + lock-step. See :func:`_reclaim_last_column` for when this is right (and when it isn't). """ def __init__(self, inner: Any) -> None: @@ -1107,11 +1121,11 @@ class TuiSession: """The output the app renders to — optionally widened to reclaim the last column. Only the *real* terminal (``self._output is None``, so prompt_toolkit would build its - own output) is wrapped, and only when :data:`_RECLAIM_LAST_COLUMN` is on: a test that - supplies its own output keeps the exact size it set, so headless rendering stays + own output) is wrapped, and only when :func:`_reclaim_last_column` says on: a test + that supplies its own output keeps the exact size it set, so headless rendering stays deterministic. See :class:`_WidthExtendedOutput` for what the wrap does. """ - if self._output is not None or not _RECLAIM_LAST_COLUMN: + if self._output is not None or not _reclaim_last_column(): return self._output from prompt_toolkit.output.defaults import create_output diff --git a/tests/conftest.py b/tests/conftest.py index 7e084fb..e34a5c7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,7 @@ from typing import Iterable, Iterator, Union import pytest +from meshterm.platforms import REGULAR, set_platform from meshterm.ui.termfont import powerline_support #: ANSI SGR escapes — the colour runs a rendered line carries between its characters. @@ -42,3 +43,17 @@ def _plain_path_rendering(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: powerline_support.cache_clear() yield powerline_support.cache_clear() + + +@pytest.fixture(autouse=True) +def _reset_platform() -> Iterator[None]: + """Every test starts and ends on :data:`~meshterm.platforms.REGULAR`. + + A test that calls ``set_platform(PICOCALC)`` (directly, or via the gallery harness) + can't leak that choice into whatever runs next — regular is the suite's baseline, the + same way it is the default for a process that never passes ``--platform``/ + ``MESHTERM_PLATFORM`` (see :mod:`meshterm.platforms`). + """ + set_platform(REGULAR) + yield + set_platform(REGULAR) diff --git a/tests/test_gallery.py b/tests/test_gallery.py new file mode 100644 index 0000000..5ff08c8 --- /dev/null +++ b/tests/test_gallery.py @@ -0,0 +1,497 @@ +"""The dual-platform gallery: every screen the interactive menu can open, checked for +width discipline under both flavours this app runs as. + +Each entry below builds one full-screen :class:`~meshterm.ui.tui.screen.Screen` against +hand-built (simulator-shaped) data — the same "fake session, real screen" approach every +individual screen's own test file already uses — then renders it, both directly +(``render_body``) and through the real frame compositor (``compose_base``), at REGULAR's +72x24 and at PICOCALC's two live/lux row counts (53x26, the actual on-device floor; 53x40, +the boot-font/6x8-font-B case). No rendered line may exceed its terminal's width. + +This is the harness described in .claude/plans/picocalc-platform.md's "Parity" section: a +screen added here without surviving both platforms is meant to fail CI by default, so the +suite polices new work automatically instead of relying on someone remembering to check +by eye. It intentionally starts already covering every screen the interactive menu can +open (not growing empty) — a screen that doesn't yet fit 53 columns is marked ``xfail`` in +:data:`_KNOWN_WIDE` instead of being left out, so that list *is* the width-reduction +worklist P6 works through screen by screen; a screen graduates off it the moment its case +starts reporting XPASS. +""" + +from __future__ import annotations + +import logging +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable + +import pytest +from rich.cells import cell_len +from rich.text import Text + +from meshterm.core.courier_store import CourierStore +from meshterm.core.models import ( + ChatMessage, + Contact, + Conversation, + Hop, + Observation, + TraceResult, + TraceStats, + TxLevelResult, + utcnow, +) +from meshterm.core.watch_store import WatchStore +from meshterm.persistence.repository import DiscoveredPath +from meshterm.platforms import PICOCALC, REGULAR, Platform, set_platform +from meshterm.services.courier import CourierService +from meshterm.services.message_paths import Arrival +from meshterm.services.monitor_service import ACTIVITY_BUCKETS +from meshterm.services.records import CATEGORY_BY_ID +from meshterm.services.topology import MeshTopology, build_topology +from meshterm.ui.chat import ChatScreen +from meshterm.ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING +from meshterm.ui.contacts_screen import ContactsScreen +from meshterm.ui.courier_screen import CourierOutboxScreen +from meshterm.ui.dashboard_screen import DashboardScreen +from meshterm.ui.livefeed_screen import LiveFeedScreen +from meshterm.ui.map_render import MapMarker +from meshterm.ui.map_screen import MapScreen +from meshterm.ui.message_paths_screen import MessagePathsScreen +from meshterm.ui.node_detail_screen import NodeDetailScreen, _Action, _RoutesView, _Tab +from meshterm.ui.packet_viewer import PacketEntry, PacketViewer +from meshterm.ui.path_composer import PathComposerScreen +from meshterm.ui.records_screen import RecordDialog +from meshterm.ui.remote_cli import RemoteCliScreen +from meshterm.ui.timemachine_screen import TimeMachineScreen +from meshterm.ui.trace_screen import TraceScreen +from meshterm.ui.tui import Screen, frame +from meshterm.ui.tx_screen import TxSweepScreen +from meshterm.ui.walk_screen import WalkScreen +from meshterm.ui.widgets import ContactsSort, highlighted_hash + +from tests.conftest import plain as _plain + +_HUB_KEY = "3d63c6429436" + "0" * 52 +_FAR_KEY = "f2c24f54551e" + "0" * 52 + + +class _GallerySession: + """A minimal TuiSession stand-in every gallery screen is happy with (the same shape + each screen's own test file rolls independently as ``_FakeSession``/``_StubSession``). + """ + + def __init__(self, cols: int = 80, rows: int = 24) -> None: + self.repaints = 0 + self.stack: list = [] + self._cols, self._rows = cols, rows + + def invalidate(self) -> None: + self.repaints += 1 + + def push(self, screen) -> None: # noqa: ANN001 + self.stack.append(screen) + + def pop(self, screen=None) -> None: # noqa: ANN001 + if screen is None: + self.stack.pop() + elif screen in self.stack: + self.stack.remove(screen) + + def base_body_size(self) -> tuple[int, int]: + return self._cols, self._rows + + async def button_dialog(self, prompt, buttons, **kwargs): # noqa: ANN001 + return None + + +@dataclass +class _Entry: + """One gallery specimen: a name, and how to build it at a given terminal size.""" + + name: str + factory: Callable[[int, int], Screen] + #: Whether this screen ends in the app's standard exit group (a blank line then the + #: bare word "Back") — true only for the ``SelectScreen``-family list screens; the rest + #: exit via Esc (their ``footer_hint`` says so) with no dedicated exit row to find. + has_exit_group: bool = False + + +# --- factories: one per screen the interactive menu can open --------------------------- + + +def _dashboard(cols: int, rows: int) -> Screen: + return DashboardScreen( + session=_GallerySession(cols, rows), + resolve=lambda h: {"a1b2c3d4": "Alice", "3d63c642": "YUL-Cartierville"}.get(h, ""), + window=[ + Observation(node="a1b2c3d4", name="a1b2c3d4", kind="advert", snr=5.0, rssi=-90.0, + observed_at=utcnow()), + Observation(node="3d63c642", name="3d63c642", kind="packet", snr=-2.0, rssi=-104.0, + observed_at=utcnow()), + ], + activity=lambda: (2.0,) * ACTIVITY_BUCKETS, + activity_flags=lambda: (True,) * ACTIVITY_BUCKETS, + kind_counts=lambda: {"advert": 5, "ack": 2, "packet": 3}, + ) + + +def _contacts(cols: int, rows: int) -> Screen: + sort = ContactsSort.from_name("name", SORT_COLUMNS, SORT_OPENS_ASCENDING) + contacts = [ + Contact(name="Alice", public_key="aa" * 32), + Contact(name="A Rather Long Repeater Name For Width", public_key=_HUB_KEY), + ] + return ContactsScreen("Homestead", "cc" * 32, contacts, 1, {"aa" * 6: 7}, sort) + + +def _node_detail_header() -> Text: + header = Text("▲ ", style="#a78bfa") + header.append("YUL-Cartierville", style="accent") + header.append(" repeater", style="muted") + return header + + +def _node_detail(cols: int, rows: int) -> Screen: + return NodeDetailScreen( + title="Node — YUL-Cartierville", + header=_node_detail_header(), + info_rows=[ + ("key", highlighted_hash(_HUB_KEY, 1)), + ("heard", Text("5m ago")), + ("packets", Text("42")), + ], + tabs=[_Tab("Info", "info"), _Tab("Routes", "routes")], + minimap=None, + map_caption=None, + routes=_RoutesView(note="no route observed yet — trace to discover one"), + info_actions=[_Action("timemachine", "⏳", "", "Time machine — 42 receptions")], + trace_action=_Action("trace", "\U0001f3af", "", "Trace — auto route …"), + tail_actions=[_Action("back", "", "", "Back")], + ) + + +class _StubTileSource: + """An always-offline tile source: the gallery renders markers only, no network.""" + + available = False + max_zoom = 14 + + def load_tile(self, z: int, x: int, y: int): # noqa: ANN001 + return None + + +def _map(cols: int, rows: int) -> Screen: + session = _GallerySession(cols, rows) + markers = [ + MapMarker("Homestead", 45.50, -73.60, is_self=True), + MapMarker("YUL-Cartierville", 45.40, -73.50, is_repeater=True), + MapMarker("A Rather Long Node Name For Width", 45.55, -73.65), + ] + return MapScreen(session, markers, _StubTileSource(), 14) + + +def _chat(cols: int, rows: int) -> Screen: + conv = Conversation( + label="Alice", is_channel=False, + contact=Contact(name="Alice", public_key="d4" + "0" * 62, key_prefix="d4e5f6a7"), + ) + messages = [ + ChatMessage(text="on my way, should be there soon", outbound=False, peer="d4e5f6a7"), + ChatMessage(text="sounds good, see you shortly", outbound=True, peer="d4e5f6a7", acked=True), + ] + return ChatScreen( + conv, messages, send=None, names={"d4e5f6a7": "Alice"}, session=_GallerySession(cols, rows), + ) + + +def _livefeed(cols: int, rows: int) -> Screen: + return LiveFeedScreen( + session=_GallerySession(cols, rows), + resolve=lambda h: {"a1b2c3d4": "Alice"}.get(h, ""), + seed=[ + Observation(node="a1b2c3d4", name="a1b2c3d4", kind="advert", snr=5.0, rssi=-90.0, + observed_at=utcnow()), + ], + hub_active=lambda: True, + ) + + +def _walk_topo() -> tuple[MeshTopology, dict[str, Contact]]: + hub = Contact(name="YUL-Cartierville", public_key=_HUB_KEY, key_prefix="3d63c6429436") + far = Contact(name="Alice", public_key=_FAR_KEY, key_prefix="f2c24f54551e") + topo = MeshTopology("aa" * 6, contacts=[hub, far]) + hub_id, far_id = topo.canonical(hub.public_key), topo.canonical(far.public_key) + when = utcnow() + topo.add_walk([topo.self_id, hub_id], snrs=[6.0], when=when, source="trace") + topo.add_walk([hub_id, far_id], snrs=[-2.0], when=when, source="packet") + return topo, {hub_id: hub, far_id: far} + + +def _walk(cols: int, rows: int) -> Screen: + topo, contacts = _walk_topo() + return WalkScreen( + session=_GallerySession(cols, rows), topo=topo, contacts=contacts, self_label="Homestead", + ) + + +def _timemachine(cols: int, rows: int) -> Screen: + def build(window, width): # noqa: ANN001 + return [Text("YUL-Cartierville 5m ago advert"), Text("Alice 12m ago packet")] + + return TimeMachineScreen( + session=_GallerySession(cols, rows), label="YUL-Cartierville", build=build, + ) + + +def _message_paths(cols: int, rows: int) -> Screen: + message = ChatMessage(text="on my way, should be there soon", is_channel=True, + created_at=utcnow()) + arrivals = [ + Arrival(when=utcnow(), hops=("3d63c6",), snr=4.0), + Arrival(when=utcnow(), hops=("a1b2c3", "77aabb"), snr=-2.0), + ] + return MessagePathsScreen( + message, arrivals, matched=True, resolve=lambda h: h, prefix_bytes=1, + self_name="Homestead", summary="heard twice", source="Alice", + ) + + +def _remote_cli(cols: int, rows: int) -> Screen: + return RemoteCliScreen( + node_label="YUL-Cartierville", + history=["get name", "get name -> YUL-Cartierville"], + send=lambda c: None, session=_GallerySession(cols, rows), + ) + + +def _path_composer(cols: int, rows: int) -> Screen: + hub = Contact(name="YUL-Cartierville", public_key=_HUB_KEY, key_prefix="3d63c6429436") + far = Contact(name="Alice", public_key=_FAR_KEY, key_prefix="f2c24f54551e") + topo = build_topology( + self_id="aaaaaaaaaaaa" + "0" * 52, contacts=[hub, far], + trace_paths=[], packet_paths=[], neighbour_links=[], + ) + return PathComposerScreen( + device_label="Homestead", device_hash="aaaaaaaaaaaa" + "0" * 52, topology=topo, + width_bytes=1, hops=["3d63c6429436", "f2c24f54551e"], + ) + + +class _CourierStubDevice: + async def get_contacts(self) -> list: + return [] + + +class _CourierStubChat: + def __init__(self) -> None: + self.sent: list = [] + + async def send_direct(self, contact, text): # noqa: ANN001 + return None + + +class _CourierStubContext: + """The minimal AppContext surface :class:`CourierOutboxScreen` actually reads.""" + + def __init__(self, config_dir: Path) -> None: + self.courier_store = CourierStore(config_dir / "courier.json") + self.watch_store = WatchStore(config_dir / "watchtower.json") + self.chat = _CourierStubChat() + self.is_connected = True + self.log = logging.getLogger("test.gallery.courier") + self._device = _CourierStubDevice() + + async def device(self) -> _CourierStubDevice: + return self._device + + +def _courier_outbox(cols: int, rows: int) -> Screen: + config_dir = Path(tempfile.mkdtemp(prefix="meshterm-gallery-courier-")) + ctx = _CourierStubContext(config_dir) + ctx.courier = CourierService(ctx) + ctx.courier_store.queue("aa" * 6, "YUL-Cartierville", "battery reading requested please") + ctx.courier_store.queue("bb" * 6, "A Rather Long Contact Name For Width", "hello there") + return CourierOutboxScreen(ctx) + + +def _record(**over) -> DiscoveredPath: + defaults = dict( + id=1, category="grand_tour", width_bytes=1, spec="3d63c6429436,f2c24f54551e", + route=("3d63c6429436", "f2c24f54551e"), score=2.0, + stats={ + "hop_count": 2, "distinct_nodes": 2, "repeats": False, "min_snr": 6.0, + "km_travelled": 3.2, "km_complete": True, "far_km": 1.5, "rtt_ms": 250.0, + }, + app_version="0.1.0", + discovered_at=datetime(2026, 7, 12, 14, 30, tzinfo=timezone.utc), + ) + defaults.update(over) + return DiscoveredPath(**defaults) + + +def _record_dialog(cols: int, rows: int) -> Screen: + record = _record() + return RecordDialog( + record, CATEGORY_BY_ID[record.category], 1, + resolve=lambda h: {"3d63c6429436": "YUL-Cartierville", "f2c24f54551e": "Alice"}.get(h, h), + device_label="Homestead", device_hash=None, + ) + + +def _packet_viewer(cols: int, rows: int) -> Screen: + entry = PacketEntry( + when=utcnow(), kind="packet", node="3d63c6429436", path="3d63c6429436f2c24f54551e", + raw={"payload_typename": "GRP_TXT", "route_typename": "FLOOD"}, + ) + return PacketViewer( + [entry], 0, resolve=lambda h: {"3d63c6429436": "YUL-Cartierville"}.get(h, h), + ) + + +def _trace(cols: int, rows: int) -> Screen: + async def _noop_flow(current): # noqa: ANN001 + return current + + async def _trace_call(path_spec, on_trace): # noqa: ANN001 + pass # never invoked — the screen is seeded directly via _on_trace below + + screen = TraceScreen( + "Alice", mode="target", device_label="Homestead", device_hash="aa" * 32, + resolve=lambda label: label, session=_GallerySession(cols, rows), + trace=_trace_call, compose_path=_noop_flow, explore=_noop_flow, + pick_width=_noop_flow, pick_samples=_noop_flow, + width_bytes=lambda: 2, sample_count=lambda: 1, pace_s=0.0, + previous=None, auto_spec=lambda: "", auto_source="", + ) + screen._on_trace(TraceResult( + target="Alice", success=True, + hops=[Hop(0, "3d63c6", 5.0), Hop(1, None, 2.0)], + round_trip_ms=210.0, path_hash_bytes=2, + )) + return screen + + +def _tx_sweep(cols: int, rows: int) -> Screen: + screen = TxSweepScreen( + admin_label="YUL-Cartierville", target_label="Alice", device_label="Homestead", + device_hash="00" * 32, resolve=lambda h: h, session=_GallerySession(cols, rows), + tx_min=12, tx_max=28, step=3, samples=3, + run_sweep=lambda: None, apply_winner=lambda: None, + ) + screen.on_phase("coarse") + screen.on_level(1, 6, TxLevelResult( + tx_power=19, samples=3, successes=3, target_snr=8.8, score=8.8, + stats=TraceStats.from_traces("Alice", []), + )) + return screen + + +_ENTRIES: list[_Entry] = [ + _Entry("dashboard", _dashboard), + _Entry("contacts", _contacts, has_exit_group=True), + _Entry("node_detail", _node_detail), + _Entry("map", _map), + _Entry("chat", _chat), + _Entry("livefeed", _livefeed), + _Entry("walk", _walk), + _Entry("timemachine", _timemachine), + _Entry("message_paths", _message_paths), + _Entry("remote_cli", _remote_cli), + _Entry("path_composer", _path_composer), + _Entry("courier_outbox", _courier_outbox, has_exit_group=True), + _Entry("record_dialog", _record_dialog), + _Entry("packet_viewer", _packet_viewer), + _Entry("trace", _trace), + _Entry("tx_sweep", _tx_sweep), +] + +#: (platform, cols, rows) combos every entry above renders under. PicoCalc gets both its +#: live floor (53x26, the on-device measurement — see the plan's P0 appendix) and the lux +#: case (53x40, today's boot fbcon font / a future 6x8 font). Regular is unchanged by this +#: seam's arrival, so it stays the existing 72x24 standard. +_COMBOS: list[tuple[Platform, int, int]] = [ + (REGULAR, REGULAR.readable_cols, REGULAR.readable_rows), + (PICOCALC, PICOCALC.readable_cols, PICOCALC.readable_rows), + (PICOCALC, PICOCALC.readable_cols, 40), +] + +#: Entries that overflow PICOCALC's 53 columns today (a width overflow doesn't depend on +#: row count, so one entry here covers both picocalc combos). This is P6's worklist, not a +#: TODO comment — pull an entry out the moment its case reports XPASS instead of xfail. +_KNOWN_WIDE: set[str] = { + "contacts", + "map", + "walk", + "livefeed", + "remote_cli", + "path_composer", +} + +#: Entries whose footer_hint already overflowed the *existing* 72-column standard before +#: this platform seam existed. Not this phase's to fix: CLAUDE.md's 72-col rule predates +#: the seam, and per standing guidance old chrome that already broke it is left for a +#: dedicated pass rather than retrofitted as a drive-by here (map's footer grows an +#: "offline — no basemap" suffix that was already too long without it). +_PREEXISTING_REGULAR_OVERFLOW: set[str] = {"map"} + + +def _cases(): + for entry in _ENTRIES: + for platform, cols, rows in _COMBOS: + case_id = f"{entry.name}-{platform.name}-{cols}x{rows}" + marks = [] + if platform.name == "picocalc" and entry.name in _KNOWN_WIDE: + marks.append(pytest.mark.xfail( + reason=( + f"{entry.name} overflows picocalc's {PICOCALC.readable_cols} cols " + "today -- P6 worklist (.claude/plans/picocalc-platform.md)" + ), + strict=False, + )) + if platform is REGULAR and entry.name in _PREEXISTING_REGULAR_OVERFLOW: + marks.append(pytest.mark.xfail( + reason=( + f"{entry.name} already overflows the existing 72-col standard, " + "pre-dating the platform seam -- not retrofitted here, see " + "_PREEXISTING_REGULAR_OVERFLOW" + ), + strict=False, + )) + yield pytest.param(entry, platform, cols, rows, id=case_id, marks=marks) + + +def _assert_fits(lines: list[str], cols: int, where: str) -> None: + """Assert every line of already-rendered output is within ``cols`` display cells.""" + for i, line in enumerate(lines): + width = cell_len(_plain(line)) + assert width <= cols, f"{where} line {i} is {width} cells, over {cols} allowed: {line!r}" + + +@pytest.mark.parametrize("entry,platform,cols,rows", list(_cases())) +def test_gallery_screen_fits_its_platform( + entry: _Entry, platform: Platform, cols: int, rows: int, +) -> None: + """Every gallery specimen renders within its platform's width, raw and framed alike.""" + set_platform(platform) + screen = entry.factory(cols, rows) + screen.note_viewport(max(1, rows - 4)) # mirrors compose_base's own viewport math + + _assert_fits(screen.render_body(cols), cols, "render_body") + # footer_hint may itself carry Rich markup (e.g. map's "[warn]offline[/warn]" tag), the + # same way compose_base renders it (`Text.from_markup(f"[muted]{footer_hint}[/muted]")") + # — so it must be parsed, not just ANSI-stripped, before measuring. + footer_plain = Text.from_markup(screen.footer_hint).plain + assert cell_len(footer_plain) <= cols, ( + f"footer_hint renders to {cell_len(footer_plain)} cells, over {cols}: {footer_plain!r}" + ) + + composed = frame.compose_base(Text(""), screen, screen.footer_hint, cols, rows) + _assert_fits(composed.split("\n"), cols, "compose_base") + + if entry.has_exit_group: + lines = _plain(screen.render_body(cols)).splitlines() + assert lines[-1].strip() == "Back", f"{entry.name}: exit row missing, got {lines[-1]!r}" + assert lines[-2].strip() == "", f"{entry.name}: no blank separator before Back" diff --git a/tests/test_platforms.py b/tests/test_platforms.py new file mode 100644 index 0000000..718b3c0 --- /dev/null +++ b/tests/test_platforms.py @@ -0,0 +1,143 @@ +"""The platform seam: resolution order, the active-platform singleton, and the two +platform specs' own sanity (PicoCalc is strictly the narrower, plainer flavour). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import meshterm.platforms as platforms_mod +from meshterm.platforms import PICOCALC, REGULAR, get_platform, resolve, set_platform + + +def test_regular_is_todays_behaviour() -> None: + """REGULAR is the desktop terminal exactly as it behaves without this seam.""" + assert REGULAR.name == "regular" + assert REGULAR.width_reclaim is True + assert REGULAR.emoji is True + assert REGULAR.truecolor is True + assert REGULAR.frame_border is True + assert REGULAR.readable_cols == 72 + + +def test_picocalc_is_the_narrower_plainer_flavour() -> None: + """PICOCALC never claims a capability the console can't back up.""" + assert PICOCALC.name == "picocalc" + assert PICOCALC.readable_cols < REGULAR.readable_cols + assert PICOCALC.width_reclaim is False # a phantom column would tear the exact-width frame + assert PICOCALC.emoji is False + assert PICOCALC.truecolor is False + assert PICOCALC.name_colour == "type" + + +def test_get_platform_defaults_to_regular() -> None: + """Every test starts on REGULAR (see the autouse fixture in conftest.py).""" + assert get_platform() is REGULAR + + +def test_set_platform_is_visible_through_get_platform() -> None: + """set_platform takes effect immediately for any caller reading via get_platform().""" + set_platform(PICOCALC) + assert get_platform() is PICOCALC + set_platform(REGULAR) + assert get_platform() is REGULAR + + +def test_set_platform_reaches_a_dotted_import_too() -> None: + """A consumer that imports the module (not the name) sees the change immediately. + + This is the exact footgun the module docstring warns about: ``from meshterm.platforms + import PLATFORM`` at a module's top level would NOT see this, but module-qualified + access (``platforms_mod.PLATFORM``) — and every real binding point in this codebase, + which reads through :func:`get_platform` — does. + """ + set_platform(PICOCALC) + assert platforms_mod.PLATFORM is PICOCALC + + +def test_resolve_defaults_to_regular_off_device(monkeypatch: pytest.MonkeyPatch) -> None: + """No flag, no env, no device tree (every desktop/CI machine) → REGULAR, source 'default'.""" + monkeypatch.delenv("MESHTERM_PLATFORM", raising=False) + monkeypatch.setattr(platforms_mod, "_DEVICE_TREE_MODEL_PATH", Path("/nonexistent/model")) + r = resolve() + assert r.platform is REGULAR + assert r.source == "default" + assert r.flag is None and r.env is None and r.detected_model is None + + +def test_resolve_flag_wins_over_everything(monkeypatch: pytest.MonkeyPatch) -> None: + """An explicit --platform overrides both the env var and auto-detection.""" + monkeypatch.setenv("MESHTERM_PLATFORM", "picocalc") + monkeypatch.setattr(platforms_mod, "_DEVICE_TREE_MODEL_PATH", Path("/nonexistent/model")) + r = resolve("regular") + assert r.platform is REGULAR + assert r.source == "--platform flag" + assert r.env == "picocalc" # recorded even though it didn't decide the outcome + + +def test_resolve_env_wins_when_no_flag(monkeypatch: pytest.MonkeyPatch) -> None: + """MESHTERM_PLATFORM decides when --platform wasn't passed.""" + monkeypatch.setenv("MESHTERM_PLATFORM", "picocalc") + monkeypatch.setattr(platforms_mod, "_DEVICE_TREE_MODEL_PATH", Path("/nonexistent/model")) + r = resolve(None) + assert r.platform is PICOCALC + assert r.source == "MESHTERM_PLATFORM env var" + + +def test_resolve_matching_is_case_insensitive_and_trims_space() -> None: + """A flag/env value matches regardless of case or stray whitespace.""" + assert resolve(" PicoCalc ").platform is PICOCALC + assert resolve("REGULAR").platform is REGULAR + + +def test_resolve_unknown_flag_raises_with_the_choices(monkeypatch: pytest.MonkeyPatch) -> None: + """A typo'd --platform is a clear ValueError, not a silent fallback to REGULAR.""" + monkeypatch.delenv("MESHTERM_PLATFORM", raising=False) + with pytest.raises(ValueError) as exc: + resolve("laptop") + message = str(exc.value) + assert "laptop" in message and "--platform" in message + assert "picocalc" in message and "regular" in message + + +def test_resolve_unknown_env_raises_only_when_it_would_decide( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A bad env var is only an error when nothing else (the flag) overrides it.""" + monkeypatch.setenv("MESHTERM_PLATFORM", "laptop") + # A valid flag wins outright — the bad env var is recorded but never validated. + assert resolve("regular").platform is REGULAR + with pytest.raises(ValueError) as exc: + resolve(None) + assert "MESHTERM_PLATFORM" in str(exc.value) + + +def test_resolve_auto_detects_the_lyra_model(monkeypatch: pytest.MonkeyPatch) -> None: + """The exact on-device string (P0, 2026-08-01) — trailing space and NUL included.""" + monkeypatch.delenv("MESHTERM_PLATFORM", raising=False) + + class _FakePath: + def read_bytes(self) -> bytes: + return b"Luckfox Lyra \x00" + + monkeypatch.setattr(platforms_mod, "_DEVICE_TREE_MODEL_PATH", _FakePath()) + r = resolve() + assert r.platform is PICOCALC + assert r.source == "auto-detect" + assert r.detected_model == "Luckfox Lyra" # trailing space/NUL stripped + + +def test_resolve_auto_detect_is_conservative(monkeypatch: pytest.MonkeyPatch) -> None: + """A device tree that exists but names different hardware never guesses picocalc.""" + monkeypatch.delenv("MESHTERM_PLATFORM", raising=False) + + class _FakePath: + def read_bytes(self) -> bytes: + return b"Raspberry Pi 4 Model B\x00" + + monkeypatch.setattr(platforms_mod, "_DEVICE_TREE_MODEL_PATH", _FakePath()) + r = resolve() + assert r.platform is REGULAR + assert r.detected_model == "Raspberry Pi 4 Model B" diff --git a/tests/test_session_width.py b/tests/test_session_width.py index 12857d2..e2e9e83 100644 --- a/tests/test_session_width.py +++ b/tests/test_session_width.py @@ -4,6 +4,10 @@ Some terminals (and prompt_toolkit's size probe on them) report the window one c narrower than it really is, so the frame's right border lands one short and the true last column sits unused. The session can report one extra column to close that gap. The wrapping output is pure and the resolver is a small branch, so both are assertable without a terminal. + +The default comes from the active :class:`~meshterm.platforms.Platform` (on, desktop; off, +PicoCalc — reclaiming a phantom column on its exact-width console would tear the frame), and +``MESHTERM_FULL_WIDTH`` remains an explicit override on top of that default either way. """ from __future__ import annotations @@ -12,7 +16,7 @@ from types import SimpleNamespace from prompt_toolkit.data_structures import Size -import meshterm.ui.tui.session as session_mod +from meshterm.platforms import PICOCALC, REGULAR, set_platform from meshterm.ui.tui.session import TuiSession, _WidthExtendedOutput @@ -36,17 +40,35 @@ def test_session_leaves_a_supplied_output_untouched() -> None: assert session._resolve_output() is dummy -def test_session_wraps_the_real_terminal_when_enabled(monkeypatch) -> None: - """No supplied output + gate on → the real terminal output is width-extended.""" +def test_session_wraps_the_real_terminal_on_regular(monkeypatch) -> None: + """No supplied output + REGULAR (today's default) → the real terminal is width-extended.""" fake = SimpleNamespace(get_size=lambda: Size(rows=30, columns=100)) monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: fake) - monkeypatch.setattr(session_mod, "_RECLAIM_LAST_COLUMN", True) + monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False) + set_platform(REGULAR) out = TuiSession()._resolve_output() assert isinstance(out, _WidthExtendedOutput) assert out.get_size() == Size(rows=30, columns=101) -def test_session_gate_off_uses_the_bare_terminal(monkeypatch) -> None: - """Gate off → the real terminal output is used untouched (pt builds its own).""" - monkeypatch.setattr(session_mod, "_RECLAIM_LAST_COLUMN", False) +def test_session_leaves_the_bare_terminal_on_picocalc(monkeypatch) -> None: + """PICOCALC's exact-width console → no reclaim: a phantom column would tear the frame.""" + monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False) + set_platform(PICOCALC) + assert TuiSession()._resolve_output() is None + + +def test_env_override_forces_reclaim_on_despite_picocalc(monkeypatch) -> None: + """``MESHTERM_FULL_WIDTH=1`` wins over PICOCALC's off-by-default.""" + fake = SimpleNamespace(get_size=lambda: Size(rows=30, columns=100)) + monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: fake) + monkeypatch.setenv("MESHTERM_FULL_WIDTH", "1") + set_platform(PICOCALC) + assert isinstance(TuiSession()._resolve_output(), _WidthExtendedOutput) + + +def test_env_override_forces_reclaim_off_despite_regular(monkeypatch) -> None: + """``MESHTERM_FULL_WIDTH=0`` wins over REGULAR's on-by-default.""" + monkeypatch.setenv("MESHTERM_FULL_WIDTH", "0") + set_platform(REGULAR) assert TuiSession()._resolve_output() is None