From 65bc65c7e3d7c4c13c5db00bee790142e8bbc385 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 16 Aug 2026 17:15:55 +0200 Subject: [PATCH] Split the brother_printer cups_queue test modules under the cap test_cups_queue.py 458 -> 167, partitioned at class boundaries into _actions (the four shell-out primitives) and _backend (health check and error-log scanning). test_cups_queue_part2.py 278 -> 131, with the four _handle_* dispatchers moving to _handlers. No MOD changed: unlike the source splits, every patched name already lives in the module each file names, so this is a pure partition. The two part-numbered survivors are renamed for what they cover. Pass count is 402 before and after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- ...nter-test-cups-queue-split-2026-08-16.json | 19 ++ ...nter-test-cups-queue-split-2026-08-16.json | 43 +++ .../brother_printer/tests/test_cups_queue.py | 291 ------------------ .../tests/test_cups_queue_actions.py | 157 ++++++++++ .../tests/test_cups_queue_backend.py | 164 ++++++++++ ...ue_part3.py => test_cups_queue_display.py} | 2 +- ...e_part2.py => test_cups_queue_handlers.py} | 129 +------- .../tests/test_cups_queue_menu.py | 131 ++++++++ 8 files changed, 521 insertions(+), 415 deletions(-) create mode 100644 docs/superpowers/contracts/brother-printer-test-cups-queue-split-2026-08-16.json create mode 100644 docs/superpowers/evidence/brother-printer-test-cups-queue-split-2026-08-16.json create mode 100644 python_pkg/brother_printer/tests/test_cups_queue_actions.py create mode 100644 python_pkg/brother_printer/tests/test_cups_queue_backend.py rename python_pkg/brother_printer/tests/{test_cups_queue_part3.py => test_cups_queue_display.py} (96%) rename python_pkg/brother_printer/tests/{test_cups_queue_part2.py => test_cups_queue_handlers.py} (56%) create mode 100644 python_pkg/brother_printer/tests/test_cups_queue_menu.py diff --git a/docs/superpowers/contracts/brother-printer-test-cups-queue-split-2026-08-16.json b/docs/superpowers/contracts/brother-printer-test-cups-queue-split-2026-08-16.json new file mode 100644 index 00000000..2999ba5d --- /dev/null +++ b/docs/superpowers/contracts/brother-printer-test-cups-queue-split-2026-08-16.json @@ -0,0 +1,19 @@ +{ + "title": "Split the brother_printer cups_queue test modules under the 250-line cap", + "objective": "Bring test_cups_queue.py (458 lines) and test_cups_queue_part2.py (278 lines) under the repo-wide 250-line cap by partitioning them along the class boundaries the source split already established, without losing or weakening a single test.", + "acceptance_criteria": [ + "All six cups_queue test modules are at most 250 lines.", + "The brother_printer suite still reports exactly 402 passing tests -- the same count as before the split, proving no test was dropped or silently skipped.", + "The package keeps 100% branch coverage of the brother_printer source modules.", + "No MOD constant changes: every name these tests patch already lives in the module the file names, so the split is a pure partition of test classes.", + "No assertion is changed and no test is deleted, skipped or weakened; whole test classes move verbatim and only the import blocks are adjusted to what each file actually uses.", + "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.", + "Splitting the remaining over-cap test modules (test_cups_service.py, test_display.py, test_usb_query.py and the consumables pair), which are separate commits.", + "Introducing a shared conftest.py: these classes need no shared fixture builders, and an unnecessary one would push jscpd toward its duplication threshold." + ], + "verifier": "PYTHONPATH=. python3 -m pytest python_pkg/brother_printer/tests -q (expects exactly 402 passed), plus pre-commit run --files on the touched files, plus bash ~/utils/scripts/check_file_length.sh --all for the cap." +} diff --git a/docs/superpowers/evidence/brother-printer-test-cups-queue-split-2026-08-16.json b/docs/superpowers/evidence/brother-printer-test-cups-queue-split-2026-08-16.json new file mode 100644 index 00000000..86c4be62 --- /dev/null +++ b/docs/superpowers/evidence/brother-printer-test-cups-queue-split-2026-08-16.json @@ -0,0 +1,43 @@ +{ + "intent": "Bring the two over-cap cups_queue test modules under the 250-line cap by partitioning them by test class, with no change to what is tested.", + "scope": [ + "python_pkg/brother_printer/tests/test_cups_queue.py (458 -> 167)", + "python_pkg/brother_printer/tests/test_cups_queue_actions.py, test_cups_queue_backend.py (new)", + "python_pkg/brother_printer/tests/test_cups_queue_part2.py -> test_cups_queue_menu.py (278 -> 131)", + "python_pkg/brother_printer/tests/test_cups_queue_handlers.py (new)", + "python_pkg/brother_printer/tests/test_cups_queue_part3.py -> test_cups_queue_display.py (rename only)", + "Non-goal: no assertion changed, no test deleted, skipped or weakened" + ], + "changes": [ + "test_cups_queue.py split three ways at class boundaries: the lpstat parsing and get_cups_queue_status tests stay, the four _cups_* action primitives go to test_cups_queue_actions.py, and the health check plus backend-error log scanning go to test_cups_queue_backend.py.", + "test_cups_queue_part2.py split at TestHandleDisabledWithJobs: the menu and the five _dwj_* choices stay, the four _handle_* dispatchers go to test_cups_queue_handlers.py.", + "No MOD constant changed anywhere in this commit. Unlike the source splits, every patched name already lives in the module each file names, so this is a pure partition of test classes.", + "Import blocks trimmed to what each file uses: the original lost eight now-unmoved names and StringIO, the backend file needed subprocess added for its TimeoutExpired cases, and the handlers file needed CUPSJob. test_cups_queue_part2.py dropped its four _handle_* imports but keeps patching them by string, since _offer_queue_fix dispatches to them.", + "Renamed the two part-numbered survivors to test_cups_queue_menu.py and test_cups_queue_display.py so the file name says which concern it covers." + ], + "verification": [ + { + "command": "PYTHONPATH=. python3 -m pytest python_pkg/brother_printer/tests -q", + "result": "pass", + "evidence": "402 passed before the split and 402 passed after it - identical pass count. This is the check that matters here: a partition that dropped a whole class would still report a green run." + }, + { + "command": "wc -l python_pkg/brother_printer/tests/test_cups_queue*.py", + "result": "pass", + "evidence": "167, 157, 164, 161, 131 and 76 lines - all six modules under the 250-line cap." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "ruff, ruff format, mypy, pylint, bandit, name-tests-test and pytest-with-coverage all pass." + } + ], + "risks": [ + "Trimming import blocks by hand can remove a name that is only reachable at runtime. Guarded by the pass count plus ruff's F401 sweep, and by checking whether each dropped name was still patched by string rather than imported.", + "No shared conftest.py was introduced; if a later split of the remaining test modules needs the same fixture scaffolding, it belongs in tests/conftest.py rather than a fourth copy, to keep jscpd under its 2% duplication threshold." + ], + "rollback": [ + "git revert the commit; the two originals return at 458 and 278 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_cups_queue.py b/python_pkg/brother_printer/tests/test_cups_queue.py index ba975d80..2b61611c 100644 --- a/python_pkg/brother_printer/tests/test_cups_queue.py +++ b/python_pkg/brother_printer/tests/test_cups_queue.py @@ -2,18 +2,10 @@ from __future__ import annotations -from io import StringIO import subprocess from unittest.mock import MagicMock, patch from python_pkg.brother_printer.cups_queue import ( - _check_cups_backend_errors, - _cups_cancel_all_jobs, - _cups_cancel_job, - _cups_enable_printer, - _cups_restart_service, - _find_backend_error_in_log, - _is_cups_printer_healthy, _parse_lpstat_jobs, _parse_lpstat_printer_line, get_cups_queue_status, @@ -173,286 +165,3 @@ class TestGetCupsQueueStatus: ] result = get_cups_queue_status() assert result.enabled is True # default unchanged - - -class TestCupsEnablePrinter: - @patch(f"{MOD}.shutil.which", return_value=None) - def test_no_cupsenable(self, m: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - assert _cups_enable_printer("B") is False - - @patch(f"{MOD}.subprocess.run") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cupsenable") - def test_success(self, w: MagicMock, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock() - assert _cups_enable_printer("B") is True - - @patch(f"{MOD}.subprocess.run") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cupsenable") - def test_timeout(self, w: MagicMock, mock_run: MagicMock) -> None: - mock_run.side_effect = subprocess.TimeoutExpired("cupsenable", 5) - with patch("sys.stdout", new_callable=StringIO): - assert _cups_enable_printer("B") is False - - @patch(f"{MOD}.subprocess.run") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cupsenable") - def test_oserror(self, w: MagicMock, mock_run: MagicMock) -> None: - mock_run.side_effect = OSError("fail") - with patch("sys.stdout", new_callable=StringIO): - assert _cups_enable_printer("B") is False - - -class TestCupsCancelAllJobs: - @patch(f"{MOD}.shutil.which", return_value=None) - def test_no_cancel(self, m: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - assert _cups_cancel_all_jobs("B") is False - - @patch(f"{MOD}.subprocess.run") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cancel") - def test_success(self, w: MagicMock, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock() - assert _cups_cancel_all_jobs("B") is True - - @patch(f"{MOD}.subprocess.run") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cancel") - def test_error(self, w: MagicMock, mock_run: MagicMock) -> None: - mock_run.side_effect = subprocess.CalledProcessError(1, "cancel") - with patch("sys.stdout", new_callable=StringIO): - assert _cups_cancel_all_jobs("B") is False - - -class TestCupsCancelJob: - @patch(f"{MOD}.shutil.which", return_value=None) - def test_no_cancel(self, m: MagicMock) -> None: - assert _cups_cancel_job("job-1") is False - - @patch(f"{MOD}.subprocess.run") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cancel") - def test_success(self, w: MagicMock, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock() - assert _cups_cancel_job("job-1") is True - - @patch(f"{MOD}.subprocess.run") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cancel") - def test_error(self, w: MagicMock, mock_run: MagicMock) -> None: - mock_run.side_effect = subprocess.CalledProcessError(1, "cancel") - assert _cups_cancel_job("job-1") is False - - -class TestCupsRestartService: - @patch(f"{MOD}.shutil.which", return_value=None) - def test_no_systemctl(self, m: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - assert _cups_restart_service() is False - - @patch(f"{MOD}.time.sleep") - @patch(f"{MOD}.time.time") - @patch(f"{MOD}.subprocess.Popen") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl") - def test_success( - self, - w: MagicMock, - mock_popen: MagicMock, - mock_time: MagicMock, - s: MagicMock, - ) -> None: - proc = MagicMock() - proc.poll.side_effect = [None, 0] - proc.returncode = 0 - mock_popen.return_value = proc - mock_time.side_effect = [0.0, 1.0, 2.0] - with patch("sys.stdout", new_callable=StringIO): - assert _cups_restart_service() is True - - @patch(f"{MOD}.time.sleep") - @patch(f"{MOD}.time.time") - @patch(f"{MOD}.subprocess.Popen") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl") - def test_timeout( - self, - w: MagicMock, - mock_popen: MagicMock, - mock_time: MagicMock, - s: MagicMock, - ) -> None: - proc = MagicMock() - proc.poll.return_value = None - mock_popen.return_value = proc - mock_time.side_effect = [0.0, 31.0] - with patch("sys.stdout", new_callable=StringIO): - assert _cups_restart_service() is False - proc.kill.assert_called_once() - - @patch(f"{MOD}.time.sleep") - @patch(f"{MOD}.time.time") - @patch(f"{MOD}.subprocess.Popen") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl") - def test_nonzero_exit( - self, - w: MagicMock, - mock_popen: MagicMock, - mock_time: MagicMock, - s: MagicMock, - ) -> None: - proc = MagicMock() - proc.poll.side_effect = [None, 1] - proc.returncode = 1 - mock_popen.return_value = proc - mock_time.side_effect = [0.0, 1.0, 2.0] - with patch("sys.stdout", new_callable=StringIO): - assert _cups_restart_service() is False - - @patch(f"{MOD}.subprocess.Popen") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl") - def test_oserror(self, w: MagicMock, mock_popen: MagicMock) -> None: - mock_popen.side_effect = OSError("fail") - with patch("sys.stdout", new_callable=StringIO): - assert _cups_restart_service() is False - - -class TestIsCupsPrinterHealthy: - @patch(f"{MOD}.shutil.which", return_value=None) - def test_no_lpstat(self, m: MagicMock) -> None: - assert _is_cups_printer_healthy("B") is False - - @patch(f"{MOD}.subprocess.run") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") - def test_healthy(self, w: MagicMock, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock( - stdout="printer BrotherHL1110 is idle. enabled since Mon\n", - ) - assert _is_cups_printer_healthy("BrotherHL1110") is True - - @patch(f"{MOD}.subprocess.run") - @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") - def test_not_healthy(self, w: MagicMock, mock_run: MagicMock) -> None: - mock_run.return_value = MagicMock( - stdout="printer BrotherHL1110 disabled\n", - ) - assert _is_cups_printer_healthy("BrotherHL1110") is False - - @patch(f"{MOD}.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 _is_cups_printer_healthy("B") is False - - -class TestFindBackendErrorInLog: - def test_no_errors(self) -> None: - lines = ["[2025-01-01] Completed job\n"] - err, _, _ = _find_backend_error_in_log(lines) - assert err == "" - - def test_backend_error(self) -> None: - lines = [ - "[2025-01-01] Completed job", - "[2025-01-02] backend errors for BrotherHL1110", - ] - err, ts, success_ts = _find_backend_error_in_log(lines) - assert "backend errors" in err - assert ts == "2025-01-02" - assert success_ts == "2025-01-01" - - def test_stopped_with_status(self) -> None: - lines = [ - "[2025-01-02] stopped with status 1", - ] - err, ts, _ = _find_backend_error_in_log(lines) - assert "stopped with status" in err - assert ts == "2025-01-02" - - def test_error_no_timestamp(self) -> None: - lines = ["backend errors no timestamp here"] - err, ts, _ = _find_backend_error_in_log(lines) - assert "backend errors" in err - assert ts == "" - - def test_completed_with_total(self) -> None: - lines = [ - "[2025-01-01] page total 10", - "[2025-01-02] backend errors", - ] - _, _, success_ts = _find_backend_error_in_log(lines) - assert success_ts == "2025-01-01" - - def test_no_success_after_error(self) -> None: - lines = [ - "[2025-01-02] backend errors", - ] - _, _, success_ts = _find_backend_error_in_log(lines) - assert success_ts == "" - - def test_completed_no_timestamp(self) -> None: - lines = [ - "Completed job", - "[2025-01-02] backend errors", - ] - _, _, success_ts = _find_backend_error_in_log(lines) - assert success_ts == "" - - -class TestCheckCupsBackendErrors: - @patch(f"{MOD}._is_cups_printer_healthy", return_value=True) - def test_healthy_printer(self, m: MagicMock) -> None: - has_errors, _ = _check_cups_backend_errors("B") - assert has_errors is False - - @patch(f"{MOD}._find_backend_error_in_log", return_value=("", "", "")) - @patch(f"{MOD}._is_cups_printer_healthy", return_value=False) - def test_no_log_file(self, h: MagicMock, f: MagicMock) -> None: - with patch(f"{MOD}.Path") as mock_path: - mock_log = MagicMock() - mock_log.exists.return_value = False - mock_path.return_value = mock_log - has_errors, _ = _check_cups_backend_errors("B") - assert has_errors is False - - @patch( - f"{MOD}._find_backend_error_in_log", return_value=("error", "2025-01-02", "") - ) - @patch(f"{MOD}._is_cups_printer_healthy", return_value=False) - def test_has_errors(self, h: MagicMock, f: MagicMock) -> None: - with patch(f"{MOD}.Path") as mock_path: - mock_log = MagicMock() - mock_log.exists.return_value = True - mock_log.read_text.return_value = "log content" - mock_path.return_value = mock_log - has_errors, _ = _check_cups_backend_errors("B") - assert has_errors is True - - @patch( - f"{MOD}._find_backend_error_in_log", - return_value=("error", "2025-01-01", "2025-01-02"), - ) - @patch(f"{MOD}._is_cups_printer_healthy", return_value=False) - def test_success_after_error(self, h: MagicMock, f: MagicMock) -> None: - with patch(f"{MOD}.Path") as mock_path: - mock_log = MagicMock() - mock_log.exists.return_value = True - mock_log.read_text.return_value = "log content" - mock_path.return_value = mock_log - has_errors, _ = _check_cups_backend_errors("B") - assert has_errors is False - - @patch(f"{MOD}._is_cups_printer_healthy", return_value=False) - def test_oserror_reading_log(self, h: MagicMock) -> None: - with patch(f"{MOD}.Path") as mock_path: - mock_log = MagicMock() - mock_log.exists.return_value = True - mock_log.read_text.side_effect = OSError("fail") - mock_path.return_value = mock_log - has_errors, _ = _check_cups_backend_errors("B") - assert has_errors is False - - @patch(f"{MOD}._find_backend_error_in_log", return_value=("", "", "")) - @patch(f"{MOD}._is_cups_printer_healthy", return_value=False) - def test_no_backend_error_in_log(self, h: MagicMock, f: MagicMock) -> None: - with patch(f"{MOD}.Path") as mock_path: - mock_log = MagicMock() - mock_log.exists.return_value = True - mock_log.read_text.return_value = "clean log" - mock_path.return_value = mock_log - has_errors, _ = _check_cups_backend_errors("B") - assert has_errors is False diff --git a/python_pkg/brother_printer/tests/test_cups_queue_actions.py b/python_pkg/brother_printer/tests/test_cups_queue_actions.py new file mode 100644 index 00000000..3ab44b2e --- /dev/null +++ b/python_pkg/brother_printer/tests/test_cups_queue_actions.py @@ -0,0 +1,157 @@ +"""Tests for brother_printer.cups_queue - the CUPS fix action primitives. + +Split from test_cups_queue.py under the 250-line cap. These are the four +functions that shell out to cupsenable, cancel and systemctl, so every one of +them patches shutil.which and subprocess on the module under test. +""" + +from __future__ import annotations + +from io import StringIO +import subprocess +from unittest.mock import MagicMock, patch + +from python_pkg.brother_printer.cups_queue import ( + _cups_cancel_all_jobs, + _cups_cancel_job, + _cups_enable_printer, + _cups_restart_service, +) + +MOD = "python_pkg.brother_printer.cups_queue" + + +class TestCupsEnablePrinter: + @patch(f"{MOD}.shutil.which", return_value=None) + def test_no_cupsenable(self, m: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + assert _cups_enable_printer("B") is False + + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cupsenable") + def test_success(self, w: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock() + assert _cups_enable_printer("B") is True + + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cupsenable") + def test_timeout(self, w: MagicMock, mock_run: MagicMock) -> None: + mock_run.side_effect = subprocess.TimeoutExpired("cupsenable", 5) + with patch("sys.stdout", new_callable=StringIO): + assert _cups_enable_printer("B") is False + + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cupsenable") + def test_oserror(self, w: MagicMock, mock_run: MagicMock) -> None: + mock_run.side_effect = OSError("fail") + with patch("sys.stdout", new_callable=StringIO): + assert _cups_enable_printer("B") is False + + +class TestCupsCancelAllJobs: + @patch(f"{MOD}.shutil.which", return_value=None) + def test_no_cancel(self, m: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + assert _cups_cancel_all_jobs("B") is False + + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cancel") + def test_success(self, w: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock() + assert _cups_cancel_all_jobs("B") is True + + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cancel") + def test_error(self, w: MagicMock, mock_run: MagicMock) -> None: + mock_run.side_effect = subprocess.CalledProcessError(1, "cancel") + with patch("sys.stdout", new_callable=StringIO): + assert _cups_cancel_all_jobs("B") is False + + +class TestCupsCancelJob: + @patch(f"{MOD}.shutil.which", return_value=None) + def test_no_cancel(self, m: MagicMock) -> None: + assert _cups_cancel_job("job-1") is False + + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cancel") + def test_success(self, w: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock() + assert _cups_cancel_job("job-1") is True + + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/cancel") + def test_error(self, w: MagicMock, mock_run: MagicMock) -> None: + mock_run.side_effect = subprocess.CalledProcessError(1, "cancel") + assert _cups_cancel_job("job-1") is False + + +class TestCupsRestartService: + @patch(f"{MOD}.shutil.which", return_value=None) + def test_no_systemctl(self, m: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + assert _cups_restart_service() is False + + @patch(f"{MOD}.time.sleep") + @patch(f"{MOD}.time.time") + @patch(f"{MOD}.subprocess.Popen") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl") + def test_success( + self, + w: MagicMock, + mock_popen: MagicMock, + mock_time: MagicMock, + s: MagicMock, + ) -> None: + proc = MagicMock() + proc.poll.side_effect = [None, 0] + proc.returncode = 0 + mock_popen.return_value = proc + mock_time.side_effect = [0.0, 1.0, 2.0] + with patch("sys.stdout", new_callable=StringIO): + assert _cups_restart_service() is True + + @patch(f"{MOD}.time.sleep") + @patch(f"{MOD}.time.time") + @patch(f"{MOD}.subprocess.Popen") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl") + def test_timeout( + self, + w: MagicMock, + mock_popen: MagicMock, + mock_time: MagicMock, + s: MagicMock, + ) -> None: + proc = MagicMock() + proc.poll.return_value = None + mock_popen.return_value = proc + mock_time.side_effect = [0.0, 31.0] + with patch("sys.stdout", new_callable=StringIO): + assert _cups_restart_service() is False + proc.kill.assert_called_once() + + @patch(f"{MOD}.time.sleep") + @patch(f"{MOD}.time.time") + @patch(f"{MOD}.subprocess.Popen") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl") + def test_nonzero_exit( + self, + w: MagicMock, + mock_popen: MagicMock, + mock_time: MagicMock, + s: MagicMock, + ) -> None: + proc = MagicMock() + proc.poll.side_effect = [None, 1] + proc.returncode = 1 + mock_popen.return_value = proc + mock_time.side_effect = [0.0, 1.0, 2.0] + with patch("sys.stdout", new_callable=StringIO): + assert _cups_restart_service() is False + + @patch(f"{MOD}.subprocess.Popen") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl") + def test_oserror(self, w: MagicMock, mock_popen: MagicMock) -> None: + mock_popen.side_effect = OSError("fail") + with patch("sys.stdout", new_callable=StringIO): + assert _cups_restart_service() is False diff --git a/python_pkg/brother_printer/tests/test_cups_queue_backend.py b/python_pkg/brother_printer/tests/test_cups_queue_backend.py new file mode 100644 index 00000000..f1f86507 --- /dev/null +++ b/python_pkg/brother_printer/tests/test_cups_queue_backend.py @@ -0,0 +1,164 @@ +"""Tests for brother_printer.cups_queue - stale backend error detection. + +Split from test_cups_queue.py under the 250-line cap. +""" + +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock, patch + +from python_pkg.brother_printer.cups_queue import ( + _check_cups_backend_errors, + _find_backend_error_in_log, + _is_cups_printer_healthy, +) + +MOD = "python_pkg.brother_printer.cups_queue" + + +class TestIsCupsPrinterHealthy: + @patch(f"{MOD}.shutil.which", return_value=None) + def test_no_lpstat(self, m: MagicMock) -> None: + assert _is_cups_printer_healthy("B") is False + + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") + def test_healthy(self, w: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock( + stdout="printer BrotherHL1110 is idle. enabled since Mon\n", + ) + assert _is_cups_printer_healthy("BrotherHL1110") is True + + @patch(f"{MOD}.subprocess.run") + @patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat") + def test_not_healthy(self, w: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock( + stdout="printer BrotherHL1110 disabled\n", + ) + assert _is_cups_printer_healthy("BrotherHL1110") is False + + @patch(f"{MOD}.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 _is_cups_printer_healthy("B") is False + + +class TestFindBackendErrorInLog: + def test_no_errors(self) -> None: + lines = ["[2025-01-01] Completed job\n"] + err, _, _ = _find_backend_error_in_log(lines) + assert err == "" + + def test_backend_error(self) -> None: + lines = [ + "[2025-01-01] Completed job", + "[2025-01-02] backend errors for BrotherHL1110", + ] + err, ts, success_ts = _find_backend_error_in_log(lines) + assert "backend errors" in err + assert ts == "2025-01-02" + assert success_ts == "2025-01-01" + + def test_stopped_with_status(self) -> None: + lines = [ + "[2025-01-02] stopped with status 1", + ] + err, ts, _ = _find_backend_error_in_log(lines) + assert "stopped with status" in err + assert ts == "2025-01-02" + + def test_error_no_timestamp(self) -> None: + lines = ["backend errors no timestamp here"] + err, ts, _ = _find_backend_error_in_log(lines) + assert "backend errors" in err + assert ts == "" + + def test_completed_with_total(self) -> None: + lines = [ + "[2025-01-01] page total 10", + "[2025-01-02] backend errors", + ] + _, _, success_ts = _find_backend_error_in_log(lines) + assert success_ts == "2025-01-01" + + def test_no_success_after_error(self) -> None: + lines = [ + "[2025-01-02] backend errors", + ] + _, _, success_ts = _find_backend_error_in_log(lines) + assert success_ts == "" + + def test_completed_no_timestamp(self) -> None: + lines = [ + "Completed job", + "[2025-01-02] backend errors", + ] + _, _, success_ts = _find_backend_error_in_log(lines) + assert success_ts == "" + + +class TestCheckCupsBackendErrors: + @patch(f"{MOD}._is_cups_printer_healthy", return_value=True) + def test_healthy_printer(self, m: MagicMock) -> None: + has_errors, _ = _check_cups_backend_errors("B") + assert has_errors is False + + @patch(f"{MOD}._find_backend_error_in_log", return_value=("", "", "")) + @patch(f"{MOD}._is_cups_printer_healthy", return_value=False) + def test_no_log_file(self, h: MagicMock, f: MagicMock) -> None: + with patch(f"{MOD}.Path") as mock_path: + mock_log = MagicMock() + mock_log.exists.return_value = False + mock_path.return_value = mock_log + has_errors, _ = _check_cups_backend_errors("B") + assert has_errors is False + + @patch( + f"{MOD}._find_backend_error_in_log", return_value=("error", "2025-01-02", "") + ) + @patch(f"{MOD}._is_cups_printer_healthy", return_value=False) + def test_has_errors(self, h: MagicMock, f: MagicMock) -> None: + with patch(f"{MOD}.Path") as mock_path: + mock_log = MagicMock() + mock_log.exists.return_value = True + mock_log.read_text.return_value = "log content" + mock_path.return_value = mock_log + has_errors, _ = _check_cups_backend_errors("B") + assert has_errors is True + + @patch( + f"{MOD}._find_backend_error_in_log", + return_value=("error", "2025-01-01", "2025-01-02"), + ) + @patch(f"{MOD}._is_cups_printer_healthy", return_value=False) + def test_success_after_error(self, h: MagicMock, f: MagicMock) -> None: + with patch(f"{MOD}.Path") as mock_path: + mock_log = MagicMock() + mock_log.exists.return_value = True + mock_log.read_text.return_value = "log content" + mock_path.return_value = mock_log + has_errors, _ = _check_cups_backend_errors("B") + assert has_errors is False + + @patch(f"{MOD}._is_cups_printer_healthy", return_value=False) + def test_oserror_reading_log(self, h: MagicMock) -> None: + with patch(f"{MOD}.Path") as mock_path: + mock_log = MagicMock() + mock_log.exists.return_value = True + mock_log.read_text.side_effect = OSError("fail") + mock_path.return_value = mock_log + has_errors, _ = _check_cups_backend_errors("B") + assert has_errors is False + + @patch(f"{MOD}._find_backend_error_in_log", return_value=("", "", "")) + @patch(f"{MOD}._is_cups_printer_healthy", return_value=False) + def test_no_backend_error_in_log(self, h: MagicMock, f: MagicMock) -> None: + with patch(f"{MOD}.Path") as mock_path: + mock_log = MagicMock() + mock_log.exists.return_value = True + mock_log.read_text.return_value = "clean log" + mock_path.return_value = mock_log + has_errors, _ = _check_cups_backend_errors("B") + assert has_errors is False diff --git a/python_pkg/brother_printer/tests/test_cups_queue_part3.py b/python_pkg/brother_printer/tests/test_cups_queue_display.py similarity index 96% rename from python_pkg/brother_printer/tests/test_cups_queue_part3.py rename to python_pkg/brother_printer/tests/test_cups_queue_display.py index d3d33ef8..95ff66a2 100644 --- a/python_pkg/brother_printer/tests/test_cups_queue_part3.py +++ b/python_pkg/brother_printer/tests/test_cups_queue_display.py @@ -1,4 +1,4 @@ -"""Tests for brother_printer._queue_fix module - part 3 (display status).""" +"""Tests for brother_printer._queue_fix - the queue status display.""" from __future__ import annotations diff --git a/python_pkg/brother_printer/tests/test_cups_queue_part2.py b/python_pkg/brother_printer/tests/test_cups_queue_handlers.py similarity index 56% rename from python_pkg/brother_printer/tests/test_cups_queue_part2.py rename to python_pkg/brother_printer/tests/test_cups_queue_handlers.py index 3c736a9a..8e485a40 100644 --- a/python_pkg/brother_printer/tests/test_cups_queue_part2.py +++ b/python_pkg/brother_printer/tests/test_cups_queue_handlers.py @@ -1,4 +1,9 @@ -"""Tests for brother_printer._queue_fix module - part 2 (interactive fix).""" +"""Tests for brother_printer._queue_fix - the choice dispatchers. + +Split from test_cups_queue_part2.py under the 250-line cap. Each dispatcher +maps a menu choice onto the CUPS action primitives, which are patched on +_queue_fix because that is the module whose globals they resolve through. +""" from __future__ import annotations @@ -6,138 +11,16 @@ from io import StringIO from unittest.mock import MagicMock, patch from python_pkg.brother_printer._queue_fix import ( - _dwj_cancel_and_enable, - _dwj_cancel_only, - _dwj_enable_only, - _dwj_restart_and_enable, - _dwj_restart_only, _handle_backend_errors_only, _handle_disabled_no_jobs, _handle_disabled_with_jobs, _handle_enabled_with_jobs, - _offer_queue_fix, ) from python_pkg.brother_printer.data_classes import CUPSJob, CUPSQueueStatus MOD = "python_pkg.brother_printer._queue_fix" -# ── _offer_queue_fix ───────────────────────────────────────────────── - - -class TestOfferQueueFix: - """Tests for _offer_queue_fix menu routing.""" - - @patch(f"{MOD}._handle_disabled_with_jobs") - @patch(f"{MOD}._prompt", return_value="1") - def test_disabled_with_jobs(self, p: MagicMock, mock_handler: MagicMock) -> None: - queue = CUPSQueueStatus( - printer_name="B", - enabled=False, - jobs=[CUPSJob("j1", "alice", "1024", "Mon")], - ) - with patch("sys.stdout", new_callable=StringIO): - _offer_queue_fix(queue) - mock_handler.assert_called_once_with(queue, "1") - - @patch(f"{MOD}._handle_disabled_no_jobs") - @patch(f"{MOD}._prompt", return_value="2") - def test_disabled_no_jobs(self, p: MagicMock, mock_handler: MagicMock) -> None: - queue = CUPSQueueStatus(printer_name="B", enabled=False) - with patch("sys.stdout", new_callable=StringIO): - _offer_queue_fix(queue) - mock_handler.assert_called_once_with(queue, "2") - - @patch(f"{MOD}._handle_enabled_with_jobs") - @patch(f"{MOD}._prompt", return_value="1") - def test_enabled_with_jobs(self, p: MagicMock, mock_handler: MagicMock) -> None: - queue = CUPSQueueStatus( - printer_name="B", - enabled=True, - jobs=[CUPSJob("j1", "alice", "1024", "Mon")], - ) - with patch("sys.stdout", new_callable=StringIO): - _offer_queue_fix(queue) - mock_handler.assert_called_once_with(queue, "1") - - @patch(f"{MOD}._handle_backend_errors_only") - @patch(f"{MOD}._prompt", return_value="1") - def test_backend_errors_only(self, p: MagicMock, mock_handler: MagicMock) -> None: - queue = CUPSQueueStatus(printer_name="B", enabled=True) - with patch("sys.stdout", new_callable=StringIO): - _offer_queue_fix(queue) - mock_handler.assert_called_once_with("1") - - -# ── _dwj_* action functions ───────────────────────────────────────── - - -class TestDwjEnableOnly: - @patch(f"{MOD}._cups_enable_printer", return_value=True) - def test_success(self, m: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - _dwj_enable_only("B") - - @patch(f"{MOD}._cups_enable_printer", return_value=False) - def test_failure(self, m: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - _dwj_enable_only("B") - - -class TestDwjCancelAndEnable: - @patch(f"{MOD}._cups_enable_printer", return_value=True) - @patch(f"{MOD}._cups_cancel_all_jobs", return_value=True) - def test_success(self, c: MagicMock, e: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - _dwj_cancel_and_enable("B") - - @patch(f"{MOD}._cups_enable_printer", return_value=False) - @patch(f"{MOD}._cups_cancel_all_jobs", return_value=True) - def test_enable_fails(self, c: MagicMock, e: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - _dwj_cancel_and_enable("B") - - -class TestDwjCancelOnly: - @patch(f"{MOD}._cups_cancel_all_jobs", return_value=True) - def test_success(self, m: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - _dwj_cancel_only("B") - - @patch(f"{MOD}._cups_cancel_all_jobs", return_value=False) - def test_failure(self, m: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - _dwj_cancel_only("B") - - -class TestDwjRestartOnly: - @patch(f"{MOD}._cups_restart_service", return_value=True) - def test_success(self, m: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - _dwj_restart_only("B") - - @patch(f"{MOD}._cups_restart_service", return_value=False) - def test_failure(self, m: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - _dwj_restart_only("B") - - -class TestDwjRestartAndEnable: - @patch(f"{MOD}._cups_enable_printer", return_value=True) - @patch(f"{MOD}._cups_restart_service", return_value=True) - def test_success(self, r: MagicMock, e: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - _dwj_restart_and_enable("B") - - @patch(f"{MOD}._cups_restart_service", return_value=False) - def test_restart_fails(self, r: MagicMock) -> None: - with patch("sys.stdout", new_callable=StringIO): - _dwj_restart_and_enable("B") - - -# ── _handle_disabled_with_jobs ─────────────────────────────────────── - - class TestHandleDisabledWithJobs: """Tests for _handle_disabled_with_jobs dispatch.""" diff --git a/python_pkg/brother_printer/tests/test_cups_queue_menu.py b/python_pkg/brother_printer/tests/test_cups_queue_menu.py new file mode 100644 index 00000000..b80aa461 --- /dev/null +++ b/python_pkg/brother_printer/tests/test_cups_queue_menu.py @@ -0,0 +1,131 @@ +"""Tests for brother_printer._queue_fix - the interactive fix menu.""" + +from __future__ import annotations + +from io import StringIO +from unittest.mock import MagicMock, patch + +from python_pkg.brother_printer._queue_fix import ( + _dwj_cancel_and_enable, + _dwj_cancel_only, + _dwj_enable_only, + _dwj_restart_and_enable, + _dwj_restart_only, + _offer_queue_fix, +) +from python_pkg.brother_printer.data_classes import CUPSJob, CUPSQueueStatus + +MOD = "python_pkg.brother_printer._queue_fix" + + +# ── _offer_queue_fix ───────────────────────────────────────────────── + + +class TestOfferQueueFix: + """Tests for _offer_queue_fix menu routing.""" + + @patch(f"{MOD}._handle_disabled_with_jobs") + @patch(f"{MOD}._prompt", return_value="1") + def test_disabled_with_jobs(self, p: MagicMock, mock_handler: MagicMock) -> None: + queue = CUPSQueueStatus( + printer_name="B", + enabled=False, + jobs=[CUPSJob("j1", "alice", "1024", "Mon")], + ) + with patch("sys.stdout", new_callable=StringIO): + _offer_queue_fix(queue) + mock_handler.assert_called_once_with(queue, "1") + + @patch(f"{MOD}._handle_disabled_no_jobs") + @patch(f"{MOD}._prompt", return_value="2") + def test_disabled_no_jobs(self, p: MagicMock, mock_handler: MagicMock) -> None: + queue = CUPSQueueStatus(printer_name="B", enabled=False) + with patch("sys.stdout", new_callable=StringIO): + _offer_queue_fix(queue) + mock_handler.assert_called_once_with(queue, "2") + + @patch(f"{MOD}._handle_enabled_with_jobs") + @patch(f"{MOD}._prompt", return_value="1") + def test_enabled_with_jobs(self, p: MagicMock, mock_handler: MagicMock) -> None: + queue = CUPSQueueStatus( + printer_name="B", + enabled=True, + jobs=[CUPSJob("j1", "alice", "1024", "Mon")], + ) + with patch("sys.stdout", new_callable=StringIO): + _offer_queue_fix(queue) + mock_handler.assert_called_once_with(queue, "1") + + @patch(f"{MOD}._handle_backend_errors_only") + @patch(f"{MOD}._prompt", return_value="1") + def test_backend_errors_only(self, p: MagicMock, mock_handler: MagicMock) -> None: + queue = CUPSQueueStatus(printer_name="B", enabled=True) + with patch("sys.stdout", new_callable=StringIO): + _offer_queue_fix(queue) + mock_handler.assert_called_once_with("1") + + +# ── _dwj_* action functions ───────────────────────────────────────── + + +class TestDwjEnableOnly: + @patch(f"{MOD}._cups_enable_printer", return_value=True) + def test_success(self, m: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + _dwj_enable_only("B") + + @patch(f"{MOD}._cups_enable_printer", return_value=False) + def test_failure(self, m: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + _dwj_enable_only("B") + + +class TestDwjCancelAndEnable: + @patch(f"{MOD}._cups_enable_printer", return_value=True) + @patch(f"{MOD}._cups_cancel_all_jobs", return_value=True) + def test_success(self, c: MagicMock, e: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + _dwj_cancel_and_enable("B") + + @patch(f"{MOD}._cups_enable_printer", return_value=False) + @patch(f"{MOD}._cups_cancel_all_jobs", return_value=True) + def test_enable_fails(self, c: MagicMock, e: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + _dwj_cancel_and_enable("B") + + +class TestDwjCancelOnly: + @patch(f"{MOD}._cups_cancel_all_jobs", return_value=True) + def test_success(self, m: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + _dwj_cancel_only("B") + + @patch(f"{MOD}._cups_cancel_all_jobs", return_value=False) + def test_failure(self, m: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + _dwj_cancel_only("B") + + +class TestDwjRestartOnly: + @patch(f"{MOD}._cups_restart_service", return_value=True) + def test_success(self, m: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + _dwj_restart_only("B") + + @patch(f"{MOD}._cups_restart_service", return_value=False) + def test_failure(self, m: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + _dwj_restart_only("B") + + +class TestDwjRestartAndEnable: + @patch(f"{MOD}._cups_enable_printer", return_value=True) + @patch(f"{MOD}._cups_restart_service", return_value=True) + def test_success(self, r: MagicMock, e: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + _dwj_restart_and_enable("B") + + @patch(f"{MOD}._cups_restart_service", return_value=False) + def test_restart_fails(self, r: MagicMock) -> None: + with patch("sys.stdout", new_callable=StringIO): + _dwj_restart_and_enable("B")