testsAndMisc/python_pkg/brother_printer/tests/test_stuck_job.py
Krzysztof kuhy Rudnicki c4d5631054
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
fix(brother_printer): truthful probe note, pyusb auto-install, stuck-job detection
The CUPS-fallback note blamed a missing pyusb when the probe was really
skipped because CUPS owned the printer. It now names the real reason.
run.sh installs python-pyusb for the system python3 that sudo runs.

A paper misfeed blocks the usb:// backend while CUPS keeps saying
"processing", and cancel leaves that backend hung so the queue never
recovers. The report now flags a job whose page counter is frozen for
15 s, and a backend of a cancelled job, with a y/N offer to SIGKILL it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaBRWtXXWCs3K3yPHSruUC
2026-10-12 16:23:48 +02:00

197 lines
7.6 KiB
Python

"""Tests for brother_printer._stuck_job - stalled jobs and orphaned backends.
/proc, ipptool, lpstat, time and os.kill are all patched: nothing here reads
the live process table, talks to CUPS, sleeps, or signals a real process.
"""
from __future__ import annotations
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.brother_printer import _stuck_job
from python_pkg.brother_printer._stuck_job import (
StuckJob,
detect_stuck_job,
find_backend_pids,
flag_stuck_job,
kill_backend,
offer_backend_kill,
)
from python_pkg.brother_printer.constants import (
DERIVED_BACKEND_ORPHANED,
DERIVED_JOB_STALLED,
)
from python_pkg.brother_printer.data_classes import USBResult
if TYPE_CHECKING:
import pytest
MOD = "python_pkg.brother_printer._stuck_job"
def _proc(tmp_path: Path, pid: str, cmdline: bytes | None) -> None:
"""Create a fake /proc/<pid>; cmdline None means it is unreadable."""
entry = tmp_path / pid
entry.mkdir()
if cmdline is not None:
(entry / "cmdline").write_bytes(cmdline)
class TestFindBackendPids:
def test_maps_job_id_to_pid(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_proc(tmp_path, "101", b"usb://Brother/HL-1110\x00105\x00kuhy\x00")
_proc(tmp_path, "102", b"/usr/bin/gs\x00-dQUIET\x00")
_proc(tmp_path, "103", b"usb://Brother/HL-1110\x00") # no job argument
_proc(tmp_path, "104", None) # exited mid-scan
(tmp_path / "self").mkdir() # non-numeric entry
monkeypatch.setattr(_stuck_job, "PROC_DIR", tmp_path)
assert find_backend_pids() == {"105": 101}
class TestJobImpressions:
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_ipptool(self, w: MagicMock) -> None:
assert _stuck_job._job_impressions("105") is None
@patch(f"{MOD}.run_command_text")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/ipptool")
def test_parses_counter(self, w: MagicMock, run: MagicMock) -> None:
run.return_value = " job-impressions-completed (integer) = 7\n"
assert _stuck_job._job_impressions("105") == 7
assert "ipp://localhost/jobs/105" in run.call_args.args[0]
@patch(f"{MOD}.run_command_text", return_value="")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/ipptool")
def test_counter_absent(self, w: MagicMock, r: MagicMock) -> None:
assert _stuck_job._job_impressions("105") is None
class TestJobIsStalled:
@patch(f"{MOD}._job_impressions", return_value=None)
def test_unknown_counter_is_not_stalled(self, i: MagicMock) -> None:
assert _stuck_job._job_is_stalled("105") is False
@patch(f"{MOD}.time")
@patch(f"{MOD}._job_impressions", side_effect=[7, 7, 8])
def test_progress_clears_it(self, i: MagicMock, t: MagicMock) -> None:
t.monotonic.side_effect = [0.0, 0.0, 1.0]
assert _stuck_job._job_is_stalled("105") is False
@patch(f"{MOD}.time")
@patch(f"{MOD}._job_impressions", return_value=7)
def test_frozen_counter_is_stalled(self, i: MagicMock, t: MagicMock) -> None:
t.monotonic.side_effect = [0.0, 0.0, 10.0, 15.0]
assert _stuck_job._job_is_stalled("105") is True
assert t.sleep.call_count == 2
class TestDetectStuckJob:
@patch(f"{MOD}._job_is_stalled")
@patch(f"{MOD}.find_backend_pids", return_value={"104": 820758})
def test_orphaned_backend(self, f: MagicMock, stalled: MagicMock) -> None:
assert detect_stuck_job([]) == StuckJob("orphaned", "104", 820758)
stalled.assert_not_called()
@patch(f"{MOD}._job_is_stalled", return_value=True)
@patch(f"{MOD}.find_backend_pids", return_value={"105": 884180})
def test_stalled_job(self, f: MagicMock, s: MagicMock) -> None:
assert detect_stuck_job(["105"]) == StuckJob("stalled", "105", 884180)
@patch(f"{MOD}._job_is_stalled", return_value=True)
@patch(f"{MOD}.find_backend_pids", return_value={})
def test_stalled_before_backend_starts(self, f: MagicMock, s: MagicMock) -> None:
assert detect_stuck_job(["105"]) == StuckJob("stalled", "105", 0)
@patch(f"{MOD}._job_is_stalled", return_value=False)
@patch(f"{MOD}.find_backend_pids", return_value={"105": 884180})
def test_healthy_job(self, f: MagicMock, s: MagicMock) -> None:
assert detect_stuck_job(["105"]) is None
@patch(f"{MOD}.find_backend_pids", return_value={})
def test_nothing_running(self, f: MagicMock) -> None:
assert detect_stuck_job([]) is None
class TestKillBackend:
@patch(f"{MOD}.os.kill")
def test_sigkill(self, kill: MagicMock) -> None:
assert kill_backend(42) is True
kill.assert_called_once_with(42, _stuck_job.signal.SIGKILL)
@patch(f"{MOD}.os.kill", side_effect=ProcessLookupError)
def test_already_gone(self, k: MagicMock) -> None:
assert kill_backend(42) is False
class TestOfferBackendKill:
def _offer(self, answer: str, *, killed: bool = True) -> tuple[str, MagicMock]:
with (
patch(f"{MOD}._prompt", return_value=answer),
patch(f"{MOD}.kill_backend", return_value=killed) as kill,
patch("sys.stdout", new_callable=StringIO) as out,
):
offer_backend_kill(42)
return out.getvalue(), kill
def test_declined(self) -> None:
out, kill = self._offer("")
kill.assert_not_called()
assert "No changes made" in out
def test_accepted(self) -> None:
out, kill = self._offer(" Y ")
kill.assert_called_once_with(42)
assert "Backend killed" in out
def test_kill_fails(self) -> None:
out, _kill = self._offer("y", killed=False)
assert "Could not kill PID 42" in out
class TestActiveJobIds:
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_lpstat(self, w: MagicMock) -> None:
assert _stuck_job._active_job_ids("Brother") == []
@patch(f"{MOD}.run_command_text")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_parses_ids(self, w: MagicMock, run: MagicMock) -> None:
run.return_value = (
"Brother-105 kuhy 160768 Mon 12 Oct 2026\nOther-7 kuhy 1 Mon 12 Oct 2026\n"
)
assert _stuck_job._active_job_ids("Brother") == ["105"]
class TestFlagStuckJob:
@patch(f"{MOD}._active_job_ids", return_value=[])
@patch(f"{MOD}.detect_stuck_job", return_value=None)
def test_nothing_stuck(self, d: MagicMock, a: MagicMock) -> None:
result = USBResult(device="cups")
assert flag_stuck_job(result, "Brother") is False
assert result.status_code == ""
@patch(f"{MOD}._active_job_ids", return_value=[])
@patch(f"{MOD}.detect_stuck_job")
def test_orphaned(self, detect: MagicMock, a: MagicMock) -> None:
detect.return_value = StuckJob("orphaned", "104", 820758)
result = USBResult(device="cups")
assert flag_stuck_job(result, "Brother") is True
assert result.status_code == DERIVED_BACKEND_ORPHANED
assert result.cups.orphaned_backend_pid == 820758
assert "cancelled job 104" in result.display
@patch(f"{MOD}._active_job_ids", return_value=["105"])
@patch(f"{MOD}.detect_stuck_job")
def test_stalled(self, detect: MagicMock, a: MagicMock) -> None:
detect.return_value = StuckJob("stalled", "105", 884180)
result = USBResult(device="cups")
assert flag_stuck_job(result, "Brother") is True
assert result.status_code == DERIVED_JOB_STALLED
assert result.cups.orphaned_backend_pid == 0
assert "job 105" in result.display