Files
MeshTerm/tests/test_vt_console_font.py
T
Jean-Pierre MartineauandClaude Opus 5.5 efc02ddced The code text uses the glossary words in more places
A final pass over the docstrings and comments: "the own X of Y" is now
"Y's own X" (84 places), a navigation entry is a screen on the stack, our
node is our node, a paint is a paint, the quoted UX labels are in code
font, and a list of punctuation glyphs is in code font.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 21:45:04 -04:00

383 lines
14 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for the console-font switch of the PicoCalc: the gate, the three verbs, and the wiring.
No test here runs ``setfont``. The module under test starts another program, so the tests
check which command line the module builds. They also check that it never builds a command
line on a machine that it must not change. A recorder replaces :func:`subprocess.run` in
all the tests. A real call would make a test change the console of the developer.
The tests ask three questions, in this order:
1. Does :func:`~meshterm.services.consolefont.applies` return false in each case where it
must?
2. Do the verbs give the correct arguments to ``setfont``, and do they ignore a failure?
3. Is the switch connected to the menu only? Then a scripted command that the user types
into the terminal of another person never repaints it.
"""
from __future__ import annotations
import io
import os
import subprocess
from pathlib import Path
from typing import Any
import pytest
from rich.console import Console
from meshterm.context import AppContext
from meshterm.core.admin_store import AdminStore
from meshterm.core.config import Settings
from meshterm.core.device_store import DeviceStore
from meshterm.core.preferences import Preferences, by_group, get_spec
from meshterm.persistence.repository import Repository
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
from meshterm.services import consolefont
from meshterm.tools.preferences import PreferencesTool
from meshterm.ui.theme import active_theme
#: The result that :func:`subprocess.run` returns for a ``setfont`` that succeeded.
_OK = subprocess.CompletedProcess(["setfont"], 0, "", "")
class _Recorder:
"""A substitute for :func:`subprocess.run`.
It stores each argv and gives the scripted answers.
"""
def __init__(self, answers: list[subprocess.CompletedProcess] | None = None) -> None:
self.calls: list[list[str]] = []
self.answers = list(answers or [])
def __call__(self, argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess:
self.calls.append(list(argv))
return self.answers.pop(0) if self.answers else _OK
@pytest.fixture
def device(monkeypatch: pytest.MonkeyPatch) -> _Recorder:
"""Make the process look like MeshTerm that runs on the handheld's own VT."""
set_platform(PICOCALC_LYRA)
monkeypatch.setattr(os, "ttyname", lambda fd: "/dev/tty1", raising=False)
monkeypatch.setattr(consolefont.shutil, "which", lambda name: "/usr/bin/setfont")
recorder = _Recorder()
monkeypatch.setattr(consolefont.subprocess, "run", recorder)
return recorder
# -- the gate --------------------------------------------------------------------
def test_the_switch_applies_on_the_handhelds_own_console(device: _Recorder) -> None:
"""The switch applies when all three conditions are true.
The conditions are: the platform is the PicoCalc, stdin is a Linux VT, and a ``setfont``
is available to run.
"""
assert consolefont.applies() is True
def test_a_desktop_never_touches_a_console_font(device: _Recorder) -> None:
"""A desktop never changes a console font.
The fonts belong to the PicoCalc. No other platform has them or needs them.
"""
set_platform(REGULAR)
assert consolefont.applies() is False
@pytest.mark.parametrize("tty", ["/dev/pts/3", "/dev/ttyS1", "/dev/console"])
def test_anything_that_is_not_a_linux_vt_is_left_alone(
device: _Recorder, monkeypatch: pytest.MonkeyPatch, tty: str
) -> None:
"""The switch leaves alone each tty that is not a Linux VT.
An ssh pty has no font of its own. A serial line is not a console that MeshTerm owns.
"""
monkeypatch.setattr(os, "ttyname", lambda fd: tty, raising=False)
assert consolefont.applies() is False
def test_a_redirected_stdin_is_not_a_vt(device: _Recorder, monkeypatch: pytest.MonkeyPatch) -> None:
"""A redirected stdin is not a VT.
If ``os.ttyname`` raises an exception (for a pipe, or on Windows, which has no such
call), the answer is no.
"""
def _no_tty(fd: int) -> str:
raise OSError(25, "not a tty")
monkeypatch.setattr(os, "ttyname", _no_tty, raising=False)
assert consolefont.applies() is False
def test_no_setfont_means_no_switching(device: _Recorder, monkeypatch: pytest.MonkeyPatch) -> None:
"""If there is no ``setfont``, there is no switch.
The font build also needs the kbd tools. Without them, no verb works.
"""
monkeypatch.setattr(consolefont.shutil, "which", lambda name: None)
assert consolefont.applies() is False
def test_a_gated_out_machine_runs_nothing_at_all(device: _Recorder) -> None:
"""A machine that the gate excludes runs nothing.
Each verb asks the gate first. Thus a no does nothing, and it does not start a
subprocess by mistake.
"""
set_platform(REGULAR)
assert consolefont.remember() is None
assert consolefont.apply("6x8") is False
assert device.calls == []
# -- the three verbs -------------------------------------------------------------
def test_remember_saves_the_font_the_shell_had(device: _Recorder) -> None:
"""``setfont -O`` writes the current font and its unicode table.
The file goes in the home directory of the app.
"""
saved = consolefont.remember()
assert saved is not None
assert saved.name == consolefont.SAVED_NAME
assert device.calls == [["setfont", "-O", str(saved)]]
def test_apply_loads_the_file_the_preference_names(device: _Recorder) -> None:
"""Apply loads the file that the preference names.
The value is a cell size. This module owns the path that the value maps to. The path is
not a preference.
"""
assert consolefont.apply("6x8") is True
assert device.calls == [["setfont", "/usr/share/consolefonts/meshterm8.psf.gz"]]
assert consolefont.apply("6x12") is True
assert device.calls[-1] == ["setfont", "/usr/share/consolefonts/meshterm.psf.gz"]
def test_no_call_ever_names_a_tty(device: _Recorder) -> None:
"""No call names a tty. It never uses ``-C``.
``setfont`` with no tty name acts on the console that the user looks at.
"""
consolefont.remember()
consolefont.apply("6x8")
assert not any("-C" in argv for argv in device.calls)
def test_a_value_with_no_font_behind_it_changes_nothing(device: _Recorder) -> None:
"""A value that names no font changes nothing.
A key from a file that the user edited by hand can name no font. The result is that
nothing happens, and there is no crash.
"""
assert consolefont.apply("8x16") is False
assert device.calls == []
def test_restore_reloads_the_saved_font_and_drops_the_file(
device: _Recorder, tmp_path: Path
) -> None:
"""The way out: load again exactly the font that the app saved, then leave no file."""
saved = tmp_path / consolefont.SAVED_NAME
saved.write_bytes(b"PSF")
consolefont.restore(saved)
assert device.calls == [["setfont", str(saved)]]
assert not saved.exists()
def test_restoring_twice_is_harmless(device: _Recorder, tmp_path: Path) -> None:
"""A second restore does no harm.
The ``finally`` of the session and the atexit hook both run. The second one finds
nothing to do.
"""
saved = tmp_path / consolefont.SAVED_NAME
saved.write_bytes(b"PSF")
consolefont.restore(saved)
consolefont.restore(saved)
consolefont.restore(None)
assert device.calls == [["setfont", str(saved)]]
def test_a_setfont_that_fails_is_swallowed(
device: _Recorder, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A ``setfont`` that fails is ignored.
A font is only cosmetic. A non-zero exit status gives a log line and never an
exception.
"""
monkeypatch.setattr(
consolefont.subprocess,
"run",
_Recorder([subprocess.CompletedProcess(["setfont"], 1, "", "setfont: KDFONTOP: EINVAL")]),
)
assert consolefont.apply("6x8") is False
def test_a_setfont_that_cannot_be_run_is_swallowed(
device: _Recorder, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A ``setfont`` that cannot run is ignored.
The same rule applies to a binary that disappears between the ``which()`` and the exec,
and to a timeout.
"""
def _boom(argv: list[str], **kwargs: Any) -> Any:
raise OSError(2, "No such file or directory")
monkeypatch.setattr(consolefont.subprocess, "run", _boom)
assert consolefont.apply("6x8") is False
assert consolefont.remember() is None
def test_a_failed_remember_leaves_nothing_to_restore(
device: _Recorder, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A ``remember`` that fails leaves nothing to restore.
If nothing is saved, there is nothing to load again. The ``finally`` of the caller gets
a None.
"""
monkeypatch.setattr(
consolefont.subprocess,
"run",
_Recorder([subprocess.CompletedProcess(["setfont"], 1, "", "denied")]),
)
assert consolefont.remember() is None
# -- the preference --------------------------------------------------------------
def test_the_preference_is_offered_on_the_handheld_only() -> None:
"""The preference is offered on the handheld only.
A row about hardware that the desktop does not have is a question that the desktop
cannot answer.
"""
spec = get_spec("console_font")
assert spec.offered_on("picocalc-lyra") is True
assert spec.offered_on("regular") is False
# All other preferences are offered on all platforms. The gate is the exception.
gated = {s.key for _, specs in by_group() for s in specs if s.platforms is not None}
assert gated == {"console_font"}
@pytest.mark.parametrize("platform", [REGULAR, PICOCALC_LYRA])
def test_the_preference_round_trips_on_every_platform(tmp_path: Path, platform: Any) -> None:
"""The preference makes a round trip on each platform.
A file that was written on the handheld loads, keeps its value, and saves again on a
desktop.
"""
set_platform(platform)
path = tmp_path / "preferences.toml"
written = Preferences(path)
written.set("console_font", "6x8")
written.save()
reread = Preferences.load(path)
assert reread.get("console_font") == "6x8"
# A second save keeps it. The gate hides a row, and it never removes a value.
reread.save()
assert "console_font" in path.read_text(encoding="utf-8")
assert Preferences.load(path).get("console_font") == "6x8"
def test_the_page_draws_the_row_on_the_handheld_and_not_on_the_desktop() -> None:
"""The page draws the row on the handheld and not on the desktop.
This is the one visible effect of the gate: a Display row that exists on one platform.
"""
from meshterm.ui.preferences import _menu_items
set_platform(PICOCALC_LYRA)
_, items = _menu_items(Preferences(), {})
assert "console_font" in [getattr(item, "value", None) for item in items]
set_platform(REGULAR)
_, items = _menu_items(Preferences(), {})
assert "console_font" not in [getattr(item, "value", None) for item in items]
def test_the_printed_table_still_names_every_preference() -> None:
"""The printed table still names each preference.
The listing of the CLI does not depend on the platform. This is on purpose, because
nobody could set a key that the listing hides.
"""
from meshterm.ui.preferences import preferences_table
set_platform(REGULAR)
console = Console(width=120, file=io.StringIO(), theme=active_theme(), legacy_windows=False)
with console.capture() as capture:
console.print(preferences_table(Preferences(), 120))
assert "console_font" in capture.get()
# -- the wiring ------------------------------------------------------------------
@pytest.fixture
def ctx(tmp_path: Path) -> Any:
"""A context that uses the mock device. Its preferences are in a temporary file."""
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "prefs.db")
context = AppContext(
console=Console(file=io.StringIO()),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
yield context
context.repo.close()
async def test_the_command_line_never_repaints_the_console(
device: _Recorder, ctx: AppContext
) -> None:
"""`meshterm preferences set console_font 6x8` writes the file and changes nothing else.
The user types a scripted run into a console that MeshTerm does not own. MeshTerm is
there as a guest. In half of the cases, it is an ssh session onto the handheld from
another place.
"""
result = await PreferencesTool().run(ctx, {"ops": [("set", "console_font", "6x8")]})
assert result.summary == {"changes": 1}
assert ctx.preferences.get("console_font") == "6x8"
assert device.calls == []
async def test_the_page_switches_the_font_as_it_saves(device: _Recorder, ctx: AppContext) -> None:
"""The page switches the font when it saves.
On the menu path, the app loads the new font at once, and not at the next start.
"""
from meshterm.ui.surface import TuiUi
ctx.ui = TuiUi(object()) # type: ignore[arg-type]
await PreferencesTool().run(ctx, {"ops": [("set", "console_font", "6x8")]})
assert device.calls == [["setfont", "/usr/share/consolefonts/meshterm8.psf.gz"]]
async def test_another_preference_leaves_the_font_alone(device: _Recorder, ctx: AppContext) -> None:
"""Another preference leaves the font alone.
Only the one preference has an effect outside the app. The app only writes the others.
"""
from meshterm.ui.surface import TuiUi
ctx.ui = TuiUi(object()) # type: ignore[arg-type]
await PreferencesTool().run(ctx, {"ops": [("set", "history_days", "30")]})
assert device.calls == []