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>
383 lines
14 KiB
Python
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 == []
|