testsAndMisc/linux_configuration/tests/test_g29_shifter_capture_cli.py
Krzysztof kuhy Rudnicki 7e2d78c7db Add the G29 shifter HID-BPF fix (1st/3rd engage reliably)
Live on this host since 2026-09-11 but never committed. The wheel firmware
decodes the shifter's hall sensor itself and this unit's top row rests at
Y 180-193, below the ~195 engage threshold, so 1st/3rd often did not
register. g29_shifter.bpf.c re-decodes those two gates from the raw X/Y
bytes with hysteresis (engage >= 160, release <= 140) and ORs the bit in
before hid-logitech parses the report; it never clears a bit, so the
firmware's working gates cannot regress. fix_g29_shifter.sh builds,
attaches and installs it persistently (udev-hid-bpf rule + a systemd
safety-net unit pulled in on every re-enumeration); g29_shifter_capture.py
records and grades raw captures per gate.

Two files were over the 250-line cap: the test file is split at the
record/CLI boundary with the report builders moved into a conftest fixture
(the naming hook allows only test_*.py in tests/), and the grader's
docstrings are tightened. No behaviour change to the installed fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SS8M5wk88nJTXxDWz61KKk
2026-09-14 22:12:31 +02:00

108 lines
3.7 KiB
Python

"""Tests for g29_shifter_capture.py: the ``record`` loop and the CLI entry."""
from __future__ import annotations
import io
from typing import TYPE_CHECKING
import g29_shifter_capture as gsc
import pytest
if TYPE_CHECKING:
from conftest import G29Reports
if TYPE_CHECKING:
from pathlib import Path
TOP_Y = 200
X_LEFT = 60
class TestRecord:
"""``record`` streams xxd lines and survives the device re-binding."""
@staticmethod
def _clock(monkeypatch: pytest.MonkeyPatch, zeros: int) -> None:
"""Make ``time.monotonic`` return 0 for ``zeros`` calls, then 99."""
calls = {"n": 0}
def monotonic() -> float:
calls["n"] += 1
return 0.0 if calls["n"] <= zeros else 99.0
monkeypatch.setattr(gsc.time, "monotonic", monotonic)
monkeypatch.setattr(gsc.time, "sleep", lambda _: None)
def test_writes_every_report_it_reads(
self, g29: type[G29Reports], tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
device = tmp_path / "hidraw"
device.write_bytes(bytes(g29.raw_report(gears=0x01, x=X_LEFT, y=TOP_Y)) * 3)
out = tmp_path / "capture.txt"
self._clock(monkeypatch, zeros=6) # deadline, outer, 3 reads, short read
assert gsc.record(1.0, str(device), out) == 0
reports = list(gsc.parse(out.read_text().splitlines(keepends=True)))
assert len(reports) == 3
assert reports[0].gate == "1st"
def test_stops_mid_device_when_the_deadline_passes(
self, g29: type[G29Reports], tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
device = tmp_path / "hidraw"
device.write_bytes(bytes(g29.raw_report(gears=0x01, x=X_LEFT, y=TOP_Y)) * 3)
out = tmp_path / "capture.txt"
self._clock(monkeypatch, zeros=4) # deadline, outer, two reads, then expired
assert gsc.record(1.0, str(device), out) == 0
# Stopped on the clock with a report still unread, not at end of device.
assert len(list(gsc.parse(out.read_text().splitlines(keepends=True)))) == 2
def test_reopens_after_an_os_error(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
out = tmp_path / "capture.txt"
self._clock(monkeypatch, zeros=2) # deadline, one failing attempt
assert gsc.record(1.0, str(tmp_path / "missing"), out) == 0
assert "reopening" in capsys.readouterr().err
assert out.read_text() == ""
class TestMain:
"""``main`` wires the CLI to the grader and optionally to the recorder."""
def test_grades_a_file(
self, g29: type[G29Reports], tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
capture = tmp_path / "capture.txt"
capture.write_text(
g29.xxd_line(gears=0x01, x=X_LEFT, y=TOP_Y) * (gsc.MIN_DWELL + 1)
)
assert gsc.main([str(capture)]) == 0
assert "0 missed" in capsys.readouterr().out
def test_reads_stdin(
self, g29: type[G29Reports], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
gsc.sys, "stdin", io.StringIO(g29.xxd_line(x=X_LEFT, y=TOP_Y))
)
assert gsc.main(["-"]) == 0
def test_records_first_when_asked(
self, g29: type[G29Reports], tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
device = tmp_path / "hidraw"
device.write_bytes(bytes(g29.raw_report(gears=0x01, x=X_LEFT, y=TOP_Y)))
capture = tmp_path / "capture.txt"
TestRecord._clock(monkeypatch, zeros=4) # deadline, outer, one read, short read
assert gsc.main([str(capture), "--record", "1", "--device", str(device)]) == 0
assert capture.read_text().count("\n") == 1