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 <noreply@anthropic.com>
This commit is contained in:
1 parent
077742868a
commit
8f1fba135e
8 files changed
+1044
-26
No files matched your search
+38
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
+6
-2
@@ -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))
|
||||
|
||||
+30
-16
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user