Files
MeshTerm/tests/test_console_host.py
T
Jean-Pierre MartineauandClaude Opus 5.5 b3bf5a6160 The Cardputer Zero gets a console host, and a desktop simulator built on it
The stock image gives an app no text console, so MeshTerm draws its own: the TUI
runs in-process with prompt_toolkit's output feeding a small VT parser, the cells
are drawn in Terminus 6x12 with MeshTerm's marks over it, and keys go in through a
pipe in xterm's spelling. The front end is the only part that differs: framebuffer
and evdev on the device (written ahead of the hardware), a Tk window on the desktop.

Each handheld's glyph inventory is now named by Platform.font, which replaces
ascii_fold; the render-boundary fold derives its accents, emoji, and fallback from
that inventory and quantizes colour only where truecolor is off.

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

261 lines
9.8 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The Cardputer's console host: its terminal, keys, font, pixels, and a whole run."""
from __future__ import annotations
import threading
import time
from prompt_toolkit.input.vt100_parser import Vt100Parser
from prompt_toolkit.keys import Keys
from meshterm.host.device import KeyState
from meshterm.host.font import CELL_H, MARKS, MISSING, build_font, load_bdf
from meshterm.host.keys import Key, encode
from meshterm.host.raster import PANEL_H, PANEL_W, Raster, rgb565
from meshterm.host.run import host_output, run
from meshterm.host.sim import key_from_tk
from meshterm.host.vt import BOLD, REVERSE, UNDERLINE, Terminal
from meshterm.ui.tui.fkeys import CARDPUTER_DECK
_BDF = """STARTFONT 2.1
FONT test
SIZE 12 72 72
FONTBOUNDINGBOX 6 12 0 -2
FONT_ASCENT 10
FONT_DESCENT 2
CHARS 1
STARTCHAR A
ENCODING 65
BBX 6 12 0 -2
BITMAP
00
00
70
88
88
88
F8
88
88
88
00
00
ENDCHAR
ENDFONT
"""
def _parsed(data: str) -> list[Keys | str]:
keys: list[Keys | str] = []
parser = Vt100Parser(lambda press: keys.append(press.key))
parser.feed(data)
parser.flush()
return keys
# --- the terminal ---------------------------------------------------------------------
def test_text_lands_where_the_cursor_is_and_wraps_at_the_edge() -> None:
"""Printing starts at the cursor and wraps onto the next row at the right edge."""
term = Terminal(6, 3)
term.feed("\x1b[2;3Habcdef")
assert term.line(1) == " abcd"
assert term.line(2) == "ef "
def test_erase_and_cursor_moves() -> None:
"""Erase-to-end and erase-to-start clear what they name and nothing else."""
term = Terminal(8, 2)
term.feed("abcdefgh\r\nijklmnop\x1b[1;4H\x1b[K\x1b[2;2H\x1b[1K")
assert term.text() == "abc \n klmnop"
def test_sgr_in_every_colour_depth() -> None:
"""16-colour, 256-colour and truecolor SGR, with either separator, and the flags."""
term = Terminal(8, 1, palette=[(i, i, i) for i in range(16)])
term.feed("\x1b[31ma\x1b[38;5;196mb\x1b[38;2;1;2;3mc\x1b[38:2::4:5:6md\x1b[1;4;7me\x1b[0mf")
styles = [style for _, style in term.screen[0][:6]]
assert styles[0].fg == (1, 1, 1) # palette slot 1
assert styles[1].fg == (255, 0, 0) # the 256-colour cube's pure red
assert styles[2].fg == (1, 2, 3) and styles[3].fg == (4, 5, 6)
assert styles[4].flags == BOLD | UNDERLINE | REVERSE
assert styles[5].fg is None and styles[5].flags == 0
def test_unknown_sequences_are_skipped_whole() -> None:
"""A sequence the parser doesn't model leaves nothing of itself on screen."""
term = Terminal(10, 1)
term.feed("\x1b]0;a title\x07\x1b[?2004h\x1b[>4;2m\x1bPq#0;\x1b\\x\x1b(By")
assert term.line(0) == "xy "
def test_the_alternate_screen_keeps_the_main_one() -> None:
"""Leaving the alternate screen brings the main one back as it was."""
term = Terminal(4, 1)
term.feed("main\x1b[?1049h\x1b[2J\x1b[Halt")
assert term.line(0) == "alt "
term.feed("\x1b[?1049l")
assert term.line(0) == "main"
def test_only_changed_rows_are_dirty() -> None:
"""A one-cell change marks one row for redrawing."""
term = Terminal(4, 3)
term.take_dirty()
term.feed("\x1b[3;1Hx")
assert term.take_dirty() == {2}
def test_prompt_toolkit_draws_into_it() -> None:
"""The output the host hands prompt_toolkit really does land in the grid."""
term = Terminal(20, 2)
frames: list[int] = []
output = host_output(term, threading.Lock(), lambda: frames.append(1))
output.cursor_goto(2, 4) # 1-based, as a terminal counts
output.write("hello")
output.flush()
assert term.line(1).startswith(" hello") and frames
assert output.get_size().columns == 20 and output.get_size().rows == 2
# --- keys -----------------------------------------------------------------------------
def test_the_lane_keys_reach_prompt_toolkit_as_the_deck_expects() -> None:
"""Fn+4..8 parse as F4-F8 and Shift with them as F16-F20, the Cardputer deck's banks."""
plain = [_parsed(encode(Key(name=f"f{n}")))[0] for n in range(4, 9)]
shifted = [_parsed(encode(Key(name=f"f{n}", shift=True)))[0] for n in range(4, 9)]
assert [k.value for k in plain] == [f"f{n}" for n in CARDPUTER_DECK.keys]
assert [k.value for k in shifted] == [f"f{n}" for n in CARDPUTER_DECK.shift_keys]
def test_modified_navigation_and_control_letters() -> None:
"""Ctrl+PgUp (the section jump), arrows, Shift+Tab, and the C0 control letters."""
assert _parsed(encode(Key(name="pageup", ctrl=True))) == [Keys.ControlPageUp]
assert _parsed(encode(Key(name="up"))) == [Keys.Up]
assert _parsed(encode(Key(name="tab", shift=True))) == [Keys.BackTab]
assert encode(Key(text="q", ctrl=True)) == "\x11"
assert encode(Key(text="w", ctrl=True)) == "\x17"
assert encode(Key(name="escape")) == "\x1b"
assert encode(Key(name="backspace")) == "\x7f"
def test_tk_events_become_keys() -> None:
"""The simulator reads Shift, Ctrl and text off Tk's events."""
assert key_from_tk("F4", "", 0x0001) == Key(name="f4", shift=True)
assert key_from_tk("q", "\x11", 0x0004) == Key(text="q", ctrl=True)
assert key_from_tk("a", "a", 0) == Key(text="a")
assert key_from_tk("Shift_L", "", 0) is None
def test_the_device_keyboard_types_what_its_keycaps_say() -> None:
"""Letters and digits as printed, Sym's placeholder codes as M5's keymap names them."""
keys = KeyState()
assert keys.event(30, 1) == Key(text="a") # KEY_A
keys.event(42, 1) # Shift down — the driver holds it for a sticky Shift too
assert keys.event(30, 1) == Key(text="A")
assert keys.event(62, 1) == Key(name="f4", shift=True) # Shift+Fn+4
keys.event(42, 0)
assert keys.event(26, 1) == Key(text="!") # Sym+1 arrives as KEY_LEFTBRACE
assert keys.event(93, 1) == Key(text="?") # Sym+M
assert keys.event(389, 1) is None # the Fn key itself (KEY_DVD) types nothing
assert keys.event(30, 0) is None # a release types nothing
# --- the font and the pixels ------------------------------------------------------------
def test_a_bdf_glyph_lands_in_the_cell(tmp_path) -> None:
"""A BDF glyph is set into the 6x12 cell where its bounding box says."""
path = tmp_path / "t.bdf"
path.write_text(_BDF)
glyph = load_bdf(path)[65]
assert len(glyph) == CELL_H
assert glyph[2] == 0x70 and glyph[6] == 0xF8
def test_meshterm_marks_draw_over_the_base_and_gaps_show() -> None:
"""MeshTerm's marks win over the base font, and a missing glyph draws as a box."""
font = build_font({0x2605: bytes(12)})
assert font.glyph("★") == MARKS[0x2605] # the mark wins over the base's glyph
assert font.glyph("⋯") == MARKS[0x2026] # aliased, as on the PicoCalc
assert font.glyph("一") == MISSING
def test_the_raster_draws_a_cell_in_its_colours() -> None:
"""A cell's ink and paper land in the right pixels, and only dirty rows redraw."""
term = Terminal(53, 14)
font = build_font({0x41: bytes([0, 0, 0x70, 0x88, 0x88, 0x88, 0xF8, 0x88, 0x88, 0x88, 0, 0])})
raster = Raster(term, font, pack=rgb565, default_bg=(0, 0, 0))
term.feed("\x1b[38;2;255;255;255m\x1b[48;2;0;0;255mA")
bands = raster.update()
assert bands == [(raster.top, raster.top + 12 * 14)] # the first paint is every row
assert len(raster.pixels) == PANEL_W * PANEL_H * 2
def pixel(x: int, y: int) -> bytes:
at = (raster.top + y) * raster.stride + (raster.left + x) * 2
return bytes(raster.pixels[at : at + 2])
assert pixel(1, 2) == rgb565((255, 255, 255)) # ink: the A's top bar
assert pixel(0, 2) == rgb565((0, 0, 255)) # paper beside it
term.feed("\x1b[14;1Hz")
assert raster.update() == [(raster.top + 13 * 12, raster.top + 14 * 12)]
# --- a whole run ----------------------------------------------------------------------
def test_meshterm_runs_inside_the_host(monkeypatch) -> None:
"""The ordinary CLI, hosted: the menu draws at 53x14 with the Cardputer's lane, ^Q^Q leaves."""
from meshterm.ui import menu
# Quitting arms a watchdog that hard-exits the process if teardown wedges — right for
# the host, which ends with the TUI, but fatal to the test run it is hosted in here.
monkeypatch.setattr(menu, "_arm_exit_watchdog", lambda *args, **kwargs: None)
seen: dict[str, str] = {}
class Headless:
def __init__(self, terminal, lock, type_text):
self._terminal, self._lock = terminal, lock
def drive():
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
with self._lock:
text = terminal.text()
if "What would you like to do?" in text:
seen["menu"] = text
break
time.sleep(0.1)
type_text("\x11")
time.sleep(0.3)
type_text("\x11")
threading.Thread(target=drive, daemon=True).start()
def frame_ready(self):
pass
def close(self):
pass
assert run(["--mock"], Headless) == 0
menu = seen["menu"].splitlines()
assert len(menu) == 14 and all(len(line) == 53 for line in menu)
assert "Quit?" in menu[-1] # the main menu's lane, drawn by the Cardputer deck
def test_the_cardputer_inventory_is_what_the_host_draws() -> None:
"""``fontset.CARDPUTER_CODEPOINTS`` holds every mark, and the installed font exactly."""
from meshterm.host.font import ALIASES, font_dir, load_bdf
from meshterm.ui.fontset import CARDPUTER_CODEPOINTS
assert set(MARKS) | set(ALIASES) <= CARDPUTER_CODEPOINTS
installed = font_dir() / "ter-u12n.bdf"
if not installed.is_file():
return # no Terminus on this machine (CI): the marks are the part that is ours
drawn = {cp for cp in load_bdf(installed) if cp >= 0x20} | set(MARKS) | set(ALIASES)
assert drawn == CARDPUTER_CODEPOINTS