From 2c01a0267ebf62e1a2ffc7db840bcec97441c80c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 16 Aug 2026 17:27:07 +0200 Subject: [PATCH] Clear the last three brother_printer test modules over the cap test_consumables_part2.py 273 -> 133 (renamed test_counter_scales.py), test_consumables.py 272 -> 123, test_check_brother_printer.py 275 -> 112. python_pkg/brother_printer is now entirely under the 250-line cap, from fifteen violations at the start. The consumables split lands exactly on the MOD/MOD_PD boundary an earlier commit introduced, so the two-constant workaround dissolves instead of being carried forward - each file has one module under test again. Pass count is 402, unchanged across all eight commits of this pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- ...r-printer-test-final-split-2026-08-16.json | 18 ++ ...r-printer-test-final-split-2026-08-16.json | 48 +++ .../tests/test_check_brother_printer.py | 167 +---------- .../tests/test_check_brother_printer_main.py | 183 ++++++++++++ .../tests/test_consumable_estimate.py | 167 +++++++++++ .../brother_printer/tests/test_consumables.py | 153 +--------- .../tests/test_consumables_part2.py | 273 ------------------ .../tests/test_counter_scales.py | 131 +++++++++ .../tests/test_page_delivery.py | 152 ++++++++++ 9 files changed, 701 insertions(+), 591 deletions(-) create mode 100644 docs/superpowers/contracts/brother-printer-test-final-split-2026-08-16.json create mode 100644 docs/superpowers/evidence/brother-printer-test-final-split-2026-08-16.json create mode 100644 python_pkg/brother_printer/tests/test_check_brother_printer_main.py create mode 100644 python_pkg/brother_printer/tests/test_consumable_estimate.py delete mode 100644 python_pkg/brother_printer/tests/test_consumables_part2.py create mode 100644 python_pkg/brother_printer/tests/test_counter_scales.py create mode 100644 python_pkg/brother_printer/tests/test_page_delivery.py diff --git a/docs/superpowers/contracts/brother-printer-test-final-split-2026-08-16.json b/docs/superpowers/contracts/brother-printer-test-final-split-2026-08-16.json new file mode 100644 index 00000000..73933c63 --- /dev/null +++ b/docs/superpowers/contracts/brother-printer-test-final-split-2026-08-16.json @@ -0,0 +1,18 @@ +{ + "title": "Clear the last three brother_printer test modules over the 250-line cap", + "objective": "Bring test_check_brother_printer.py (275 lines), test_consumables.py (272) and test_consumables_part2.py (273) under the repo-wide 250-line cap, taking python_pkg/brother_printer from fifteen violations to zero.", + "acceptance_criteria": [ + "bash ~/utils/scripts/check_file_length.sh --all lists no file under python_pkg/brother_printer.", + "The brother_printer suite still reports exactly 402 passing tests -- the same count as at the start of this whole package pass, proving no test was dropped or silently skipped across any of the eight commits.", + "The package keeps 100% branch coverage of the brother_printer source modules.", + "test_consumables_part2.py's split removes the need for its second MOD_PD constant: the tests that needed it are exactly the ones that move, so each resulting file has one module under test again.", + "No assertion is changed and no test is deleted, skipped or weakened; whole classes move verbatim and only the import blocks are adjusted.", + "No suppression is added and no coverage exclusion is introduced." + ], + "out_of_scope": [ + "Changing what any test asserts.", + "Touching the brother_printer source, which is already under the cap.", + "Other packages' violations, which are separate commits." + ], + "verifier": "PYTHONPATH=. python3 -m pytest python_pkg/brother_printer/tests -q (expects exactly 402 passed), plus bash ~/utils/scripts/check_file_length.sh --all showing no brother_printer entry, plus pre-commit run --files on the touched files." +} diff --git a/docs/superpowers/evidence/brother-printer-test-final-split-2026-08-16.json b/docs/superpowers/evidence/brother-printer-test-final-split-2026-08-16.json new file mode 100644 index 00000000..62c3b1cf --- /dev/null +++ b/docs/superpowers/evidence/brother-printer-test-final-split-2026-08-16.json @@ -0,0 +1,48 @@ +{ + "intent": "Clear the last three over-cap brother_printer test modules, taking the package from fifteen violations to zero.", + "scope": [ + "python_pkg/brother_printer/tests/test_consumables_part2.py -> test_counter_scales.py (273 -> 133)", + "python_pkg/brother_printer/tests/test_page_delivery.py (new)", + "python_pkg/brother_printer/tests/test_consumables.py (272 -> 123)", + "python_pkg/brother_printer/tests/test_consumable_estimate.py (new)", + "python_pkg/brother_printer/tests/test_check_brother_printer.py (275 -> 112)", + "python_pkg/brother_printer/tests/test_check_brother_printer_main.py (new)", + "Non-goal: no assertion changed, no test deleted, skipped or weakened" + ], + "changes": [ + "test_consumables_part2.py split at TestCheckPageDelivery, which is exactly the MOD/MOD_PD boundary an earlier commit introduced. The two classes that needed MOD_PD move to test_page_delivery.py and use a plain MOD there, so the two-constant workaround dissolves rather than being carried forward: each file has one module under test again.", + "test_consumables.py split at TestEstimateConsumableLife: the state file and CUPS page-log tests stay, the life estimate and replacement resets go to test_consumable_estimate.py.", + "test_check_brother_printer.py split at TestMain: discovery and the network/USB mode selection stay, the CLI entry point and the reset flags go to test_check_brother_printer_main.py.", + "Renamed test_consumables_part2.py to test_counter_scales.py and narrowed its docstring, which had claimed to cover dropped-page detection that now lives in test_page_delivery.py." + ], + "verification": [ + { + "command": "PYTHONPATH=. python3 -m pytest python_pkg/brother_printer/tests -q", + "result": "pass", + "evidence": "402 passed - the same count as before the first of the eight commits in this package pass, so nothing was lost anywhere along the way." + }, + { + "command": "PYTHONPATH=. python3 -m pytest python_pkg/brother_printer/tests -q (first run after the check_brother_printer split)", + "result": "fail then pass", + "evidence": "Reported 4 failed, 398 passed: TestResetConsumableFromPrinter travelled with TestMain and needed USBResult, which my hand-written import block had omitted. Added; the count returned to 402." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all | grep brother_printer", + "result": "pass", + "evidence": "No output - python_pkg/brother_printer is entirely under the 250-line cap, down from fifteen violations at the start of the session." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "ruff, ruff format, mypy, pylint, bandit, name-tests-test and pytest-with-coverage all pass." + } + ], + "risks": [ + "Hand-trimmed import blocks remain the recurring risk in test splits; this pass hit it three times and the pass-count check caught it every time. A test that was already skipped would not be caught this way, but the package has none.", + "Several test modules are now named after the private module they cover (_page_delivery, _pjl_io, _queue_fix). If a future split renames one of those source modules, the test file names go stale even though nothing breaks." + ], + "rollback": [ + "git revert the commit; the three originals return at 275, 272 and 273 lines and the three new modules disappear.", + "After rollback, re-run the package suite and confirm it still reports 402 passed." + ] +} diff --git a/python_pkg/brother_printer/tests/test_check_brother_printer.py b/python_pkg/brother_printer/tests/test_check_brother_printer.py index 284d56d7..ac7f920c 100644 --- a/python_pkg/brother_printer/tests/test_check_brother_printer.py +++ b/python_pkg/brother_printer/tests/test_check_brother_printer.py @@ -1,4 +1,4 @@ -"""Tests for brother_printer.check_brother_printer module.""" +"""Tests for brother_printer.check_brother_printer - discovery and modes.""" from __future__ import annotations @@ -11,10 +11,8 @@ import pytest from python_pkg.brother_printer.check_brother_printer import ( _discover_network_printer, _no_printer_found, - _reset_consumable_from_printer, _run_network_mode, _run_usb_mode, - main, ) from python_pkg.brother_printer.data_classes import USBResult @@ -110,166 +108,3 @@ class TestNoPrinterFound: pytest.raises(SystemExit), ): _no_printer_found() - - -class TestMain: - @patch(f"{MOD}.reset_consumable") - def test_reset_toner(self, mock_reset: MagicMock) -> None: - main(["--reset-toner"]) - mock_reset.assert_called_once_with("toner") - - @patch(f"{MOD}.reset_consumable") - def test_reset_drum(self, mock_reset: MagicMock) -> None: - main(["--reset-drum"]) - mock_reset.assert_called_once_with("drum") - - @patch(f"{MOD}.os.geteuid", return_value=1000) - def test_not_root(self, m: MagicMock) -> None: - with ( - patch("sys.stdout", new_callable=StringIO), - pytest.raises(SystemExit), - ): - main([]) - - @patch(f"{MOD}._run_network_mode") - @patch(f"{MOD}.os.geteuid", return_value=0) - def test_with_ip(self, g: MagicMock, mock_net: MagicMock) -> None: - main(["1.2.3.4"]) - mock_net.assert_called_once_with("1.2.3.4") - - @patch(f"{MOD}._run_usb_mode") - @patch(f"{MOD}.find_brother_usb", return_value="Brother USB") - @patch(f"{MOD}.os.geteuid", return_value=0) - def test_usb_found( - self, - g: MagicMock, - f: MagicMock, - mock_usb: MagicMock, - ) -> None: - main([]) - mock_usb.assert_called_once() - - def test_network_discovered(self) -> None: - from python_pkg.brother_printer.data_classes import NetworkResult - - with ( - patch(f"{MOD}.os.geteuid", return_value=0), - patch(f"{MOD}.find_brother_usb", return_value=""), - patch(f"{MOD}._discover_network_printer", return_value="192.168.1.100"), - patch(f"{MOD}.shutil.which", return_value="/usr/bin/snmpwalk"), - patch(f"{MOD}.query_network_snmp") as mock_query, - patch(f"{MOD}.display_network_results") as mock_display, - patch("sys.stdout", new_callable=StringIO), - ): - mock_query.return_value = NetworkResult(ip="192.168.1.100") - main([]) - mock_display.assert_called_once() - - @patch(f"{MOD}._no_printer_found") - @patch(f"{MOD}._discover_network_printer", return_value="") - @patch(f"{MOD}.find_brother_usb", return_value="") - @patch(f"{MOD}.os.geteuid", return_value=0) - def test_nothing_found( - self, - g: MagicMock, - f: MagicMock, - d: MagicMock, - mock_no: MagicMock, - ) -> None: - main([]) - mock_no.assert_called_once() - - @patch(f"{MOD}._no_printer_found") - @patch(f"{MOD}.shutil.which", return_value=None) - @patch(f"{MOD}._discover_network_printer", return_value="192.168.1.100") - @patch(f"{MOD}.find_brother_usb", return_value="") - @patch(f"{MOD}.os.geteuid", return_value=0) - def test_network_discovered_no_snmpwalk( - self, - g: MagicMock, - f: MagicMock, - d: MagicMock, - w: MagicMock, - mock_no: MagicMock, - ) -> None: - main([]) - mock_no.assert_called_once() - - def test_default_argv(self) -> None: - with ( - patch(f"{MOD}.sys.argv", ["prog", "--reset-toner"]), - patch(f"{MOD}.reset_consumable") as mock_reset, - ): - main() - mock_reset.assert_called_once_with("toner") - - @patch(f"{MOD}.os.geteuid", return_value=1000) - def test_not_root_with_args(self, g: MagicMock) -> None: - with ( - patch("sys.stdout", new_callable=StringIO), - pytest.raises(SystemExit), - ): - main(["1.2.3.4"]) - - -class TestResetConsumableFromPrinter: - """Resetting a baseline must use the printer's own counter, or refuse. - - Baselines live on the printer's lifetime-counter scale. Recording one from - any other source skews every later estimate, so a printer that cannot be - read is a hard error rather than a silent fallback. - """ - - @patch(f"{MOD}.reset_consumable") - @patch(f"{MOD}.query_usb_pjl") - def test_uses_printer_page_count( - self, - mock_query: MagicMock, - mock_reset: MagicMock, - ) -> None: - mock_query.return_value = USBResult(page_count="2017") - _reset_consumable_from_printer("toner") - mock_reset.assert_called_once_with("toner", 2017) - - @patch(f"{MOD}._out") - @patch(f"{MOD}.reset_consumable") - @patch(f"{MOD}.query_usb_pjl") - def test_refuses_without_page_count( - self, - mock_query: MagicMock, - mock_reset: MagicMock, - mock_out: MagicMock, - ) -> None: - mock_query.return_value = USBResult(page_count="") - with pytest.raises(SystemExit): - _reset_consumable_from_printer("toner") - mock_reset.assert_not_called() - - @patch(f"{MOD}._out") - @patch(f"{MOD}.reset_consumable") - @patch(f"{MOD}.query_usb_pjl") - def test_reports_underlying_error( - self, - mock_query: MagicMock, - mock_reset: MagicMock, - mock_out: MagicMock, - ) -> None: - mock_query.return_value = USBResult(page_count="", error="device busy") - with pytest.raises(SystemExit): - _reset_consumable_from_printer("drum") - printed = " ".join(str(c) for c in mock_out.call_args_list) - assert "device busy" in printed - - @patch(f"{MOD}._out") - @patch(f"{MOD}.reset_consumable") - @patch(f"{MOD}.query_usb_pjl") - def test_non_numeric_page_count_refused( - self, - mock_query: MagicMock, - mock_reset: MagicMock, - mock_out: MagicMock, - ) -> None: - mock_query.return_value = USBResult(page_count="???") - with pytest.raises(SystemExit): - _reset_consumable_from_printer("toner") - mock_reset.assert_not_called() diff --git a/python_pkg/brother_printer/tests/test_check_brother_printer_main.py b/python_pkg/brother_printer/tests/test_check_brother_printer_main.py new file mode 100644 index 00000000..8875d875 --- /dev/null +++ b/python_pkg/brother_printer/tests/test_check_brother_printer_main.py @@ -0,0 +1,183 @@ +"""Tests for brother_printer.check_brother_printer - the CLI entry point. + +Split from test_check_brother_printer.py under the 250-line cap. These cover +argument handling, mode selection and the consumable reset flags. +""" + +from __future__ import annotations + +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest + +from python_pkg.brother_printer.check_brother_printer import ( + _reset_consumable_from_printer, + main, +) +from python_pkg.brother_printer.data_classes import USBResult + +MOD = "python_pkg.brother_printer.check_brother_printer" + + +class TestMain: + @patch(f"{MOD}.reset_consumable") + def test_reset_toner(self, mock_reset: MagicMock) -> None: + main(["--reset-toner"]) + mock_reset.assert_called_once_with("toner") + + @patch(f"{MOD}.reset_consumable") + def test_reset_drum(self, mock_reset: MagicMock) -> None: + main(["--reset-drum"]) + mock_reset.assert_called_once_with("drum") + + @patch(f"{MOD}.os.geteuid", return_value=1000) + def test_not_root(self, m: MagicMock) -> None: + with ( + patch("sys.stdout", new_callable=StringIO), + pytest.raises(SystemExit), + ): + main([]) + + @patch(f"{MOD}._run_network_mode") + @patch(f"{MOD}.os.geteuid", return_value=0) + def test_with_ip(self, g: MagicMock, mock_net: MagicMock) -> None: + main(["1.2.3.4"]) + mock_net.assert_called_once_with("1.2.3.4") + + @patch(f"{MOD}._run_usb_mode") + @patch(f"{MOD}.find_brother_usb", return_value="Brother USB") + @patch(f"{MOD}.os.geteuid", return_value=0) + def test_usb_found( + self, + g: MagicMock, + f: MagicMock, + mock_usb: MagicMock, + ) -> None: + main([]) + mock_usb.assert_called_once() + + def test_network_discovered(self) -> None: + from python_pkg.brother_printer.data_classes import NetworkResult + + with ( + patch(f"{MOD}.os.geteuid", return_value=0), + patch(f"{MOD}.find_brother_usb", return_value=""), + patch(f"{MOD}._discover_network_printer", return_value="192.168.1.100"), + patch(f"{MOD}.shutil.which", return_value="/usr/bin/snmpwalk"), + patch(f"{MOD}.query_network_snmp") as mock_query, + patch(f"{MOD}.display_network_results") as mock_display, + patch("sys.stdout", new_callable=StringIO), + ): + mock_query.return_value = NetworkResult(ip="192.168.1.100") + main([]) + mock_display.assert_called_once() + + @patch(f"{MOD}._no_printer_found") + @patch(f"{MOD}._discover_network_printer", return_value="") + @patch(f"{MOD}.find_brother_usb", return_value="") + @patch(f"{MOD}.os.geteuid", return_value=0) + def test_nothing_found( + self, + g: MagicMock, + f: MagicMock, + d: MagicMock, + mock_no: MagicMock, + ) -> None: + main([]) + mock_no.assert_called_once() + + @patch(f"{MOD}._no_printer_found") + @patch(f"{MOD}.shutil.which", return_value=None) + @patch(f"{MOD}._discover_network_printer", return_value="192.168.1.100") + @patch(f"{MOD}.find_brother_usb", return_value="") + @patch(f"{MOD}.os.geteuid", return_value=0) + def test_network_discovered_no_snmpwalk( + self, + g: MagicMock, + f: MagicMock, + d: MagicMock, + w: MagicMock, + mock_no: MagicMock, + ) -> None: + main([]) + mock_no.assert_called_once() + + def test_default_argv(self) -> None: + with ( + patch(f"{MOD}.sys.argv", ["prog", "--reset-toner"]), + patch(f"{MOD}.reset_consumable") as mock_reset, + ): + main() + mock_reset.assert_called_once_with("toner") + + @patch(f"{MOD}.os.geteuid", return_value=1000) + def test_not_root_with_args(self, g: MagicMock) -> None: + with ( + patch("sys.stdout", new_callable=StringIO), + pytest.raises(SystemExit), + ): + main(["1.2.3.4"]) + + +class TestResetConsumableFromPrinter: + """Resetting a baseline must use the printer's own counter, or refuse. + + Baselines live on the printer's lifetime-counter scale. Recording one from + any other source skews every later estimate, so a printer that cannot be + read is a hard error rather than a silent fallback. + """ + + @patch(f"{MOD}.reset_consumable") + @patch(f"{MOD}.query_usb_pjl") + def test_uses_printer_page_count( + self, + mock_query: MagicMock, + mock_reset: MagicMock, + ) -> None: + mock_query.return_value = USBResult(page_count="2017") + _reset_consumable_from_printer("toner") + mock_reset.assert_called_once_with("toner", 2017) + + @patch(f"{MOD}._out") + @patch(f"{MOD}.reset_consumable") + @patch(f"{MOD}.query_usb_pjl") + def test_refuses_without_page_count( + self, + mock_query: MagicMock, + mock_reset: MagicMock, + mock_out: MagicMock, + ) -> None: + mock_query.return_value = USBResult(page_count="") + with pytest.raises(SystemExit): + _reset_consumable_from_printer("toner") + mock_reset.assert_not_called() + + @patch(f"{MOD}._out") + @patch(f"{MOD}.reset_consumable") + @patch(f"{MOD}.query_usb_pjl") + def test_reports_underlying_error( + self, + mock_query: MagicMock, + mock_reset: MagicMock, + mock_out: MagicMock, + ) -> None: + mock_query.return_value = USBResult(page_count="", error="device busy") + with pytest.raises(SystemExit): + _reset_consumable_from_printer("drum") + printed = " ".join(str(c) for c in mock_out.call_args_list) + assert "device busy" in printed + + @patch(f"{MOD}._out") + @patch(f"{MOD}.reset_consumable") + @patch(f"{MOD}.query_usb_pjl") + def test_non_numeric_page_count_refused( + self, + mock_query: MagicMock, + mock_reset: MagicMock, + mock_out: MagicMock, + ) -> None: + mock_query.return_value = USBResult(page_count="???") + with pytest.raises(SystemExit): + _reset_consumable_from_printer("toner") + mock_reset.assert_not_called() diff --git a/python_pkg/brother_printer/tests/test_consumable_estimate.py b/python_pkg/brother_printer/tests/test_consumable_estimate.py new file mode 100644 index 00000000..c6cdfb9e --- /dev/null +++ b/python_pkg/brother_printer/tests/test_consumable_estimate.py @@ -0,0 +1,167 @@ +"""Tests for brother_printer.consumables - the toner and drum life estimate. + +Split from test_consumables.py under the 250-line cap. These cover which +counter the estimate is based on and how replacement resets rebase it. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from python_pkg.brother_printer.consumables import ( + STATE_SCHEMA_CUPS_SCALE, + STATE_SCHEMA_PRINTER_SCALE, + estimate_consumable_life, + reset_consumable, +) + +MOD = "python_pkg.brother_printer.consumables" + + +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 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 diff --git a/python_pkg/brother_printer/tests/test_consumables.py b/python_pkg/brother_printer/tests/test_consumables.py index ea79b44f..f13b3277 100644 --- a/python_pkg/brother_printer/tests/test_consumables.py +++ b/python_pkg/brother_printer/tests/test_consumables.py @@ -1,4 +1,4 @@ -"""Tests for brother_printer.consumables - state, estimates, resets.""" +"""Tests for brother_printer.consumables - the state file and page log.""" from __future__ import annotations @@ -7,11 +7,9 @@ from unittest.mock import MagicMock, patch from python_pkg.brother_printer.consumables import ( STATE_SCHEMA_CUPS_SCALE, - STATE_SCHEMA_PRINTER_SCALE, _get_cups_total_pages, _load_consumable_state, _save_consumable_state, - estimate_consumable_life, reset_consumable, ) @@ -121,152 +119,3 @@ class TestResetConsumable: reset_consumable("toner") saved_state = mock_save.call_args[0][0] assert saved_state["toner_replaced_at"] == 500 - - -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 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 diff --git a/python_pkg/brother_printer/tests/test_consumables_part2.py b/python_pkg/brother_printer/tests/test_consumables_part2.py deleted file mode 100644 index 70e9e1e8..00000000 --- a/python_pkg/brother_printer/tests/test_consumables_part2.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Tests for brother_printer.consumables - counter scales and dropped pages. - -The printer's counter and the CUPS page log measure different things. These -tests pin the rebasing between them and the dropped-page detection that the -gap makes possible. -""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -from python_pkg.brother_printer._page_delivery import ( - _snapshot_counters, - check_page_delivery, -) -from python_pkg.brother_printer.consumables import ( - STATE_SCHEMA_CUPS_SCALE, - STATE_SCHEMA_PRINTER_SCALE, - _cups_total_on_printer_scale, - _migrate_state_to_printer_scale, -) - -MOD = "python_pkg.brother_printer.consumables" -# check_page_delivery and _snapshot_counters moved to _page_delivery, so the -# state-file helpers they call resolve through that module's globals instead. -MOD_PD = "python_pkg.brother_printer._page_delivery" - - -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 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_PD}._snapshot_counters") - @patch(f"{MOD_PD}._get_cups_total_pages", return_value=1841) - @patch(f"{MOD_PD}._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_PD}._snapshot_counters") - @patch(f"{MOD_PD}._get_cups_total_pages", return_value=1841) - @patch(f"{MOD_PD}._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_PD}._snapshot_counters") - @patch(f"{MOD_PD}._get_cups_total_pages", return_value=1780) - @patch(f"{MOD_PD}._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_PD}._snapshot_counters") - @patch(f"{MOD_PD}._get_cups_total_pages", return_value=1841) - @patch(f"{MOD_PD}._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_PD}._snapshot_counters") - @patch(f"{MOD_PD}._get_cups_total_pages", return_value=10) - @patch(f"{MOD_PD}._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_PD}._snapshot_counters") - @patch(f"{MOD_PD}._get_cups_total_pages", return_value=1841) - @patch(f"{MOD_PD}._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_PD}._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_PD}._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 diff --git a/python_pkg/brother_printer/tests/test_counter_scales.py b/python_pkg/brother_printer/tests/test_counter_scales.py new file mode 100644 index 00000000..d46ffd42 --- /dev/null +++ b/python_pkg/brother_printer/tests/test_counter_scales.py @@ -0,0 +1,131 @@ +"""Tests for brother_printer.consumables - rebasing the counter scales. + +The printer's counter and the CUPS page log measure different things. These +tests pin the rebasing between them; the dropped-page detection that the gap +makes possible is covered in test_page_delivery.py. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from python_pkg.brother_printer.consumables import ( + STATE_SCHEMA_CUPS_SCALE, + STATE_SCHEMA_PRINTER_SCALE, + _cups_total_on_printer_scale, + _migrate_state_to_printer_scale, +) + +MOD = "python_pkg.brother_printer.consumables" + + +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 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 diff --git a/python_pkg/brother_printer/tests/test_page_delivery.py b/python_pkg/brother_printer/tests/test_page_delivery.py new file mode 100644 index 00000000..fd3cb036 --- /dev/null +++ b/python_pkg/brother_printer/tests/test_page_delivery.py @@ -0,0 +1,152 @@ +"""Tests for brother_printer._page_delivery - dropped-page detection. + +Split from test_consumables_part2.py under the 250-line cap. These are exactly +the tests whose functions moved to _page_delivery, so the file needs only that +module's patch target rather than the two constants the combined file carried. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from python_pkg.brother_printer._page_delivery import ( + _snapshot_counters, + check_page_delivery, +) + +MOD = "python_pkg.brother_printer._page_delivery" + + +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()