Files
MeshTerm/tests/conftest.py
T
Jean-Pierre MartineauandClaude Haiku 5.5 daa26be5df ^K in a conversation locks the author of the selected message
In a channel, a DM, or a room, ^K toggles the lock of the contact that wrote
the selected message, so the archive sweep never takes it. The hint says
^K lock or ^K unlock, but only where it fits without dropping another atom,
and the status line confirms what the key did.

On the handhelds, Lock (or Unlock) rides Shift+F2 in a channel and a DM. A
room keeps Log in there, so the Author page that a conversation opens now
carries the lock row, and no other management verb.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 23:18:57 -04:00

227 lines
9.5 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Shared fixtures and helpers: plain-text screen reading, fixed path rendering, project files.
:func:`plain` is the only way that a test reads a rendered screen. Each screen test must
first remove the ANSI colour escapes and join the rendered lines. Only then can it make
assertions about the content. If each file did this again, the copies would differ over
time.
The powerline verdict depends on the environment. The suite can run inside VS Code or
Windows Terminal (both can draw chips) or in a bare CI shell (which cannot). The screen
assertions must not change with the terminal of the developer. ``MESHTERM_POWERLINE=0``
is the supported way to fix the verdict. The suite clears the cached verdict around each
test, so that the order of the tests has no effect. The tests that are specific to
powerline pass explicit modes, or they patch the widget's own switch with
``monkeypatch``.
``NO_COLOR`` depends on the environment in the same way. Rich obeys it: it removes colour
and keeps attributes. This silently makes each colour assertion in the suite useless (a
shell that an agent harness runs sets it). The autouse fixture below deletes it. Thus the
tests always see the colours that the app really sends.
:func:`not_ignored` keeps a test that walks a folder to the files of the project. Git
ignores the other files in the checkout, and these files are not ours to examine.
"""
from __future__ import annotations
import os
import re
import subprocess
from collections.abc import Callable, Iterable, Iterator
from pathlib import Path
import pytest
from meshterm.platforms import REGULAR, set_platform
from meshterm.ui.termfont import powerline_support
#: ANSI SGR escapes: the colour runs that a rendered line has between its characters.
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
def plain(rendered: str | Iterable[str]) -> str:
"""The plain-text form of rendered screen output, for content assertions.
The function takes the result of a render: a list of ANSI lines (which it joins with
newlines), or one string that is already joined. It removes the colour escapes. Thus
the assertions read the screen in the same way as a viewer.
"""
if not isinstance(rendered, str):
rendered = "\n".join(rendered)
return _ANSI.sub("", rendered)
def recording_locator(markers: list) -> object:
"""A real :class:`~meshterm.ui.map_screen.Locator` whose map is a list, ``shown``.
It finds markers with the rule of the app. Instead of a map, :meth:`show` appends the
marker that the map would open on to ``shown``.
"""
from meshterm.ui.map_screen import Locator
class _Recording(Locator):
async def show(self, marker) -> None: # noqa: ANN001 - a MapMarker
self.shown.append(marker)
locator = _Recording(None, markers) # type: ignore[arg-type]
locator.shown = [] # type: ignore[attr-defined]
return locator
def recording_pages(contacts: list, *, store: object = None, self_key: str = "") -> object:
"""A real :class:`~meshterm.ui.node_detail_screen.ContactPages` whose pages are a list.
It finds contacts with the rule of the app. Instead of a node page, :meth:`show` appends
the contact that the page would open on to ``shown`` (``None`` is our node). With a
contact ``store`` and the key of the device, ^K can lock the contacts too.
"""
from types import SimpleNamespace
from meshterm.ui.node_detail_screen import ContactPages
class _Recording(ContactPages):
async def show(self, contact) -> None: # noqa: ANN001 - a Contact, or None for us
self.shown.append(contact)
ctx = None if store is None else SimpleNamespace(contact_store=store)
pages = _Recording(ctx, contacts, self_key=self_key) # type: ignore[arg-type]
pages.shown = [] # type: ignore[attr-defined]
return pages
#: The root of the checkout.
_ROOT = Path(__file__).resolve().parent.parent
def not_ignored(paths: Iterable[Path]) -> list[Path]:
"""The paths in ``paths`` that git does not ignore, in their order.
A test that walks a folder of the checkout must examine only the files of the project.
A folder can also hold files that git ignores. An example is the MeshCore checkout that
``build-firmware.sh`` makes in ``scripts/``, with its third-party code and its build
tree. Git never ignores a tracked file. When git is not available (for example, in an
unpacked source archive), the function returns all the paths, and the test examines
each file, as it did before.
"""
paths = list(paths)
relative = [path.resolve().relative_to(_ROOT).as_posix() for path in paths]
try:
result = subprocess.run(
["git", "check-ignore", "--stdin", "-z"],
cwd=_ROOT,
input="\0".join(relative),
capture_output=True,
encoding="utf-8",
check=False,
)
except OSError:
return paths
if result.returncode not in (0, 1): # 1 is "nothing ignored", 128 is "not a repository"
return paths
ignored = set(result.stdout.split("\0"))
return [path for path, name in zip(paths, relative, strict=True) if name not in ignored]
@pytest.fixture(autouse=True)
def _plain_path_rendering(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setenv("MESHTERM_POWERLINE", "0")
# An inherited NO_COLOR makes Rich remove colour (and keep attributes). Then each
# colour assertion silently reads bare text. Refer to the module docstring.
monkeypatch.delenv("NO_COLOR", raising=False)
powerline_support.cache_clear()
yield
powerline_support.cache_clear()
@pytest.fixture
def powerline(monkeypatch: pytest.MonkeyPatch) -> Callable[[bool], None]:
"""Fix the powerline verdict for a test that is about chips.
The suite runs with ``MESHTERM_POWERLINE=0``. Thus the screen assertions do not change
with the terminal of the developer (refer to the module docstring). A test of the chip
language needs the other answer. The test asks for it through the supported override,
and does not go into the widget. ``monkeypatch`` restores the environment, and the
autouse fixture above clears the cached verdict before and after. Thus no state stays.
"""
def _set(on: bool) -> None:
monkeypatch.setenv("MESHTERM_POWERLINE", "1" if on else "0")
powerline_support.cache_clear()
return _set
@pytest.fixture(autouse=True)
def _reset_transmit_gate() -> Iterator[None]:
"""Each test starts with nothing transmitted.
The transmit clock is for the whole process (refer to :mod:`meshterm.core.transmit_gate`).
This is correct for a session and wrong for a suite. If a test sent anything, it would
leave a cooldown that is still active for the next test. Then the flow of the next test
would stop on a countdown that nobody wrote in the script.
"""
from meshterm.core.transmit_gate import current as transmit_clock
transmit_clock().reset()
yield
transmit_clock().reset()
@pytest.fixture(autouse=True)
def _isolate_config_dir(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]:
"""No test can reach the developer's own ``~/.meshterm``.
``$MESHTERM_HOME`` moves the whole directory: the history, the caches of contacts and
channels, the outbox, the stored admin passwords, the device profiles, the preferences,
and the log. ``--db`` moves only the first of these. Thus a CLI test that used only
``--db`` was not isolated. A synthetic contact that a probe wrote stayed in the real
cache. This made ``test_cli_contract`` fail on one machine and pass on each other
machine. Also, each run added lines to the real ``meshterm.log`` of the developer, and
read the real ``log_level``.
The fixture is autouse and unconditional, because the risk is not only in the CLI.
Any code that uses ``Settings.load()``, a preference, a store, or the logger has it.
A test that needs a directory with data sets the variable itself.
``monkeypatch.setenv`` in a test still wins over this fixture.
"""
home = tmp_path_factory.mktemp("meshterm-home")
previous = os.environ.get("MESHTERM_HOME")
os.environ["MESHTERM_HOME"] = str(home)
yield
if previous is None:
os.environ.pop("MESHTERM_HOME", None)
else:
os.environ["MESHTERM_HOME"] = previous
@pytest.fixture(autouse=True)
def _empty_registries(monkeypatch: pytest.MonkeyPatch) -> None:
"""Each test starts with no registered secret and no registered host.
The two registries are process-wide on purpose: a secret that MeshTerm read once must
stay hidden for the rest of the run. In a suite, that means that a password one test
used ("hello", the default room password) hides the same word in each later test that
looks for it. ``monkeypatch`` puts the shared objects back after the test.
"""
from meshterm.core import anonymize, secrets
monkeypatch.setattr(secrets, "_known", set())
monkeypatch.setattr(secrets, "_known_pattern", None)
monkeypatch.setattr(anonymize, "_hosts", {})
monkeypatch.setattr(anonymize, "_host_pattern", None)
@pytest.fixture(autouse=True)
def _reset_platform() -> Iterator[None]:
"""Each test starts and ends on :data:`~meshterm.platforms.REGULAR`.
A test can call ``set_platform(PICOCALC_LYRA)`` (directly, or through the gallery
harness). That choice cannot stay for the next test. Regular is the baseline of the
suite. It is also the default for a process that does not get ``--platform`` or
``MESHTERM_PLATFORM`` (refer to :mod:`meshterm.platforms`).
"""
set_platform(REGULAR)
yield
set_platform(REGULAR)