mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
first_time_install.sh applies patches/rpi-rgb-led-matrix/0001-bulk-setimage.patch just before building the Python binding and reverts it afterwards (and from the EXIT trap), so the submodule stays at its pinned commit. The patch copies each image row with one bulk FrameCanvas::SetPixels call and writes the bit planes branch-free: on a 512x64 Pi 4 the frame copy went from 6.57 ms to 2.21 ms. A patch that no longer applies is reported and skipped. Existing installs get it on a rebuild (RPI_RGB_FORCE_REBUILD=1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
335 lines
15 KiB
Python
335 lines
15 KiB
Python
"""
|
|
Tests for the rpi-rgb-led-matrix checkout helpers in first_time_install.sh, and
|
|
for the contract between scripts/install/one-shot-install.sh and that script.
|
|
|
|
Background: the submodule was pinned to a commit whose RP1 backend emits the
|
|
ARMv7-only `dmb ishst` instruction, so the build failed on every ARMv6 board
|
|
(Pi Zero / Zero W / Pi 1). Bumping the pin fixes fresh installs, but `git pull`
|
|
never moves an existing submodule checkout, and earlier installers ran the
|
|
submodule git commands as root, leaving .git/modules/<submodule> root-owned and
|
|
the user locked out of their own checkout.
|
|
|
|
These cover everything that runs without root. The ownership-repair path
|
|
(root, a second user, chown) mutates the system, so -- as with the swap helpers
|
|
in test_install_lowmem.py -- it is exercised manually instead.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
INSTALLER = ROOT / "first_time_install.sh"
|
|
ONE_SHOT = ROOT / "scripts" / "install" / "one-shot-install.sh"
|
|
|
|
BEGIN = "# --- rpi-rgb-led-matrix checkout helpers"
|
|
END = "# --- end rpi-rgb-led-matrix checkout helpers"
|
|
SUB = "rpi-rgb-led-matrix-master"
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not sys.platform.startswith("linux"), reason="exercises the installer's bash under Linux"
|
|
)
|
|
|
|
|
|
def helper_block() -> str:
|
|
text = INSTALLER.read_text(encoding="utf-8")
|
|
assert text.count(BEGIN) == 1 and text.count(END) == 1, "helper block markers missing or duplicated"
|
|
return text[text.index(BEGIN): text.index(END)]
|
|
|
|
|
|
def git(*args: str, cwd: Path, env: dict) -> str:
|
|
result = subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True)
|
|
assert result.returncode == 0, f"git {' '.join(args)} failed: {result.stderr}"
|
|
return result.stdout.strip()
|
|
|
|
|
|
@pytest.fixture
|
|
def git_env(tmp_path):
|
|
config = tmp_path / "gitconfig"
|
|
config.write_text(
|
|
"[user]\n\tname = t\n\temail = t@t\n"
|
|
"[protocol \"file\"]\n\tallow = always\n"
|
|
"[init]\n\tdefaultBranch = master\n"
|
|
"[advice]\n\tdetachedHead = false\n",
|
|
encoding="utf-8",
|
|
)
|
|
return {
|
|
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
"HOME": str(tmp_path),
|
|
"GIT_CONFIG_GLOBAL": str(config),
|
|
"GIT_CONFIG_NOSYSTEM": "1",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def upstream(tmp_path, git_env):
|
|
"""A stand-in for hzeller/rpi-rgb-led-matrix with commits A -> B -> C."""
|
|
up = tmp_path / "upstream"
|
|
up.mkdir()
|
|
git("init", "-q", ".", cwd=up, env=git_env)
|
|
commits = {}
|
|
for name, filename in (("A", "Makefile"), ("B", "b"), ("C", "c")):
|
|
(up / filename).write_text(name, encoding="utf-8")
|
|
git("add", ".", cwd=up, env=git_env)
|
|
git("commit", "-qm", name, cwd=up, env=git_env)
|
|
commits[name] = git("rev-parse", "HEAD", cwd=up, env=git_env)
|
|
return up, commits
|
|
|
|
|
|
@pytest.fixture
|
|
def make_project(tmp_path, git_env, upstream):
|
|
"""make_project(checkout) -> project pinned at B, submodule checked out at `checkout`."""
|
|
up, commits = upstream
|
|
|
|
def _make(checkout: str) -> Path:
|
|
project = tmp_path / f"project-{checkout}"
|
|
project.mkdir()
|
|
git("init", "-q", ".", cwd=project, env=git_env)
|
|
git("submodule", "add", "-q", str(up), SUB, cwd=project, env=git_env)
|
|
git("checkout", "-q", commits["B"], cwd=project / SUB, env=git_env)
|
|
git("add", SUB, cwd=project, env=git_env)
|
|
git("commit", "-qm", "pin", cwd=project, env=git_env)
|
|
git("checkout", "-q", commits[checkout], cwd=project / SUB, env=git_env)
|
|
return project
|
|
|
|
return _make
|
|
|
|
|
|
def run_helpers(snippet: str, project: Path, env: dict) -> subprocess.CompletedProcess:
|
|
"""Run a snippet against the helpers under the installer's own strict mode."""
|
|
script = (
|
|
"set -Eeuo pipefail\n"
|
|
"trap 'echo ERR_TRAP_FIRED >&2; exit 99' ERR\n"
|
|
f"{helper_block()}\n"
|
|
f'PROJECT_ROOT_DIR="{project}"\n'
|
|
f"{snippet}\n"
|
|
)
|
|
return subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env)
|
|
|
|
|
|
def head_of(project: Path, env: dict) -> str:
|
|
return git("rev-parse", "HEAD", cwd=project / SUB, env=env)
|
|
|
|
|
|
class TestInstallerStructure:
|
|
def test_installer_is_syntactically_valid(self):
|
|
result = subprocess.run(["bash", "-n", str(INSTALLER)], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
def test_helper_block_defines_the_helpers(self):
|
|
block = helper_block()
|
|
for name in ("_rgb_repo_owner", "_git_as_repo_owner", "_reclaim_rgb_checkout", "_sync_rgb_submodule"):
|
|
assert re.search(rf"^{name}\(\) \{{", block, re.M), f"{name} is not defined in the helper block"
|
|
|
|
def test_every_called_underscore_function_is_defined_before_use(self):
|
|
# A renamed helper with a stale call site aborts the installer with
|
|
# "command not found" under set -e -- the users this protects are the
|
|
# ones least able to recover from that.
|
|
lines = INSTALLER.read_text(encoding="utf-8").splitlines()
|
|
defined_at = {}
|
|
for number, line in enumerate(lines, 1):
|
|
match = re.match(r"\s*(_[A-Za-z0-9_]+)\(\) \{", line)
|
|
if match:
|
|
defined_at.setdefault(match.group(1), number)
|
|
# A name in command position; `_name=value` is an assignment and
|
|
# `_name() {` a definition, so neither counts as a call.
|
|
call = re.compile(r"(?:^\s*|\bretry\s+|\bif\s+!?\s*|&&\s*|\|\|\s*|\$\()(_[A-Za-z0-9_]+)\b(?!\(\)|=|\[)")
|
|
for number, line in enumerate(lines, 1):
|
|
if line.lstrip().startswith("#"):
|
|
continue
|
|
for name in call.findall(line):
|
|
if name.startswith("_") and not re.match(r"_[A-Z0-9_]+$", name): # skip $_VARS
|
|
assert name in defined_at, f"line {number} calls undefined function {name}"
|
|
# Functions defined inside another function body run later;
|
|
# top-level calls must come after the definition.
|
|
if not line.startswith((" ", "\t")):
|
|
assert defined_at[name] < number, f"line {number} calls {name} before it is defined"
|
|
|
|
|
|
class TestSyncSubmodule:
|
|
def test_checkout_behind_the_pin_is_moved_forward(self, make_project, upstream, git_env):
|
|
project = make_project("A")
|
|
result = run_helpers("_reclaim_rgb_checkout; _sync_rgb_submodule", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert head_of(project, git_env) == upstream[1]["B"]
|
|
|
|
def test_checkout_newer_than_the_pin_is_left_alone(self, make_project, upstream, git_env):
|
|
# What `git submodule update --remote` -- advice the installer itself
|
|
# prints for Pi 5 -- leaves behind. Must never be rolled back.
|
|
project = make_project("C")
|
|
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert head_of(project, git_env) == upstream[1]["C"]
|
|
assert "leaving it as is" in result.stdout
|
|
|
|
def test_checkout_at_the_pin_is_a_silent_no_op(self, make_project, upstream, git_env):
|
|
project = make_project("B")
|
|
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert result.stdout == ""
|
|
assert head_of(project, git_env) == upstream[1]["B"]
|
|
|
|
def test_unreachable_pin_warns_and_keeps_the_checkout(self, make_project, upstream, git_env):
|
|
project = make_project("A")
|
|
ghost = "0123456789abcdef0123456789abcdef01234567"
|
|
git("update-index", "--cacheinfo", f"160000,{ghost},{SUB}", cwd=project, env=git_env)
|
|
git("commit", "-qm", "ghost pin", cwd=project, env=git_env)
|
|
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert "ERR_TRAP_FIRED" not in result.stderr
|
|
assert "Could not update" in result.stdout
|
|
assert head_of(project, git_env) == upstream[1]["A"]
|
|
|
|
def test_local_files_blocking_the_checkout_are_not_clobbered(self, make_project, git_env):
|
|
project = make_project("A")
|
|
(project / SUB / "b").write_text("mine", encoding="utf-8") # B adds a tracked `b`
|
|
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert (project / SUB / "b").read_text(encoding="utf-8") == "mine"
|
|
|
|
def test_directory_without_git_metadata_is_a_no_op(self, make_project, git_env):
|
|
project = make_project("A")
|
|
subprocess.run(["rm", "-rf", str(project / SUB / ".git"), str(project / ".git" / "modules")], check=True)
|
|
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert result.stdout == ""
|
|
|
|
def test_project_without_the_submodule_configured_is_a_no_op(self, tmp_path, git_env):
|
|
project = tmp_path / "plain"
|
|
(project / SUB).mkdir(parents=True)
|
|
result = run_helpers("_sync_rgb_submodule", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert result.stdout == ""
|
|
|
|
|
|
class TestOwnershipHelpersWithoutRoot:
|
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="covers the non-root path")
|
|
def test_reclaim_is_a_no_op_and_git_runs_directly(self, make_project, git_env):
|
|
project = make_project("B")
|
|
result = run_helpers("_reclaim_rgb_checkout; _git_as_repo_owner --version", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert result.stdout.startswith("git version")
|
|
|
|
|
|
class TestOneShotContract:
|
|
"""one-shot-install.sh clones the repo and hands off to first_time_install.sh."""
|
|
|
|
def test_one_shot_invokes_the_installer_at_the_repo_root(self):
|
|
one_shot = ONE_SHOT.read_text(encoding="utf-8")
|
|
assert "first_time_install.sh -y" in one_shot
|
|
assert INSTALLER.is_file()
|
|
|
|
def test_installer_accepts_everything_the_one_shot_passes(self):
|
|
one_shot = ONE_SHOT.read_text(encoding="utf-8")
|
|
installer = INSTALLER.read_text(encoding="utf-8")
|
|
assert re.search(r"^\s*-y\|--yes\)", installer, re.M), "installer no longer parses -y"
|
|
for var in sorted(set(re.findall(r"\b(LEDMATRIX_[A-Z_]+)=", one_shot))):
|
|
assert var in installer, f"one-shot passes {var}, which first_time_install.sh never reads"
|
|
|
|
def test_installer_initialises_the_submodule_itself(self):
|
|
# The one-shot clones without --recurse-submodules, so the installer is
|
|
# the only thing that ever fetches the matrix library.
|
|
assert "--recurse-submodules" not in ONE_SHOT.read_text(encoding="utf-8")
|
|
installer = INSTALLER.read_text(encoding="utf-8")
|
|
assert re.search(rf"submodule update --init --recursive {SUB}", installer)
|
|
|
|
|
|
PATCH_DIR = ROOT / "patches" / "rpi-rgb-led-matrix"
|
|
|
|
|
|
def _write_patch(project: Path, name: str, old: str, new: str) -> Path:
|
|
"""A git patch rewriting the fake library's Makefile from `old` to `new`."""
|
|
patch = project / "patches" / "rpi-rgb-led-matrix" / name
|
|
patch.parent.mkdir(parents=True, exist_ok=True)
|
|
patch.write_text(
|
|
"A note before the diff, as the shipped patches carry.\n\n"
|
|
"diff --git a/Makefile b/Makefile\n"
|
|
"--- a/Makefile\n"
|
|
"+++ b/Makefile\n"
|
|
"@@ -1 +1 @@\n"
|
|
f"-{old}\n"
|
|
"\\ No newline at end of file\n"
|
|
f"+{new}\n"
|
|
"\\ No newline at end of file\n",
|
|
encoding="utf-8",
|
|
)
|
|
return patch
|
|
|
|
|
|
def _makefile(project: Path) -> str:
|
|
return (project / SUB / "Makefile").read_text(encoding="utf-8")
|
|
|
|
|
|
def _status(project: Path, env: dict) -> str:
|
|
return git("status", "--porcelain", cwd=project / SUB, env=env)
|
|
|
|
|
|
class TestLibraryPatches:
|
|
"""patches/rpi-rgb-led-matrix/*.patch go in for the build and come back out."""
|
|
|
|
def test_applied_for_the_build_and_reverted_after(self, make_project, git_env):
|
|
project = make_project("B")
|
|
_write_patch(project, "0001-x.patch", "A", "patched")
|
|
result = run_helpers(
|
|
f'_apply_rgb_patches; cat "{project / SUB / "Makefile"}"; echo; _revert_rgb_patches',
|
|
project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert "Applied library patch 0001-x.patch" in result.stdout
|
|
assert "patched" in result.stdout # what the build would see
|
|
assert _makefile(project) == "A" # and the checkout afterwards
|
|
assert _status(project, git_env) == ""
|
|
|
|
def test_already_applied_is_left_alone(self, make_project, git_env):
|
|
project = make_project("B")
|
|
_write_patch(project, "0001-x.patch", "A", "patched")
|
|
(project / SUB / "Makefile").write_text("patched", encoding="utf-8")
|
|
result = run_helpers("_apply_rgb_patches; _revert_rgb_patches", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert "already applied" in result.stdout
|
|
assert _makefile(project) == "patched" # not reverted: it was not ours
|
|
|
|
def test_a_patch_that_does_not_apply_is_skipped_not_fatal(self, make_project, git_env):
|
|
project = make_project("B")
|
|
_write_patch(project, "0001-x.patch", "something else", "patched")
|
|
result = run_helpers("_apply_rgb_patches; _revert_rgb_patches; echo REACHED", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert "ERR_TRAP_FIRED" not in result.stderr
|
|
assert "does not apply" in result.stdout and "REACHED" in result.stdout
|
|
assert _makefile(project) == "A"
|
|
|
|
def test_no_patch_directory_is_a_no_op(self, make_project, git_env):
|
|
project = make_project("B")
|
|
result = run_helpers("_apply_rgb_patches; _revert_rgb_patches; echo REACHED", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert result.stdout.strip() == "REACHED"
|
|
|
|
def test_revert_twice_is_harmless(self, make_project, git_env):
|
|
# The EXIT trap runs _revert_rgb_patches again after Step 6 already has.
|
|
project = make_project("B")
|
|
_write_patch(project, "0001-x.patch", "A", "patched")
|
|
result = run_helpers("_apply_rgb_patches; _revert_rgb_patches; _revert_rgb_patches", project, git_env)
|
|
assert result.returncode == 0, result.stderr
|
|
assert _makefile(project) == "A" and "Could not revert" not in result.stdout
|
|
|
|
def test_build_is_wrapped_and_the_exit_trap_reverts(self):
|
|
installer = INSTALLER.read_text(encoding="utf-8")
|
|
apply_at = installer.index("_apply_rgb_patches\n if run_rgbmatrix_build")
|
|
assert installer.index("_revert_rgb_patches\n cat \"$BUILD_OUTPUT\"") > apply_at
|
|
assert re.search(r"^trap '[^']*_revert_rgb_patches[^']*' EXIT", installer, re.M)
|
|
|
|
def test_shipped_patches_are_git_patches_against_the_library(self, tmp_path, git_env):
|
|
patches = sorted(PATCH_DIR.glob("*.patch"))
|
|
assert patches, "no shipped library patches"
|
|
repo = tmp_path / "r"
|
|
repo.mkdir()
|
|
git("init", "-q", ".", cwd=repo, env=git_env)
|
|
for patch in patches:
|
|
stat = git("apply", "--numstat", str(patch), cwd=repo, env=git_env)
|
|
files = {line.split("\t")[2] for line in stat.splitlines()}
|
|
assert files <= {"lib/framebuffer.cc", "bindings/python/rgbmatrix/core.pyx",
|
|
"bindings/python/rgbmatrix/cppinc.pxd"}, files
|