testsAndMisc/python_pkg/brother_printer/tests/test_display.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

510 lines
18 KiB
Python

"""Tests for brother_printer.display module."""
from __future__ import annotations
from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.brother_printer.data_classes import (
NetworkResult,
PageCountEstimate,
PageDeliveryCheck,
SupplyReadings,
USBPortStatus,
USBResult,
)
from python_pkg.brother_printer.display import (
_classify_percentage_level,
_classify_supply_level,
_collect_supply_items,
_display_consumables_reference,
_display_cups_fallback_note,
_display_page_count_estimate,
_display_page_delivery_warning,
_display_pjl_status,
_display_report_header,
_display_supply_levels,
_display_supply_warnings,
_display_usb_device_info,
_format_status_detail,
_format_supply_bar,
_parse_supply_value,
_process_supply_item,
display_usb_results,
)
MOD = "python_pkg.brother_printer.display"
class TestDisplayReportHeader:
def test_prints_header(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as out:
_display_report_header()
assert "Brother Laser Printer" in out.getvalue()
class TestDisplayPageCountEstimate:
@patch(f"{MOD}.estimate_consumable_life")
def test_no_pages(self, mock_est: MagicMock) -> None:
mock_est.return_value = PageCountEstimate(total_pages=0)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate()
assert out.getvalue() == ""
@patch(f"{MOD}.estimate_consumable_life")
def test_printer_count_passed_through(self, mock_est: MagicMock) -> None:
mock_est.return_value = PageCountEstimate(total_pages=2017)
with patch("sys.stdout", new_callable=StringIO):
_display_page_count_estimate(2017)
mock_est.assert_called_once_with(2017)
@patch(f"{MOD}.estimate_consumable_life")
def test_approximate_is_labelled(self, mock_est: MagicMock) -> None:
"""A CUPS-log figure must not be presented as the printer's own count."""
mock_est.return_value = PageCountEstimate(
total_pages=1658,
toner_pages=250,
drum_pages=1658,
approximate=True,
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate(0)
assert "Approximate" in out.getvalue()
@patch(f"{MOD}.estimate_consumable_life")
def test_printer_count_is_not_labelled_approximate(
self,
mock_est: MagicMock,
) -> None:
mock_est.return_value = PageCountEstimate(
total_pages=2017,
toner_pages=250,
drum_pages=2017,
approximate=False,
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate(2017)
assert "Approximate" not in out.getvalue()
@patch(f"{MOD}.estimate_consumable_life")
def test_healthy(self, mock_est: MagicMock) -> None:
mock_est.return_value = PageCountEstimate(
total_pages=100,
toner_pages=100,
drum_pages=100,
toner_pct_remaining=90,
drum_pct_remaining=99,
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate()
assert "Total pages" in out.getvalue()
@patch(f"{MOD}.estimate_consumable_life")
def test_toner_exhausted(self, mock_est: MagicMock) -> None:
mock_est.return_value = PageCountEstimate(
total_pages=1000,
toner_pages=1000,
drum_pages=100,
toner_pct_remaining=0,
drum_pct_remaining=99,
toner_exhausted=True,
toner_low=True,
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate()
assert "REPLACE NOW" in out.getvalue()
@patch(f"{MOD}.estimate_consumable_life")
def test_toner_low(self, mock_est: MagicMock) -> None:
mock_est.return_value = PageCountEstimate(
total_pages=800,
toner_pages=800,
drum_pages=100,
toner_pct_remaining=20,
drum_pct_remaining=99,
toner_low=True,
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate()
assert "order soon" in out.getvalue()
@patch(f"{MOD}.estimate_consumable_life")
def test_drum_near_end(self, mock_est: MagicMock) -> None:
mock_est.return_value = PageCountEstimate(
total_pages=9000,
toner_pages=100,
drum_pages=9000,
toner_pct_remaining=90,
drum_pct_remaining=10,
drum_near_end=True,
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate()
assert "nearing end" in out.getvalue()
class TestDisplayConsumablesReference:
def test_prints(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as out:
_display_consumables_reference()
assert "TN-1050" in out.getvalue()
class TestDisplayUsbDeviceInfo:
def test_full_info(self) -> None:
r = USBResult(
product="HL-1110",
serial="SN123",
online="TRUE",
economode="ON",
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
text = out.getvalue()
assert "HL-1110" in text
assert "SN123" in text
assert "Yes" in text
assert "Toner Save" in text
def test_offline(self) -> None:
r = USBResult(online="FALSE")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "No (needs attention)" in out.getvalue()
def test_no_online(self) -> None:
r = USBResult(online="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "Online" not in out.getvalue()
def test_economode_off(self) -> None:
r = USBResult(economode="OFF")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "OFF" in out.getvalue()
def test_no_economode(self) -> None:
r = USBResult(economode="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "Toner Save" not in out.getvalue()
def test_no_serial(self) -> None:
r = USBResult(serial="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "Serial" not in out.getvalue()
def test_no_product(self) -> None:
r = USBResult(product="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "Unknown" in out.getvalue()
class TestFormatStatusDetail:
def test_with_action(self) -> None:
r = USBResult(
status_code="30010",
display="Toner Low Display",
)
with patch("sys.stdout", new_callable=StringIO) as out:
_format_status_detail("warn", "Toner Low", "Replace toner", r)
text = out.getvalue()
assert "Toner Low" in text
assert "Replace toner" in text
assert "Display:" in text
def test_no_action(self) -> None:
r = USBResult(status_code="10001", display="Ready")
with patch("sys.stdout", new_callable=StringIO) as out:
_format_status_detail("ok", "Ready", "", r)
assert "Action" not in out.getvalue()
def test_display_same_as_text(self) -> None:
r = USBResult(status_code="10001", display="Ready")
with patch("sys.stdout", new_callable=StringIO) as out:
_format_status_detail("ok", "Ready", "", r)
assert "Display:" not in out.getvalue()
def test_unknown_severity(self) -> None:
r = USBResult(status_code="99999", display="")
with patch("sys.stdout", new_callable=StringIO):
_format_status_detail("unknown", "Test", "", r)
# Should not crash
def test_critical(self) -> None:
r = USBResult(status_code="40310", display="Toner End")
with patch("sys.stdout", new_callable=StringIO) as out:
_format_status_detail("critical", "Toner End", "Replace", r)
assert "ACTION REQUIRED" in out.getvalue()
def test_info(self) -> None:
r = USBResult(status_code="10006", display="Processing")
with patch("sys.stdout", new_callable=StringIO) as out:
_format_status_detail("info", "Processing", "", r)
assert "busy" in out.getvalue()
class TestDisplayPjlStatus:
def test_no_code(self) -> None:
r = USBResult(status_code="", display="hello")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_pjl_status(r)
assert "Could not read status" in out.getvalue()
assert "hello" in out.getvalue()
def test_no_code_no_display(self) -> None:
r = USBResult(status_code="", display="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_pjl_status(r)
assert "Could not read status" in out.getvalue()
@patch(f"{MOD}._format_status_detail")
@patch(f"{MOD}.get_status_info", return_value=("ok", "Ready", ""))
def test_with_code(self, g: MagicMock, mock_fmt: MagicMock) -> None:
r = USBResult(status_code="10001")
with patch("sys.stdout", new_callable=StringIO):
_display_pjl_status(r)
mock_fmt.assert_called_once()
class TestDisplayCupsFallbackNote:
def test_with_port_status(self) -> None:
r = USBResult(port_status=USBPortStatus())
with patch("sys.stdout", new_callable=StringIO) as out:
_display_cups_fallback_note(r)
assert "USB port query" in out.getvalue()
def test_without_port_status(self) -> None:
r = USBResult(port_status=None)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_cups_fallback_note(r)
assert "pyusb not available" in out.getvalue()
class TestDisplayUsbResults:
def test_normal(self) -> None:
r = USBResult(device="/dev/usb/lp0")
with (
patch(f"{MOD}._display_report_header"),
patch(f"{MOD}._display_usb_device_info"),
patch(f"{MOD}._display_pjl_status"),
patch(f"{MOD}._display_page_count_estimate"),
patch(f"{MOD}._display_consumables_reference"),
patch(f"{MOD}.get_cups_queue_status"),
patch(f"{MOD}.display_cups_queue_status"),
patch("sys.stdout", new_callable=StringIO),
):
display_usb_results(r)
def test_cups_device(self) -> None:
r = USBResult(device="cups")
with (
patch(f"{MOD}._display_report_header"),
patch(f"{MOD}._display_usb_device_info"),
patch(f"{MOD}._display_pjl_status"),
patch(f"{MOD}._display_page_count_estimate"),
patch(f"{MOD}._display_consumables_reference"),
patch(f"{MOD}.get_cups_queue_status"),
patch(f"{MOD}.display_cups_queue_status"),
patch(f"{MOD}._display_cups_fallback_note") as mock_fallback,
patch("sys.stdout", new_callable=StringIO),
):
display_usb_results(r)
mock_fallback.assert_called_once()
def test_error(self) -> None:
r = USBResult(error="fail")
with (
patch("sys.stdout", new_callable=StringIO),
pytest.raises(SystemExit),
):
display_usb_results(r)
class TestClassifyPercentageLevel:
def test_low(self) -> None:
pct, _, _, _, replace = _classify_percentage_level("Toner", 5)
assert pct == 5
assert replace is True
def test_warn(self) -> None:
_, _, _, warn, replace = _classify_percentage_level("Toner", 20)
assert replace is False
assert "order soon" in warn
def test_ok(self) -> None:
_, _, _, warn, replace = _classify_percentage_level("Toner", 80)
assert replace is False
assert warn == ""
class TestClassifySupplyLevel:
def test_snmp_ok(self) -> None:
_, text, _, _, replace = _classify_supply_level("Toner", 100, -3)
assert text == "OK"
assert replace is False
def test_snmp_low(self) -> None:
_, text, _, _, replace = _classify_supply_level("Toner", 100, -2)
assert text == "LOW"
assert replace is True
def test_empty(self) -> None:
_, text, _, _, replace = _classify_supply_level("Toner", 100, 0)
assert text == "EMPTY"
assert replace is True
def test_normal_percentage(self) -> None:
pct, _, _, _, replace = _classify_supply_level("Toner", 100, 80)
assert pct == 80
assert replace is False
def test_no_max_val(self) -> None:
pct, text, _, _, _ = _classify_supply_level("Toner", 0, 50)
assert pct == -1
assert text == ""
def test_over_100_capped(self) -> None:
pct, _, _, _, _ = _classify_supply_level("Toner", 50, 100)
assert pct == 100
class TestFormatSupplyBar:
def test_negative(self) -> None:
assert _format_supply_bar(-1) == ""
def test_zero(self) -> None:
bar = _format_supply_bar(0)
assert "░" in bar
def test_full(self) -> None:
bar = _format_supply_bar(100)
assert "█" in bar
class TestProcessSupplyItem:
def test_normal(self) -> None:
item = _process_supply_item("Toner", 100, 80)
assert item.status_text == "80%"
def test_empty(self) -> None:
item = _process_supply_item("Toner", 100, 0)
assert item.needs_replacement is True
class TestDisplaySupplyWarnings:
def test_replacement_needed(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as out:
_display_supply_warnings(
needs_replacement=True,
warnings=["Toner low"],
)
assert "ACTION NEEDED" in out.getvalue()
def test_warnings_only(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as out:
_display_supply_warnings(
needs_replacement=False,
warnings=["Toner at 20%"],
)
assert "HEADS UP" in out.getvalue()
def test_all_healthy(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as out:
_display_supply_warnings(
needs_replacement=False,
warnings=[],
)
assert "healthy" in out.getvalue()
class TestParseSupplyValue:
def test_valid(self) -> None:
assert _parse_supply_value(["10", "20"], 0) == 10
def test_index_error(self) -> None:
assert _parse_supply_value([], 0) == 0
def test_value_error(self) -> None:
assert _parse_supply_value(["abc"], 0) == 0
class TestCollectSupplyItems:
def test_collect(self) -> None:
result = NetworkResult(
supplies=SupplyReadings(
descriptions=["Toner", "Drum"],
max_values=["100", "200"],
levels=["80", "150"],
),
)
items, descs = _collect_supply_items(result)
assert len(items) == 2
assert descs == ["Toner", "Drum"]
class TestDisplaySupplyLevels:
def test_with_items(self) -> None:
result = NetworkResult(
supplies=SupplyReadings(
descriptions=["Toner"],
max_values=["100"],
levels=["80"],
),
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_supply_levels(result)
assert "Toner" in out.getvalue()
def test_needs_replacement_and_warning(self) -> None:
result = NetworkResult(
supplies=SupplyReadings(
descriptions=["Toner", "Drum"],
max_values=["100", "100"],
levels=["0", "15"],
),
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_supply_levels(result)
text = out.getvalue()
assert "ACTION NEEDED" in text
class TestDisplayPageDeliveryWarning:
"""Surfacing pages the printer dropped without telling anyone."""
@patch(f"{MOD}.check_page_delivery")
def test_warns_with_actionable_fix(self, mock_check: MagicMock) -> None:
mock_check.return_value = PageDeliveryCheck(
cups_pages=63,
printer_pages=3,
dropped=60,
suspected=True,
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_delivery_warning(2087, queue_idle=True)
printed = out.getvalue()
assert "60 pages did not print" in printed
assert "63" in printed
# Useless without telling the reader what to actually do about it.
assert "Resolution=300dpi" in printed
@patch(f"{MOD}.check_page_delivery")
def test_silent_when_healthy(self, mock_check: MagicMock) -> None:
mock_check.return_value = PageDeliveryCheck(suspected=False)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_delivery_warning(2087, queue_idle=True)
assert out.getvalue() == ""
@patch(f"{MOD}.check_page_delivery")
def test_passes_queue_state_through(self, mock_check: MagicMock) -> None:
mock_check.return_value = PageDeliveryCheck(suspected=False)
with patch("sys.stdout", new_callable=StringIO):
_display_page_delivery_warning(2087, queue_idle=False)
mock_check.assert_called_once_with(2087, queue_idle=False)