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

451 lines
16 KiB
Python

"""Tests for brother_printer.cups_service module - part 4 (consumable life, IPP)."""
from __future__ import annotations
import subprocess
from unittest.mock import MagicMock, patch
from python_pkg.brother_printer.cups_service import (
STATE_SCHEMA_CUPS_SCALE,
STATE_SCHEMA_PRINTER_SCALE,
_cups_total_on_printer_scale,
_get_cups_ipp_status,
_migrate_state_to_printer_scale,
_parse_ipp_attributes,
_snapshot_counters,
check_page_delivery,
estimate_consumable_life,
reset_consumable,
)
MOD = "python_pkg.brother_printer.cups_service"
class TestEstimateConsumableLife:
@patch(f"{MOD}._load_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=0)
def test_no_pages(self, p: MagicMock, mock_load: MagicMock) -> None:
result = estimate_consumable_life()
assert result.total_pages == 0
@patch(f"{MOD}._load_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=500)
def test_mid_life(self, p: MagicMock, mock_load: MagicMock) -> None:
mock_load.return_value = {
"toner_replaced_at": 0,
"drum_replaced_at": 0,
"last_printer_count": 0,
"last_cups_total": 0,
}
result = estimate_consumable_life()
assert result.total_pages == 500
assert result.toner_pct_remaining == 50
assert result.toner_exhausted is False
assert result.toner_low is False
@patch(f"{MOD}._load_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=1000)
def test_toner_exhausted(self, p: MagicMock, mock_load: MagicMock) -> None:
mock_load.return_value = {
"toner_replaced_at": 0,
"drum_replaced_at": 0,
"last_printer_count": 0,
"last_cups_total": 0,
}
result = estimate_consumable_life()
assert result.toner_exhausted is True
@patch(f"{MOD}._load_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=800)
def test_toner_low(self, p: MagicMock, mock_load: MagicMock) -> None:
mock_load.return_value = {
"toner_replaced_at": 0,
"drum_replaced_at": 0,
"last_printer_count": 0,
"last_cups_total": 0,
}
result = estimate_consumable_life()
assert result.toner_low is True
@patch(f"{MOD}._load_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=9000)
def test_drum_near_end(self, p: MagicMock, mock_load: MagicMock) -> None:
mock_load.return_value = {
"toner_replaced_at": 8500,
"drum_replaced_at": 0,
"last_printer_count": 0,
"last_cups_total": 0,
}
result = estimate_consumable_life()
assert result.drum_near_end is True
class TestParseIppAttributes:
def test_parse(self) -> None:
output = " printer-state (enum) = idle\n printer-name (name) = Brother\n"
result = _parse_ipp_attributes(output)
assert result["printer-state"] == "idle"
assert result["printer-name"] == "Brother"
def test_no_match(self) -> None:
result = _parse_ipp_attributes("no attributes here\n")
assert result == {}
class TestGetCupsIppStatus:
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_ipptool(self, m: MagicMock) -> None:
assert _get_cups_ipp_status("Brother") == {}
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/ipptool")
def test_success(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout=" printer-state (enum) = idle\n",
)
result = _get_cups_ipp_status("Brother")
assert result["printer-state"] == "idle"
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/ipptool")
def test_timeout(self, w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.TimeoutExpired("ipptool", 10)
assert _get_cups_ipp_status("Brother") == {}
class TestMigrateStateToPrinterScale:
"""Rebasing replacement baselines from the CUPS page log onto the printer.
The baselines are recorded against whatever counter was in use when they
were written. Switching counters without rebasing them silently changes
every reported percentage, so this is the migration that keeps the numbers
meaning the same thing before and after.
"""
@patch(f"{MOD}._save_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=1658)
def test_shifts_baseline_by_offset(
self,
mock_total: MagicMock,
mock_save: MagicMock,
) -> None:
state = {
"toner_replaced_at": 1408,
"drum_replaced_at": 0,
"schema": STATE_SCHEMA_CUPS_SCALE,
}
result = _migrate_state_to_printer_scale(state, 2017)
# Offset is 2017 - 1658 = 359.
assert result["toner_replaced_at"] == 1767
assert result["schema"] == STATE_SCHEMA_PRINTER_SCALE
mock_save.assert_called_once()
@patch(f"{MOD}._save_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=1658)
def test_zero_baseline_left_alone(
self,
mock_total: MagicMock,
mock_save: MagicMock,
) -> None:
"""Zero means 'never replaced', not 'replaced at page zero'.
On the printer's scale zero already means "as old as the printer",
which is the truthful reading; shifting it would re-hide the pages the
CUPS log never saw.
"""
state = {
"toner_replaced_at": 0,
"drum_replaced_at": 0,
"schema": STATE_SCHEMA_CUPS_SCALE,
}
result = _migrate_state_to_printer_scale(state, 2017)
assert result["drum_replaced_at"] == 0
assert result["toner_replaced_at"] == 0
@patch(f"{MOD}._save_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=1658)
def test_runs_only_once(
self,
mock_total: MagicMock,
mock_save: MagicMock,
) -> None:
"""A second run must not shift the baselines again."""
state = {
"toner_replaced_at": 1767,
"drum_replaced_at": 0,
"schema": STATE_SCHEMA_PRINTER_SCALE,
}
result = _migrate_state_to_printer_scale(state, 2017)
assert result["toner_replaced_at"] == 1767
mock_save.assert_not_called()
@patch(f"{MOD}._save_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=2100)
def test_negative_offset_leaves_baselines(
self,
mock_total: MagicMock,
mock_save: MagicMock,
) -> None:
"""If the CUPS log somehow exceeds the printer, do not corrupt state."""
state = {
"toner_replaced_at": 1408,
"drum_replaced_at": 0,
"schema": STATE_SCHEMA_CUPS_SCALE,
}
result = _migrate_state_to_printer_scale(state, 2017)
assert result["toner_replaced_at"] == 1408
assert result["schema"] == STATE_SCHEMA_PRINTER_SCALE
class TestEstimateConsumableLifeCounterSource:
"""Which counter the estimate came from, and whether it says so."""
@patch(f"{MOD}._migrate_state_to_printer_scale")
@patch(f"{MOD}._load_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=1658)
def test_printer_counter_is_authoritative(
self,
mock_cups: MagicMock,
mock_load: MagicMock,
mock_migrate: MagicMock,
) -> None:
mock_load.return_value = {
"toner_replaced_at": 1767,
"drum_replaced_at": 0,
"last_printer_count": 0,
"last_cups_total": 0,
}
mock_migrate.side_effect = lambda state, total: state
estimate = estimate_consumable_life(2017)
assert estimate.total_pages == 2017
assert estimate.approximate is False
assert estimate.toner_pages == 250
@patch(f"{MOD}._load_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=1658)
def test_falls_back_to_cups_log_and_flags_it(
self,
mock_cups: MagicMock,
mock_load: MagicMock,
) -> None:
"""Without the printer's counter, say the number is approximate."""
mock_load.return_value = {
"toner_replaced_at": 0,
"drum_replaced_at": 0,
"last_printer_count": 0,
"last_cups_total": 0,
}
estimate = estimate_consumable_life(0)
assert estimate.total_pages == 1658
assert estimate.approximate is True
@patch(f"{MOD}._get_cups_total_pages", return_value=0)
def test_no_counter_at_all(self, mock_cups: MagicMock) -> None:
assert estimate_consumable_life(0).total_pages == 0
class TestResetConsumableScale:
@patch(f"{MOD}._out")
@patch(f"{MOD}._save_consumable_state")
@patch(f"{MOD}._load_consumable_state")
def test_printer_total_marks_state_migrated(
self,
mock_load: MagicMock,
mock_save: MagicMock,
mock_out: MagicMock,
) -> None:
"""A baseline written from the printer is already on the new scale."""
mock_load.return_value = {
"toner_replaced_at": 0,
"drum_replaced_at": 0,
"schema": STATE_SCHEMA_CUPS_SCALE,
}
reset_consumable("toner", 2017)
saved = mock_save.call_args[0][0]
assert saved["toner_replaced_at"] == 2017
assert saved["schema"] == STATE_SCHEMA_PRINTER_SCALE
@patch(f"{MOD}._out")
@patch(f"{MOD}._save_consumable_state")
@patch(f"{MOD}._load_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=1658)
def test_without_printer_total_uses_cups_log(
self,
mock_cups: MagicMock,
mock_load: MagicMock,
mock_save: MagicMock,
mock_out: MagicMock,
) -> None:
mock_load.return_value = {
"toner_replaced_at": 0,
"drum_replaced_at": 0,
"schema": STATE_SCHEMA_CUPS_SCALE,
}
reset_consumable("drum")
saved = mock_save.call_args[0][0]
assert saved["drum_replaced_at"] == 1658
assert saved["schema"] == STATE_SCHEMA_CUPS_SCALE
class TestCheckPageDelivery:
"""Spotting pages the printer silently dropped.
CUPS calls a job successful once the data leaves the machine, so this
comparison against the printer's own counter is the only signal that a page
never actually came out.
"""
@patch(f"{MOD}._snapshot_counters")
@patch(f"{MOD}._get_cups_total_pages", return_value=1841)
@patch(f"{MOD}._load_consumable_state")
def test_detects_dropped_pages(
self,
mock_load: MagicMock,
mock_cups: MagicMock,
mock_snap: MagicMock,
) -> None:
"""Replay of the real failure: CUPS sent 63, printer printed none."""
mock_load.return_value = {
"last_printer_count": 2087,
"last_cups_total": 1778,
}
check = check_page_delivery(2087, queue_idle=True)
assert check.cups_pages == 63
assert check.printer_pages == 0
assert check.dropped == 63
assert check.suspected is True
@patch(f"{MOD}._snapshot_counters")
@patch(f"{MOD}._get_cups_total_pages", return_value=1841)
@patch(f"{MOD}._load_consumable_state")
def test_healthy_job_does_not_warn(
self,
mock_load: MagicMock,
mock_cups: MagicMock,
mock_snap: MagicMock,
) -> None:
mock_load.return_value = {
"last_printer_count": 2024,
"last_cups_total": 1778,
}
check = check_page_delivery(2087, queue_idle=True)
assert check.dropped == 0
assert check.suspected is False
@patch(f"{MOD}._snapshot_counters")
@patch(f"{MOD}._get_cups_total_pages", return_value=1780)
@patch(f"{MOD}._load_consumable_state")
def test_small_gap_is_not_a_warning(
self,
mock_load: MagicMock,
mock_cups: MagicMock,
mock_snap: MagicMock,
) -> None:
"""A page or two of drift is timing, not a fault."""
mock_load.return_value = {
"last_printer_count": 2087,
"last_cups_total": 1778,
}
check = check_page_delivery(2087, queue_idle=True)
assert check.dropped == 2
assert check.suspected is False
def test_skipped_while_printing(self) -> None:
"""Mid-job the counters legitimately disagree, so never compare."""
assert check_page_delivery(2087, queue_idle=False).suspected is False
def test_skipped_without_printer_count(self) -> None:
assert check_page_delivery(0, queue_idle=True).suspected is False
@patch(f"{MOD}._snapshot_counters")
@patch(f"{MOD}._get_cups_total_pages", return_value=1841)
@patch(f"{MOD}._load_consumable_state")
def test_first_run_only_establishes_baseline(
self,
mock_load: MagicMock,
mock_cups: MagicMock,
mock_snap: MagicMock,
) -> None:
mock_load.return_value = {"last_printer_count": 0, "last_cups_total": 0}
check = check_page_delivery(2087, queue_idle=True)
assert check.suspected is False
mock_snap.assert_called_once()
@patch(f"{MOD}._snapshot_counters")
@patch(f"{MOD}._get_cups_total_pages", return_value=10)
@patch(f"{MOD}._load_consumable_state")
def test_log_rotation_draws_no_conclusion(
self,
mock_load: MagicMock,
mock_cups: MagicMock,
mock_snap: MagicMock,
) -> None:
"""A rotated page log makes the delta negative; do not cry wolf."""
mock_load.return_value = {
"last_printer_count": 2087,
"last_cups_total": 1778,
}
check = check_page_delivery(2087, queue_idle=True)
assert check.suspected is False
@patch(f"{MOD}._snapshot_counters")
@patch(f"{MOD}._get_cups_total_pages", return_value=1841)
@patch(f"{MOD}._load_consumable_state")
def test_printer_counter_reset_draws_no_conclusion(
self,
mock_load: MagicMock,
mock_cups: MagicMock,
mock_snap: MagicMock,
) -> None:
mock_load.return_value = {
"last_printer_count": 5000,
"last_cups_total": 1778,
}
check = check_page_delivery(2087, queue_idle=True)
assert check.suspected is False
class TestSnapshotCounters:
@patch(f"{MOD}._save_consumable_state")
def test_writes_new_snapshot(self, mock_save: MagicMock) -> None:
state = {"last_printer_count": 2000, "last_cups_total": 1700}
_snapshot_counters(state, 2087, 1778)
saved = mock_save.call_args[0][0]
assert saved["last_printer_count"] == 2087
assert saved["last_cups_total"] == 1778
@patch(f"{MOD}._save_consumable_state")
def test_unchanged_counters_skip_the_write(self, mock_save: MagicMock) -> None:
"""Do not rewrite the file on every run when nothing moved."""
state = {"last_printer_count": 2087, "last_cups_total": 1778}
_snapshot_counters(state, 2087, 1778)
mock_save.assert_not_called()
class TestCupsTotalOnPrinterScale:
"""The CUPS figure must be shifted onto the scale the baselines use.
Without the shift the raw CUPS total sits below a printer-scale baseline,
the subtraction clamps at zero, and a part-used cartridge reads 100%.
"""
@patch(f"{MOD}._get_cups_total_pages", return_value=1778)
def test_applies_offset(self, mock_cups: MagicMock) -> None:
state = {"last_printer_count": 2087, "last_cups_total": 1778}
assert _cups_total_on_printer_scale(state) == 2087
@patch(f"{MOD}._get_cups_total_pages", return_value=1778)
def test_no_snapshot_returns_raw(self, mock_cups: MagicMock) -> None:
state = {"last_printer_count": 0, "last_cups_total": 0}
assert _cups_total_on_printer_scale(state) == 1778
@patch(f"{MOD}._get_cups_total_pages", return_value=1778)
def test_negative_offset_returns_raw(self, mock_cups: MagicMock) -> None:
state = {"last_printer_count": 1700, "last_cups_total": 1778}
assert _cups_total_on_printer_scale(state) == 1778
@patch(f"{MOD}._get_cups_total_pages", return_value=0)
def test_no_cups_pages(self, mock_cups: MagicMock) -> None:
state = {"last_printer_count": 2087, "last_cups_total": 1778}
assert _cups_total_on_printer_scale(state) == 0