mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:47:12 +01:00
a12e696 turned CI red two ways, both missed locally because the suite was
run for the brother_printer package alone while CI runs everything in a
random order.
The repo caps Python files at 500 lines and cups_service.py had reached
659. Rather than cut it arbitrarily, extract consumable life tracking -
page counters, replacement baselines, the scale migration, dropped-page
detection - into consumables.py. The seam is real: none of it depends on
CUPS service control, and cups_service needs only estimate_consumable_life
for its fallback query, so the dependency runs one way. The test files are
split along the same seams.
test_main_entry passed or failed depending on execution order: runpy warns
when the target module is already in sys.modules, a sibling test imports
it, and pytest is configured with filterwarnings=error. Drop the module
before running it so the test no longer depends on who ran first.
Structural only; no behaviour change. 692 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RG3eEn8rwZbcXaJrKJxdv8
391 lines
15 KiB
Python
391 lines
15 KiB
Python
"""Tests for brother_printer.cups_service module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from python_pkg.brother_printer.cups_service import (
|
|
_cups_is_busy,
|
|
_ensure_cups_running,
|
|
_get_pyusb_device_info,
|
|
_port_status_via_usblp,
|
|
_query_usb_port_status_raw,
|
|
is_cups_scheduler_running,
|
|
start_cups,
|
|
)
|
|
from python_pkg.brother_printer.data_classes import USBPortStatus
|
|
|
|
MOD = "python_pkg.brother_printer.cups_service"
|
|
|
|
|
|
class TestGetPyusbDeviceInfo:
|
|
def test_found(self) -> None:
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_dev = MagicMock()
|
|
mock_dev.product = "HL-1110"
|
|
mock_dev.serial_number = "SN123"
|
|
mock_usb.core.find.return_value = mock_dev
|
|
with patch.dict(_sys.modules, {"usb": mock_usb, "usb.core": mock_usb.core}):
|
|
result = _get_pyusb_device_info()
|
|
assert result["product"] == "HL-1110"
|
|
assert result["serial"] == "SN123"
|
|
|
|
def test_import_error(self) -> None:
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_usb.core.find.side_effect = ImportError("no usb")
|
|
with patch.dict(_sys.modules, {"usb": mock_usb, "usb.core": mock_usb.core}):
|
|
result = _get_pyusb_device_info()
|
|
assert result == {}
|
|
|
|
def test_not_found(self) -> None:
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_usb.core.find.return_value = None
|
|
with patch.dict(_sys.modules, {"usb": mock_usb, "usb.core": mock_usb.core}):
|
|
result = _get_pyusb_device_info()
|
|
assert result == {}
|
|
|
|
def test_none_product_serial(self) -> None:
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_dev = MagicMock()
|
|
mock_dev.product = None
|
|
mock_dev.serial_number = None
|
|
mock_usb.core.find.return_value = mock_dev
|
|
with patch.dict(_sys.modules, {"usb": mock_usb, "usb.core": mock_usb.core}):
|
|
result = _get_pyusb_device_info()
|
|
assert result["product"] == ""
|
|
assert result["serial"] == ""
|
|
|
|
def test_oserror(self) -> None:
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_usb.core.find.side_effect = OSError("usb fail")
|
|
with patch.dict(_sys.modules, {"usb": mock_usb, "usb.core": mock_usb.core}):
|
|
result = _get_pyusb_device_info()
|
|
assert result == {}
|
|
|
|
def test_value_error(self) -> None:
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_usb.core.find.side_effect = ValueError("bad")
|
|
with patch.dict(_sys.modules, {"usb": mock_usb, "usb.core": mock_usb.core}):
|
|
result = _get_pyusb_device_info()
|
|
assert result == {}
|
|
|
|
|
|
class TestIsCupsSchedulerRunning:
|
|
@patch(f"{MOD}.shutil.which", return_value=None)
|
|
def test_no_lpstat(self, m: MagicMock) -> None:
|
|
assert is_cups_scheduler_running() is False
|
|
|
|
@patch(f"{MOD}.subprocess.run")
|
|
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
|
|
def test_running(self, w: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(stdout="scheduler is running")
|
|
assert is_cups_scheduler_running() is True
|
|
|
|
@patch(f"{MOD}.subprocess.run")
|
|
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
|
|
def test_not_running(self, w: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_run.return_value = MagicMock(stdout="scheduler is not running")
|
|
assert is_cups_scheduler_running() 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", 3)
|
|
assert is_cups_scheduler_running() is False
|
|
|
|
@patch(f"{MOD}.subprocess.run")
|
|
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
|
|
def test_oserror(self, w: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_run.side_effect = OSError("fail")
|
|
assert is_cups_scheduler_running() is False
|
|
|
|
|
|
class TestStartCups:
|
|
@patch(f"{MOD}.shutil.which", return_value=None)
|
|
def test_no_systemctl(self, m: MagicMock) -> None:
|
|
assert start_cups() is False
|
|
|
|
@patch(f"{MOD}.time.sleep")
|
|
@patch(f"{MOD}.is_cups_scheduler_running")
|
|
@patch(f"{MOD}.subprocess.run")
|
|
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl")
|
|
def test_success(
|
|
self,
|
|
w: MagicMock,
|
|
mock_run: MagicMock,
|
|
mock_is_running: MagicMock,
|
|
s: MagicMock,
|
|
) -> None:
|
|
mock_run.return_value = MagicMock()
|
|
mock_is_running.return_value = True
|
|
assert start_cups() is True
|
|
|
|
@patch(f"{MOD}.subprocess.run")
|
|
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl")
|
|
def test_timeout(self, w: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_run.side_effect = subprocess.TimeoutExpired("systemctl", 15)
|
|
assert start_cups() is False
|
|
|
|
@patch(f"{MOD}.subprocess.run")
|
|
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl")
|
|
def test_called_process_error(self, w: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_run.side_effect = subprocess.CalledProcessError(1, "systemctl")
|
|
assert start_cups() is False
|
|
|
|
@patch(f"{MOD}.time.sleep")
|
|
@patch(f"{MOD}.is_cups_scheduler_running", return_value=False)
|
|
@patch(f"{MOD}.subprocess.run")
|
|
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl")
|
|
def test_never_starts(
|
|
self,
|
|
w: MagicMock,
|
|
mock_run: MagicMock,
|
|
is_running: MagicMock,
|
|
s: MagicMock,
|
|
) -> None:
|
|
mock_run.return_value = MagicMock()
|
|
assert start_cups() is False
|
|
|
|
|
|
class TestEnsureCupsRunning:
|
|
@patch(f"{MOD}.is_cups_scheduler_running", return_value=True)
|
|
def test_already_running(self, m: MagicMock) -> None:
|
|
assert _ensure_cups_running() is True
|
|
|
|
@patch(f"{MOD}.start_cups", return_value=True)
|
|
@patch(f"{MOD}.is_cups_scheduler_running", return_value=False)
|
|
def test_needs_start(self, is_running: MagicMock, st: MagicMock) -> None:
|
|
assert _ensure_cups_running() is True
|
|
|
|
@patch(f"{MOD}.start_cups", return_value=False)
|
|
@patch(f"{MOD}.is_cups_scheduler_running", return_value=False)
|
|
def test_start_fails(self, is_running: MagicMock, st: MagicMock) -> None:
|
|
assert _ensure_cups_running() is False
|
|
|
|
|
|
class TestPortStatusViaUsblp:
|
|
"""The zero-side-effect path: read the status byte from /dev/usb/lp*."""
|
|
|
|
@patch(f"{MOD}.Path")
|
|
def test_no_dev_dir(self, mock_path: MagicMock) -> None:
|
|
mock_path.return_value.is_dir.return_value = False
|
|
assert _port_status_via_usblp() is None
|
|
|
|
@patch(f"{MOD}.Path")
|
|
def test_no_devices(self, mock_path: MagicMock) -> None:
|
|
mock_path.return_value.is_dir.return_value = True
|
|
mock_path.return_value.glob.return_value = []
|
|
assert _port_status_via_usblp() is None
|
|
|
|
@patch(f"{MOD}.os.close")
|
|
@patch(f"{MOD}.fcntl.ioctl")
|
|
@patch(f"{MOD}.os.open", return_value=7)
|
|
@patch(f"{MOD}.Path")
|
|
def test_success(
|
|
self,
|
|
mock_path: MagicMock,
|
|
mock_open: MagicMock,
|
|
mock_ioctl: MagicMock,
|
|
mock_close: MagicMock,
|
|
) -> None:
|
|
mock_path.return_value.is_dir.return_value = True
|
|
mock_path.return_value.glob.return_value = ["/dev/usb/lp0"]
|
|
|
|
def fill(fd: int, req: int, buf: bytearray) -> None:
|
|
buf[0] = 0x18
|
|
|
|
mock_ioctl.side_effect = fill
|
|
result = _port_status_via_usblp()
|
|
assert result is not None
|
|
assert result.online is True
|
|
assert result.error is False
|
|
assert result.paper_empty is False
|
|
# Must never block: the printer may be mid-job.
|
|
assert mock_open.call_args[0][1] & os.O_NONBLOCK
|
|
mock_close.assert_called_once_with(7)
|
|
|
|
@patch(f"{MOD}.os.open", side_effect=OSError("busy"))
|
|
@patch(f"{MOD}.Path")
|
|
def test_open_fails(self, mock_path: MagicMock, mock_open: MagicMock) -> None:
|
|
mock_path.return_value.is_dir.return_value = True
|
|
mock_path.return_value.glob.return_value = ["/dev/usb/lp0"]
|
|
assert _port_status_via_usblp() is None
|
|
|
|
|
|
class TestQueryUsbPortStatusRaw:
|
|
@patch(f"{MOD}._port_status_via_usblp", return_value=USBPortStatus(raw_byte=0x18))
|
|
def test_prefers_usblp(self, m: MagicMock) -> None:
|
|
"""The free path wins; pyusb is never touched."""
|
|
result = _query_usb_port_status_raw("idle")
|
|
assert result is not None
|
|
assert result.raw_byte == 0x18
|
|
|
|
@patch(f"{MOD}._port_status_via_usblp", return_value=None)
|
|
def test_never_probes_while_printing(self, m: MagicMock) -> None:
|
|
"""Regression: probing mid-job used to stop CUPS and kill the print."""
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
with patch.dict(
|
|
_sys.modules,
|
|
{"usb": mock_usb, "usb.core": mock_usb.core, "usb.util": mock_usb.util},
|
|
):
|
|
assert _query_usb_port_status_raw("processing") is None
|
|
mock_usb.core.find.assert_not_called()
|
|
|
|
@patch(f"{MOD}._port_status_via_usblp", return_value=None)
|
|
def test_import_error(self, m: MagicMock) -> None:
|
|
with patch.dict(
|
|
"sys.modules", {"usb": None, "usb.core": None, "usb.util": None}
|
|
):
|
|
assert _query_usb_port_status_raw("idle") is None
|
|
|
|
@patch(f"{MOD}._port_status_via_usblp", return_value=None)
|
|
def test_dev_none(self, m: MagicMock) -> None:
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_usb.core.find.return_value = None
|
|
with patch.dict(
|
|
_sys.modules,
|
|
{"usb": mock_usb, "usb.core": mock_usb.core, "usb.util": mock_usb.util},
|
|
):
|
|
assert _query_usb_port_status_raw("idle") is None
|
|
|
|
@patch(f"{MOD}._port_status_via_usblp", return_value=None)
|
|
def test_success_reattaches_driver(self, m: MagicMock) -> None:
|
|
"""usblp must get its device back, or every later run hits the fallback."""
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_dev = MagicMock()
|
|
mock_dev.is_kernel_driver_active.return_value = True
|
|
mock_dev.ctrl_transfer.return_value = [0x18]
|
|
mock_usb.core.find.return_value = mock_dev
|
|
mock_usb.core.USBError = type("USBError", (Exception,), {})
|
|
with patch.dict(
|
|
_sys.modules,
|
|
{"usb": mock_usb, "usb.core": mock_usb.core, "usb.util": mock_usb.util},
|
|
):
|
|
result = _query_usb_port_status_raw("idle")
|
|
assert result is not None
|
|
assert result.online is True
|
|
mock_dev.reset.assert_not_called()
|
|
mock_dev.attach_kernel_driver.assert_called_once_with(0)
|
|
|
|
@patch(f"{MOD}._port_status_via_usblp", return_value=None)
|
|
def test_kernel_driver_not_active(self, m: MagicMock) -> None:
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_dev = MagicMock()
|
|
mock_dev.is_kernel_driver_active.return_value = False
|
|
mock_dev.ctrl_transfer.return_value = [0x18]
|
|
mock_usb.core.find.return_value = mock_dev
|
|
mock_usb.core.USBError = type("USBError", (Exception,), {})
|
|
with patch.dict(
|
|
_sys.modules,
|
|
{"usb": mock_usb, "usb.core": mock_usb.core, "usb.util": mock_usb.util},
|
|
):
|
|
result = _query_usb_port_status_raw("idle")
|
|
assert result is not None
|
|
# Nothing was detached, so nothing should be re-attached.
|
|
mock_dev.attach_kernel_driver.assert_not_called()
|
|
|
|
@patch(f"{MOD}._port_status_via_usblp", return_value=None)
|
|
def test_kernel_driver_usberror(self, m: MagicMock) -> None:
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_dev = MagicMock()
|
|
usb_error_cls = type("USBError", (Exception,), {})
|
|
mock_dev.is_kernel_driver_active.side_effect = usb_error_cls("err")
|
|
mock_dev.ctrl_transfer.return_value = [0x18]
|
|
mock_usb.core.find.return_value = mock_dev
|
|
mock_usb.core.USBError = usb_error_cls
|
|
with patch.dict(
|
|
_sys.modules,
|
|
{"usb": mock_usb, "usb.core": mock_usb.core, "usb.util": mock_usb.util},
|
|
):
|
|
assert _query_usb_port_status_raw("idle") is not None
|
|
|
|
@patch(f"{MOD}._port_status_via_usblp", return_value=None)
|
|
def test_oserror_during_transfer(self, m: MagicMock) -> None:
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_dev = MagicMock()
|
|
mock_dev.is_kernel_driver_active.return_value = False
|
|
mock_usb.core.find.return_value = mock_dev
|
|
mock_usb.core.USBError = type("USBError", (Exception,), {})
|
|
mock_usb.util.claim_interface.side_effect = OSError("usb fail")
|
|
with patch.dict(
|
|
_sys.modules,
|
|
{"usb": mock_usb, "usb.core": mock_usb.core, "usb.util": mock_usb.util},
|
|
):
|
|
assert _query_usb_port_status_raw("idle") is None
|
|
|
|
@patch(f"{MOD}._port_status_via_usblp", return_value=None)
|
|
def test_attach_failure_is_survivable(self, m: MagicMock) -> None:
|
|
import sys as _sys
|
|
|
|
mock_usb = MagicMock()
|
|
mock_dev = MagicMock()
|
|
mock_dev.is_kernel_driver_active.return_value = True
|
|
mock_dev.ctrl_transfer.return_value = [0x18]
|
|
mock_dev.attach_kernel_driver.side_effect = OSError("cannot reattach")
|
|
mock_usb.core.find.return_value = mock_dev
|
|
mock_usb.core.USBError = type("USBError", (Exception,), {})
|
|
with patch.dict(
|
|
_sys.modules,
|
|
{"usb": mock_usb, "usb.core": mock_usb.core, "usb.util": mock_usb.util},
|
|
):
|
|
assert _query_usb_port_status_raw("idle") is not None
|
|
|
|
|
|
class TestCupsIsBusy:
|
|
def test_processing(self) -> None:
|
|
assert _cups_is_busy("processing") is True
|
|
|
|
def test_printing_text(self) -> None:
|
|
assert _cups_is_busy("now printing Brother-98") is True
|
|
|
|
def test_idle(self) -> None:
|
|
assert _cups_is_busy("idle") is False
|
|
|
|
def test_empty(self) -> None:
|
|
assert _cups_is_busy("") is False
|
|
|
|
|
|
class TestPortStatusViaUsblpIoctlFailure:
|
|
@patch(f"{MOD}.os.close")
|
|
@patch(f"{MOD}.fcntl.ioctl", side_effect=OSError("not supported"))
|
|
@patch(f"{MOD}.os.open", return_value=7)
|
|
@patch(f"{MOD}.Path")
|
|
def test_ioctl_failure_still_closes_fd(
|
|
self,
|
|
mock_path: MagicMock,
|
|
mock_open: MagicMock,
|
|
mock_ioctl: MagicMock,
|
|
mock_close: MagicMock,
|
|
) -> None:
|
|
"""A failed ioctl must not leak the fd, or usblp stays busy."""
|
|
mock_path.return_value.is_dir.return_value = True
|
|
mock_path.return_value.glob.return_value = ["/dev/usb/lp0"]
|
|
assert _port_status_via_usblp() is None
|
|
mock_close.assert_called_once_with(7)
|