testsAndMisc/python_pkg/brother_printer/tests/test_cups_service_part2.py
Krzysztof kuhy Rudnicki a12e696494
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
fix(brother-printer): report the truth, not invented status codes
./run.sh failed outright with ModuleNotFoundError, and once running it
reported a sleeping printer as a critical paper jam. Both are fixed, plus
the defects found while verifying them against the real HL-1110.

Entry point: run.sh ran the script by path, so the absolute
python_pkg.* imports could not resolve. Resolve the repo root and pass
PYTHONPATH as a sudo argument, since sudo's env_reset strips an exported
one. __main__.py had the wrong import prefix and ran main() on import.

Status codes: the table claimed provenance from a "Brother PJL Technical
Reference" that does not appear to exist, and 23 of its 25 entries were
wrong. Rebuilt from the HP PJL Technical Reference Manual, Appendix D,
and verified against six states induced on real hardware (10001 READY,
10003 WARMING UP, 10023 PRINTING, 40000 SLEEP, 40021 TOP COVER OPEN,
41213 NO PAPER) - all six matched the reference, none matched the table.
40000 is SLEEP MODE, which the reference explicitly flags "not an error";
the real jam code 40022 was absent entirely. The inverse also bit: 10006
is TONER LOW, previously shown as a benign "Processing". Codes in no
reference are dropped and now report "Unknown status - check printer
display" rather than a confident wrong answer, and states we infer
ourselves no longer borrow PJL numbers to describe themselves.

Page counts: estimates came from the CUPS page log, which only sees jobs
CUPS logged and counts pages the printer never printed (it logged
total=50 for a job that printed 3). Take them from @PJL INFO PAGECOUNT
instead, migrating stored baselines onto the printer's scale once so the
reported percentages do not jump.

Reading status must not cost anything: a blocking open() hung forever
whenever the printer was unwell - precisely when a status report is
wanted - and the port-status probe stopped CUPS and reset the USB device,
destroying the in-flight job and then misreporting the damage as a
printer error. The fd is now non-blocking with deadline-bounded writes,
and the probe reads the same byte via the free usblp ioctl, skips
entirely while a job prints, and re-attaches the kernel driver.

Dropped pages: the HL-1110 silently discards pages whose 600dpi raster
exceeds its ~1MB memory, stays READY, and CUPS still calls the job
successful - so nothing anywhere reports the failure. Compare CUPS's
claim against the printer's counter between jobs and name the workaround
(lp -o Resolution=300dpi).

402 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RG3eEn8rwZbcXaJrKJxdv8
2026-07-17 14:49:23 +02:00

262 lines
10 KiB
Python

"""Tests for brother_printer.cups_service module - part 2."""
from __future__ import annotations
import subprocess
from unittest.mock import MagicMock, patch
from python_pkg.brother_printer.constants import (
_CUPS_REASONS_TO_STATUS,
DERIVED_CUPS_ERROR,
get_status_info,
)
from python_pkg.brother_printer.cups_service import (
_cups_reasons_to_error,
_get_cups_economode,
_map_cups_to_status_code,
_port_status_to_status_code,
find_cups_printer_name,
)
from python_pkg.brother_printer.data_classes import (
USBPortStatus,
)
MOD = "python_pkg.brother_printer.cups_service"
# ── _get_cups_economode ──────────────────────────────────────────────
class TestGetCupsEconomode:
"""Tests for _get_cups_economode."""
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_lpoptions(self, m: MagicMock) -> None:
assert _get_cups_economode("Brother") == ""
@patch("python_pkg.brother_printer._query.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_economode_on(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="BREconomode/Toner Save Mode: *True False\n"
)
assert _get_cups_economode("Brother") == "ON"
@patch("python_pkg.brother_printer._query.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_economode_off(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="BREconomode/Toner Save Mode: True *False\n"
)
assert _get_cups_economode("Brother") == "OFF"
@patch("python_pkg.brother_printer._query.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_no_economode_line(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="Resolution/Output Resolution: 600dpi *1200dpi\n"
)
assert _get_cups_economode("Brother") == ""
@patch("python_pkg.brother_printer._query.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_economode_no_star_match(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="BREconomode/Toner Save Mode: True False\n"
)
assert _get_cups_economode("Brother") == ""
@patch("python_pkg.brother_printer._query.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_timeout(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.TimeoutExpired("lpoptions", 5)
assert _get_cups_economode("Brother") == ""
@patch("python_pkg.brother_printer._query.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_oserror(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = OSError("fail")
assert _get_cups_economode("Brother") == ""
# ── _map_cups_to_status_code ─────────────────────────────────────────
class TestMapCupsToStatusCode:
"""Tests for _map_cups_to_status_code.
Every code here must exist in BROTHER_STATUS_CODES; mapping CUPS onto a
code the table does not know silently degrades to "unknown status".
"""
def test_reason_match(self) -> None:
assert _map_cups_to_status_code("idle", "toner-low-report") == "40038"
def test_state_match(self) -> None:
assert _map_cups_to_status_code("idle", "none") == "10001"
def test_processing_state(self) -> None:
assert _map_cups_to_status_code("processing", "none") == "10023"
def test_stopped_state(self) -> None:
assert _map_cups_to_status_code("stopped", "none") == "40079"
def test_unknown_state(self) -> None:
assert _map_cups_to_status_code("mystery", "none") == "10001"
def test_state_with_parenthetical(self) -> None:
assert _map_cups_to_status_code("idle (on fire)", "none") == "10001"
def test_every_mapped_code_is_resolvable(self) -> None:
"""Guard the class of bug where a mapping points at a deleted code."""
for state in ("idle", "processing", "stopped"):
code = _map_cups_to_status_code(state, "none")
_, text, _ = get_status_info(code)
assert "Unknown" not in text
for reason in _CUPS_REASONS_TO_STATUS:
code = _map_cups_to_status_code("idle", reason)
_, text, _ = get_status_info(code)
assert "Unknown" not in text
# ── _cups_reasons_to_error ───────────────────────────────────────────
class TestCupsReasonsToError:
"""Tests for _cups_reasons_to_error."""
def test_media_jam(self) -> None:
"""A real jam is 40022. It used to map to 40000, which is Sleep."""
code, display = _cups_reasons_to_error("media-jam-report")
assert code == "40022"
assert display == "Paper Jam"
def test_cover_open(self) -> None:
code, _ = _cups_reasons_to_error("cover-open")
assert code == "40021"
def test_door_open(self) -> None:
code, _ = _cups_reasons_to_error("door-open")
assert code == "40021"
def test_toner_empty(self) -> None:
code, _ = _cups_reasons_to_error("toner-empty")
assert code == "40010"
def test_toner_low(self) -> None:
code, _ = _cups_reasons_to_error("toner-low")
assert code == "40038"
def test_unknown_reason_shows_what_cups_said(self) -> None:
"""'Printer Error' alone is useless; quote the reason we were given."""
code, display = _cups_reasons_to_error("something-weird")
assert code == DERIVED_CUPS_ERROR
assert "something-weird" in display
def test_no_reason_at_all(self) -> None:
code, display = _cups_reasons_to_error("none")
assert code == DERIVED_CUPS_ERROR
assert "no reason" in display.lower()
def test_every_error_code_is_resolvable(self) -> None:
for reason in ("media-jam", "cover-open", "toner-empty", "toner-low"):
code, _ = _cups_reasons_to_error(reason)
_, text, _ = get_status_info(code)
assert "Unknown" not in text
# ── _port_status_to_status_code ──────────────────────────────────────
class TestPortStatusToStatusCode:
"""Tests for _port_status_to_status_code.
The port status only exposes paper/error/online bits, so it must not
pretend to know more than that - it previously reported "Cover Open" for
any error on an offline printer, which was a guess.
"""
def test_error_and_paper_empty(self) -> None:
ps = USBPortStatus(error=True, paper_empty=True, online=True)
code, display = _port_status_to_status_code(ps, "none")
assert code == "41000"
assert display == "No Paper"
def test_error_and_not_online_defers_to_cups(self) -> None:
ps = USBPortStatus(error=True, paper_empty=False, online=False)
code, display = _port_status_to_status_code(ps, "cover-open")
assert code == "40021"
assert display == "Cover Open"
def test_error_with_no_clue_does_not_guess(self) -> None:
ps = USBPortStatus(error=True, paper_empty=False, online=False)
code, _ = _port_status_to_status_code(ps, "none")
assert code == DERIVED_CUPS_ERROR
def test_error_only(self) -> None:
ps = USBPortStatus(error=True, paper_empty=False, online=True)
code, _ = _port_status_to_status_code(ps, "media-jam")
assert code == "40022"
def test_paper_empty_no_error(self) -> None:
ps = USBPortStatus(error=False, paper_empty=True, online=True)
code, _ = _port_status_to_status_code(ps, "none")
assert code == "41000"
def test_not_online_no_error(self) -> None:
ps = USBPortStatus(error=False, paper_empty=False, online=False)
code, display = _port_status_to_status_code(ps, "none")
assert code == "10002"
assert display == "Offline / Sleep"
def test_all_ok(self) -> None:
ps = USBPortStatus(error=False, paper_empty=False, online=True)
code, display = _port_status_to_status_code(ps, "none")
assert code == ""
assert display == ""
# ── find_cups_printer_name ───────────────────────────────────────────
class TestFindCupsPrinterName:
"""Tests for find_cups_printer_name."""
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_lpstat(self, m: MagicMock) -> None:
assert find_cups_printer_name() == ""
@patch("python_pkg.brother_printer._query.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_found(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="device for BrotherHL1110: usb://Brother/HL-1110\n"
)
assert find_cups_printer_name() == "BrotherHL1110"
@patch("python_pkg.brother_printer._query.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_no_brother(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="device for HP: ipp://hp.local\n")
assert find_cups_printer_name() == ""
@patch("python_pkg.brother_printer._query.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_brother_no_match(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="brother printer found but format unexpected\n"
)
assert find_cups_printer_name() == ""
@patch("python_pkg.brother_printer._query.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_timeout(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.TimeoutExpired("lpstat", 5)
assert find_cups_printer_name() == ""
@patch("python_pkg.brother_printer._query.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_oserror(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = OSError("fail")
assert find_cups_printer_name() == ""