test: achieve 100% branch coverage across all python_pkg packages

- Add comprehensive tests for all packages (3572 tests, 100% branch coverage)
- Split oversized test files to stay under 500-line limit
- Add per-file ruff ignores for test-appropriate suppressions
- Fix _cache_decks.py to properly convert JSON lists to tuples
- Add session-scoped conftest fixture for logging handler cleanup (Python 3.14)
- Update ruff pre-commit hook to v0.15.2
- Add codespell ignore words for test data
- Add generated output files to .gitignore
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-03-21 17:51:36 +01:00
parent 72c6c3788c
commit 2545d72710
292 changed files with 42371 additions and 399 deletions

View File

@ -28,5 +28,5 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run pytest
run: pytest -q
- name: Run pytest with coverage
run: pytest --cov=python_pkg --cov-branch --cov-report=term-missing --cov-fail-under=100

4
.gitignore vendored
View File

@ -319,3 +319,7 @@ CPP/miscelanious/howManyValidISBNNumbersAreThere/ISBN.txt
# Focus mode secrets (contains home GPS coordinates)
phone_focus_mode/config_secrets.sh
# Generated output files
out.txt
cinema_plan_*.txt

View File

@ -75,7 +75,7 @@ repos:
# RUFF - Fast Python linter and formatter (replaces black, isort, flake8, etc.)
# ===========================================================================
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.1
rev: v0.15.2
hooks:
# Linter - run first to catch issues
- id: ruff
@ -165,6 +165,18 @@ repos:
additional_dependencies: ["bandit[toml]"]
exclude: ^(Bash/|\.venv/|tests/|.*test.*\.py$)
# ===========================================================================
# PYTEST + COVERAGE - Run tests and enforce 100% code coverage
# ===========================================================================
- repo: local
hooks:
- id: pytest-coverage
name: pytest with coverage enforcement
entry: python -m pytest --cov=python_pkg --cov-branch --cov-report=term-missing --cov-fail-under=100 -q
language: system
types: [python]
pass_filenames: false
# ===========================================================================
# VULTURE - Dead code detection (disabled - doesn't work well with pre-commit)
# ===========================================================================
@ -196,7 +208,7 @@ repos:
- id: codespell
args:
- --skip=*.json,*.lock,*.min.js,*.min.css,.git,__pycache__,.venv,*.txt
- --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre
- --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre,hel,alph
exclude: ^(Bash/ffmpeg-build/|LaTeX/|CPP/|.*\.geojson$)
# ===========================================================================

View File

@ -220,7 +220,7 @@ class FocusMode:
) -> None:
"""Handle updates when no mode is active."""
if steam_running and browser_running:
log("Both Steam and browsers detected at " "startup - entering GAMING mode")
log("Both Steam and browsers detected at startup - entering GAMING mode")
self.current_mode = "gaming"
self.mode_start_time = datetime.now(tz=timezone.utc)
kill_browsers()
@ -228,13 +228,13 @@ class FocusMode:
self._enter_mode(
"gaming",
"Steam detected - entering GAMING mode",
"\U0001f3ae Gaming Mode|" "Steam detected. Browsers are now blocked.",
"\U0001f3ae Gaming Mode|Steam detected. Browsers are now blocked.",
)
elif browser_running:
self._enter_mode(
"browsing",
"Browser detected - entering BROWSING mode",
"\U0001f310 Browsing Mode|" "Browser detected. Steam is now blocked.",
"\U0001f310 Browsing Mode|Browser detected. Steam is now blocked.",
)
def _handle_gaming(
@ -254,7 +254,7 @@ class FocusMode:
"normal",
)
elif browser_running:
log("Browser detected during GAMING mode " "- killing browsers")
log("Browser detected during GAMING mode - killing browsers")
kill_browsers()
def _handle_browsing(
@ -274,7 +274,7 @@ class FocusMode:
"normal",
)
elif steam_running:
log("Steam detected during BROWSING mode " "- killing Steam")
log("Steam detected during BROWSING mode - killing Steam")
kill_steam()
def update(self, processes: set[str]) -> None:
@ -310,8 +310,8 @@ class FocusMode:
duration = f" (active for {minutes}m)"
if self.current_mode == "gaming":
return f"\U0001f3ae GAMING mode{duration}" " - browsers blocked"
return f"\U0001f310 BROWSING mode{duration}" " - Steam blocked"
return f"\U0001f3ae GAMING mode{duration} - browsers blocked"
return f"\U0001f310 BROWSING mode{duration} - Steam blocked"
def write_status(focus: FocusMode) -> None:

View File

@ -63,7 +63,7 @@ def _probe_with_ffprobe(path: str) -> float | None:
"-show_entries",
"format=duration",
"-of",
"default=" "noprint_wrappers=1:nokey=1",
"default=noprint_wrappers=1:nokey=1",
path,
],
stderr=subprocess.DEVNULL,
@ -246,7 +246,7 @@ def _load_audio(
alt = _ffmpeg_transcode_to_wav16_mono(audio_path)
if alt is None:
logger.warning(
"Could not read audio for diarization " "and no ffmpeg fallback: %s",
"Could not read audio for diarization and no ffmpeg fallback: %s",
exc,
)
return None
@ -334,7 +334,7 @@ def diarize_segments(
torch_mod = _try_import("torch")
if torch_mod is None:
logger.warning(
"Diarization dependencies missing; " "skipping speaker labels.",
"Diarization dependencies missing; skipping speaker labels.",
)
return None

View File

@ -88,7 +88,7 @@ def _download_files(
repo_id,
)
logger.info(
"This may take several minutes for large " "models (~3GB for large-v3)",
"This may take several minutes for large models (~3GB for large-v3)",
)
_log_total_download_size(repo_id, required_files)
@ -156,7 +156,7 @@ def download_model_with_progress(
hh = _try_import("huggingface_hub")
if hh is None:
logger.warning(
"huggingface_hub not available, " "falling back to default download",
"huggingface_hub not available, falling back to default download",
)
return model_name
@ -181,7 +181,7 @@ def download_model_with_progress(
return _download_files(repo_id, required_files)
except (OSError, RuntimeError) as exc:
logger.warning(
"Custom download failed (%s), " "falling back to default",
"Custom download failed (%s), falling back to default",
exc,
)
return model_name

View File

@ -15,7 +15,7 @@ def format_timestamp(seconds: float) -> str:
minutes = (total_seconds % 3600) // 60
secs = total_seconds % 60
millis = int((seconds - int(seconds)) * 1000)
return f"{hours:02d}:{minutes:02d}:" f"{secs:02d},{millis:03d}"
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
def write_srt(segments: list[Any], srt_path: str) -> None:
@ -56,7 +56,7 @@ def write_srt_with_speakers(
spk = f"SPK{lab + 1}"
start_ts = format_timestamp(seg.start)
end_ts = format_timestamp(seg.end)
f.write(f"{i}\n{start_ts} --> {end_ts}\n" f"[{spk}] {text}\n\n")
f.write(f"{i}\n{start_ts} --> {end_ts}\n[{spk}] {text}\n\n")
def write_txt_with_speakers(
@ -87,9 +87,7 @@ def write_rttm(
dur = max(0.0, end - start)
name = f"SPK{lab + 1}"
f.write(
f"SPEAKER {file_id} 1 "
f"{start:.3f} {dur:.3f} "
f"<NA> <NA> {name} <NA>\n"
f"SPEAKER {file_id} 1 {start:.3f} {dur:.3f} <NA> <NA> {name} <NA>\n"
)

View File

@ -33,7 +33,7 @@ def _try_import(name: str) -> types.ModuleType | None:
def _parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description=("Transcribe audio with faster-whisper " "and write .txt and .srt"),
description=("Transcribe audio with faster-whisper and write .txt and .srt"),
)
parser.add_argument("input", help="Path to audio/video file")
parser.add_argument(
@ -152,9 +152,7 @@ def _format_progress_line(
)
elapsed = now - start_ts
line = (
f"[PROGRESS] {hhmmss(processed)} / "
f"{hhmmss(total_duration)} "
f"({pct:5.1f}%)"
f"[PROGRESS] {hhmmss(processed)} / {hhmmss(total_duration)} ({pct:5.1f}%)"
)
if processed > 0:
rate = processed / max(1e-6, elapsed)
@ -206,7 +204,7 @@ def _write_diarized_outputs(
logger.info("Wrote: %s", rttm_path)
else:
logger.warning(
"Diarization failed or returned " "mismatched labels; writing plain.",
"Diarization failed or returned mismatched labels; writing plain.",
)
@ -222,7 +220,7 @@ def main() -> int:
fw = _try_import("faster_whisper")
if fw is None:
logger.error(
"faster-whisper is not installed " "in this environment.",
"faster-whisper is not installed in this environment.",
)
return 2
@ -241,7 +239,7 @@ def main() -> int:
device, compute_type = _resolve_device_and_compute(args)
logger.info(
"Loading model='%s', device='%s', " "compute_type='%s'",
"Loading model='%s', device='%s', compute_type='%s'",
args.model,
device,
compute_type,

View File

@ -47,7 +47,7 @@ def check_diarization_deps() -> bool:
_torch = _try_import("torch")
if _sf is None or _sb is None or _torch is None:
logger.warning(
"Diarization deps missing offline; " "speaker labels will be skipped.",
"Diarization deps missing offline; speaker labels will be skipped.",
)
return False
return True
@ -139,7 +139,7 @@ def prepare_model(model_name: str, model_dir: str) -> bool:
logger.info("Preparing model '%s' into %s", model_name, model_dir)
logger.info(
"Downloading model files " "(progress bar should appear below)...",
"Downloading model files (progress bar should appear below)...",
)
fw.WhisperModel(
model_name,

4
out.json Normal file
View File

@ -0,0 +1,4 @@
{
"squares": [],
"notes": []
}

View File

@ -49,20 +49,44 @@ unfixable = []
[tool.ruff.lint.per-file-ignores]
# Test files - allow test-specific patterns (assert, magic values)
"**/tests/**/*.py" = [
"S101", # Allow assert in tests
"ANN", # Allow missing type annotations in tests
"ARG", # Allow unused arguments (fixtures, mocks)
"D", # Allow missing docstrings in tests
"E402", # Allow imports not at top (after sys.modules setup)
"FBT", # Allow boolean positional args/values
"PERF203", # Allow try-except in loop
"PLC0415", # Allow late imports for test isolation
"PLR0913", # Allow many arguments (mock patches)
"PLR2004", # Allow magic values in tests
"PT019", # Allow underscore-prefixed fixture params
"RUF059", # Allow unused passed args (patched fixtures)
"S101", # Allow assert in tests
"S108", # Allow hardcoded tmp paths in tests
"SIM117", # Allow non-combined with statements
"SLF001", # Allow private member access in tests
]
"**/test_*.py" = [
"S101", # Allow assert in tests
"S310", # Allow URL open in tests
"S607", # Allow partial executable path in tests
"ANN", # Allow missing type annotations in tests
"ARG", # Allow unused arguments (fixtures, mocks)
"D", # Allow missing docstrings in tests
"E402", # Allow imports not at top (after sys.modules setup)
"FBT", # Allow boolean positional args/values
"PLC0415", # Allow late imports for test isolation
"PLR0913", # Allow many arguments (mock patches)
"PLR2004", # Allow magic values in tests
"PT019", # Allow underscore-prefixed fixture params
"RUF059", # Allow unused passed args (patched fixtures)
"S101", # Allow assert in tests
"S108", # Allow hardcoded tmp paths in tests
"S310", # Allow URL open in tests
"S607", # Allow partial executable path in tests
"SIM117", # Allow non-combined with statements
"SLF001", # Allow private member access in tests
]
# Non-test files with late imports by design
"python_pkg/praca_magisterska_video/generate_images/generate_arch_diagrams.py" = [
"E402", # Imports after helper function definitions
]
# Files using urlopen with validated URL schemes
"python_pkg/geo_data/_common.py" = ["S310"]
"python_pkg/steam_backlog_enforcer/library_hider.py" = ["S310"]
@ -257,23 +281,26 @@ addopts = [
"--strict-markers",
"--strict-config",
"-ra",
"--cov=python_pkg",
"--cov-branch",
"--cov-report=term-missing",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning",
"default::pytest.PytestUnraisableExceptionWarning",
]
# ============================================================================
# COVERAGE - Code coverage configuration
# ============================================================================
[tool.coverage.run]
source = ["."]
source = ["python_pkg"]
branch = true
omit = [
"*/__pycache__/*",
"*/tests/*",
"*/.venv/*",
"Bash/*",
]
[tool.coverage.report]

View File

@ -0,0 +1,11 @@
"""Pytest conftest for anki_decks tests.
Ensures the geo_data package is importable by adding python_pkg/ to sys.path.
"""
from __future__ import annotations
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))

View File

@ -0,0 +1,239 @@
"""Tests for the Polish coastal features Anki generator."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import LineString, Point, Polygon
from python_pkg.anki_decks.polish_coastal_features import (
polish_coastal_features_anki as _mod,
)
if TYPE_CHECKING:
from pathlib import Path
_init_worker = _mod._init_worker
_mp_state = _mod._mp_state
_render_single_feature = _mod._render_single_feature
create_coastal_map = _mod.create_coastal_map
generate_anki_package = _mod.generate_anki_package
generate_coastal_image_bytes = _mod.generate_coastal_image_bytes
main = _mod.main
_MOD = "python_pkg.anki_decks.polish_coastal_features.polish_coastal_features_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _polygon_feature() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Mierzeja",
"type": "peninsula",
"geometry": Polygon([(18, 54), (19, 54), (19, 54.5), (18, 54.5)]),
},
],
crs="EPSG:4326",
)
def _line_feature() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Klif",
"type": "cliff",
"geometry": LineString([(14.5, 54.5), (15, 54.6), (15.5, 54.7)]),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(self, processes=None, initializer=None, initargs=()) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(self, func, items):
return [func(item) for item in items]
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestCreateCoastalMap:
"""Tests for create_coastal_map."""
def test_polygon_geometry(self) -> None:
fig = create_coastal_map(_polygon_feature(), _boundary())
assert fig is not None
plt.close(fig)
def test_line_geometry(self) -> None:
fig = create_coastal_map(_line_feature(), _boundary())
assert fig is not None
plt.close(fig)
def test_other_geometry_type(self) -> None:
"""A Point geometry hits neither Polygon nor LineString branch."""
feature = gpd.GeoDataFrame(
[
{
"name": "PointFeature",
"feature_type": "buoy",
"geometry": Point(17, 54.5),
}
],
crs="EPSG:4326",
)
fig = create_coastal_map(feature, _boundary())
assert fig is not None
plt.close(fig)
class TestGenerateCoastalImageBytes:
"""Tests for generate_coastal_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_coastal_image_bytes(_polygon_feature(), _boundary())
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
assert "poland_boundary" in _mp_state
_mp_state.clear()
def test_render_single_feature(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
geojson = _polygon_feature().to_json()
name, data = _render_single_feature(("Mierzeja", geojson))
assert name == "Mierzeja"
assert len(data) > 0
_mp_state.clear()
def test_render_single_feature_not_initialized(self) -> None:
_mp_state.clear()
geojson = _polygon_feature().to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_feature(("Mierzeja", geojson))
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_polygon_feature(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_polygon_feature(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
_mp_state.clear()
def test_progress_reporting(self) -> None:
features = gpd.GeoDataFrame(
[
{
"name": f"Feature{i}",
"feature_type": "cliff",
"geometry": Polygon([(16, 54), (17, 54), (17, 55), (16, 55)]),
}
for i in range(10)
],
crs="EPSG:4326",
)
with (
patch(f"{_MOD}.mp.Pool", _FakePool),
patch(f"{_MOD}.generate_coastal_image_bytes", return_value=b"PNG"),
):
package = generate_anki_package(features, _boundary())
assert len(package.decks[0].notes) == 10
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(
f"{_MOD}.get_polish_coastal_features", return_value=_polygon_feature()
),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(
f"{_MOD}.get_polish_coastal_features", return_value=_polygon_feature()
),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(
f"{_MOD}.get_polish_coastal_features", return_value=_polygon_feature()
),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -292,8 +292,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_forests = list(forests.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_forests)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_forests)} preview images to {preview_dir}...\n"
)
for _, row in preview_forests:
forest_name = row["name"]

View File

@ -0,0 +1,216 @@
"""Tests for the Polish forests Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Polygon
try:
from python_pkg.anki_decks.polish_forests.polish_forests_anki import (
_init_worker,
_mp_state,
_render_single_forest,
create_forest_map,
generate_anki_package,
generate_forest_image_bytes,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.polish_forests.polish_forests_anki import (
_init_worker,
_mp_state,
_render_single_forest,
create_forest_map,
generate_anki_package,
generate_forest_image_bytes,
main,
)
_MOD = "python_pkg.anki_decks.polish_forests.polish_forests_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _forests() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Puszcza A",
"area_km2": 150.5,
"geometry": Polygon([(16, 51), (17, 51), (17, 52), (16, 52)]),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(self, processes=None, initializer=None, initargs=()) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(self, func, items):
return [func(item) for item in items]
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestCreateForestMap:
"""Tests for create_forest_map."""
def test_returns_figure(self) -> None:
fig = create_forest_map(_forests(), _boundary())
assert fig is not None
plt.close(fig)
class TestGenerateForestImageBytes:
"""Tests for generate_forest_image_bytes."""
def test_returns_png_bytes(self) -> None:
data = generate_forest_image_bytes(_forests(), _boundary())
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
assert "poland_boundary" in _mp_state
_mp_state.clear()
def test_render_single_forest(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
geojson = _forests().to_json()
name, data = _render_single_forest(("Puszcza A", geojson))
assert name == "Puszcza A"
assert len(data) > 0
_mp_state.clear()
def test_render_single_forest_not_initialized(self) -> None:
_mp_state.clear()
geojson = _forests().to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_forest(("Puszcza A", geojson))
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_forests(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_forests(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
_mp_state.clear()
def test_notes_have_tags(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_forests(), _boundary())
note = package.decks[0].notes[0]
assert "geography" in note.tags
assert "forests" in note.tags
_mp_state.clear()
def test_progress_reporting(self) -> None:
forests = gpd.GeoDataFrame(
[
{
"name": f"Forest{i}",
"area_km2": 100.0,
"geometry": Polygon([(16, 51), (17, 51), (17, 52), (16, 52)]),
}
for i in range(10)
],
crs="EPSG:4326",
)
with (
patch(f"{_MOD}.mp.Pool", _FakePool),
patch(f"{_MOD}.generate_forest_image_bytes", return_value=b"PNG"),
):
package = generate_anki_package(forests, _boundary())
assert len(package.decks[0].notes) == 10
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_forests", return_value=_forests()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_forests", return_value=_forests()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_forests", return_value=_forests()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -373,8 +373,7 @@ def main(argv: Sequence[str] | None = None) -> int:
# Pre-compute color mapping for previews
color_map = _build_color_map(gminy["name"].tolist())
sys.stdout.write(
f"Exporting {len(preview_gminy)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_gminy)} preview images to {preview_dir}...\n"
)
for _, row in preview_gminy:
gmina_name = row["name"]

View File

@ -0,0 +1,240 @@
"""Tests for the Polish gminy Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Polygon
try:
from python_pkg.anki_decks.polish_gminy.polish_gminy_anki import (
_build_color_map,
_init_worker,
_mp_state,
_render_single_gmina,
create_gmina_map,
generate_anki_package,
generate_gmina_image_bytes,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.polish_gminy.polish_gminy_anki import (
_build_color_map,
_init_worker,
_mp_state,
_render_single_gmina,
create_gmina_map,
generate_anki_package,
generate_gmina_image_bytes,
main,
)
_MOD = "python_pkg.anki_decks.polish_gminy.polish_gminy_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _gminy() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Gmina A",
"geometry": Polygon([(16, 51), (17, 51), (17, 52), (16, 52)]),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(self, processes=None, initializer=None, initargs=()) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(self, func, items):
return [func(item) for item in items]
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestBuildColorMap:
"""Tests for _build_color_map."""
def test_returns_dict(self) -> None:
result = _build_color_map(["A", "B", "C"])
assert isinstance(result, dict)
assert len(result) == 3
def test_colors_are_hex(self) -> None:
result = _build_color_map(["X"])
assert result["X"].startswith("#")
class TestCreateGminaMap:
"""Tests for create_gmina_map."""
def test_returns_figure(self) -> None:
color_map = _build_color_map(["Gmina A"])
fig = create_gmina_map("Gmina A", _gminy(), _boundary(), color_map)
assert fig is not None
plt.close(fig)
def test_missing_name_uses_default(self) -> None:
color_map = _build_color_map(["Other"])
fig = create_gmina_map("Gmina A", _gminy(), _boundary(), color_map)
assert fig is not None
plt.close(fig)
class TestGenerateGminaImageBytes:
"""Tests for generate_gmina_image_bytes."""
def test_returns_bytes(self) -> None:
color_map = _build_color_map(["Gmina A"])
data = generate_gmina_image_bytes("Gmina A", _gminy(), _boundary(), color_map)
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path, {"Gmina A": "#E74C3C"})
assert "poland_boundary" in _mp_state
assert "color_map" in _mp_state
_mp_state.clear()
def test_render_single_gmina(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path, {"Gmina A": "#E74C3C"})
geojson = _gminy().to_json()
name, data = _render_single_gmina(("Gmina A", geojson))
assert name == "Gmina A"
assert len(data) > 0
_mp_state.clear()
def test_render_not_initialized(self) -> None:
_mp_state.clear()
geojson = _gminy().to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_gmina(("Gmina A", geojson))
def test_render_no_color_map(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_mp_state["poland_boundary"] = _boundary()
geojson = _gminy().to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_gmina(("Gmina A", geojson))
_mp_state.clear()
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_gminy(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_gminy(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
_mp_state.clear()
def test_progress_reporting(self) -> None:
gminy = gpd.GeoDataFrame(
[
{
"name": f"Gmina{i}",
"geometry": Polygon([(16, 51), (17, 51), (17, 52), (16, 52)]),
}
for i in range(100)
],
crs="EPSG:4326",
)
with (
patch(f"{_MOD}.mp.Pool", _FakePool),
patch(f"{_MOD}.generate_gmina_image_bytes", return_value=b"PNG"),
):
package = generate_anki_package(gminy, _boundary())
assert len(package.decks[0].notes) == 100
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_gminy", return_value=_gminy()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_gminy", return_value=_gminy()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_gminy", return_value=_gminy()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -378,8 +378,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_islands = list(islands.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_islands)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_islands)} preview images to {preview_dir}...\n"
)
for _, row in preview_islands:
island_name = row["name"]

View File

@ -0,0 +1,244 @@
"""Tests for the Polish islands Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Polygon
try:
from python_pkg.anki_decks.polish_islands.polish_islands_anki import (
_init_worker,
_island_extends_beyond,
_mp_state,
_render_single_island,
create_island_map,
generate_anki_package,
generate_island_image_bytes,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.polish_islands.polish_islands_anki import (
_init_worker,
_island_extends_beyond,
_mp_state,
_render_single_island,
create_island_map,
generate_anki_package,
generate_island_image_bytes,
main,
)
_MOD = "python_pkg.anki_decks.polish_islands.polish_islands_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _island_inside() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Wyspa A",
"area_km2": 10.0,
"geometry": Polygon([(18, 52), (19, 52), (19, 53), (18, 53)]),
},
],
crs="EPSG:4326",
)
def _island_outside() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Wyspa B",
"area_km2": 20.0,
"geometry": Polygon([(13, 52), (15, 52), (15, 53), (13, 53)]),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(self, processes=None, initializer=None, initargs=()) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(self, func, items):
return [func(item) for item in items]
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestIslandExtendsBeyond:
"""Tests for _island_extends_beyond."""
def test_inside_returns_false(self) -> None:
assert not _island_extends_beyond(_island_inside(), _boundary())
def test_outside_returns_true(self) -> None:
assert _island_extends_beyond(_island_outside(), _boundary())
class TestCreateIslandMap:
"""Tests for create_island_map - all 3 branches."""
def test_zoom_true(self) -> None:
fig = create_island_map(_island_inside(), _boundary(), zoom=True)
assert fig is not None
plt.close(fig)
def test_no_zoom_extends_beyond(self) -> None:
fig = create_island_map(_island_outside(), _boundary(), zoom=False)
assert fig is not None
plt.close(fig)
def test_no_zoom_inside(self) -> None:
fig = create_island_map(_island_inside(), _boundary(), zoom=False)
assert fig is not None
plt.close(fig)
class TestGenerateIslandImageBytes:
"""Tests for generate_island_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_island_image_bytes(_island_inside(), _boundary(), zoom=True)
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path, "zoom")
assert "poland_boundary" in _mp_state
assert _mp_state["zoom_mode"] == "zoom"
_mp_state.clear()
def test_render_single_island(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path, "zoom")
geojson = _island_inside().to_json()
name, data = _render_single_island(("Wyspa A", geojson))
assert name == "Wyspa A"
assert len(data) > 0
_mp_state.clear()
def test_render_not_initialized(self) -> None:
_mp_state.clear()
geojson = _island_inside().to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_island(("Wyspa A", geojson))
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_island_inside(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_island_inside(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
_mp_state.clear()
def test_progress_reporting(self) -> None:
islands = gpd.GeoDataFrame(
[
{
"name": f"Island{i}",
"area_km2": 50.0,
"geometry": Polygon([(18, 52), (19, 52), (19, 53), (18, 53)]),
}
for i in range(10)
],
crs="EPSG:4326",
)
with (
patch(f"{_MOD}.mp.Pool", _FakePool),
patch(f"{_MOD}.generate_island_image_bytes", return_value=b"PNG"),
):
package = generate_anki_package(islands, _boundary())
assert len(package.decks[0].notes) == 10
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_islands", return_value=_island_inside()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_islands", return_value=_island_inside()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_islands", return_value=_island_inside()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -331,8 +331,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_lakes = list(lakes.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_lakes)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_lakes)} preview images to {preview_dir}...\n"
)
for _, row in preview_lakes:
lake_name = row["name"]

View File

@ -0,0 +1,243 @@
"""Tests for the Polish lakes Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Polygon
try:
from python_pkg.anki_decks.polish_lakes.polish_lakes_anki import (
_init_worker,
_mp_state,
_render_single_lake,
create_lake_map,
generate_anki_package,
generate_lake_image_bytes,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.polish_lakes.polish_lakes_anki import (
_init_worker,
_mp_state,
_render_single_lake,
create_lake_map,
generate_anki_package,
generate_lake_image_bytes,
main,
)
_MOD = "python_pkg.anki_decks.polish_lakes.polish_lakes_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _lakes() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Jezioro A",
"area_km2": 25.5,
"geometry": Polygon([(17, 53), (18, 53), (18, 53.5), (17, 53.5)]),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(self, processes=None, initializer=None, initargs=()) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(self, func, items):
return [func(item) for item in items]
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestCreateLakeMap:
"""Tests for create_lake_map."""
def test_zoom_true(self) -> None:
fig = create_lake_map(_lakes(), _boundary(), zoom=True)
assert fig is not None
plt.close(fig)
def test_zoom_false(self) -> None:
fig = create_lake_map(_lakes(), _boundary(), zoom=False)
assert fig is not None
plt.close(fig)
class TestGenerateLakeImageBytes:
"""Tests for generate_lake_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_lake_image_bytes(_lakes(), _boundary(), zoom=True)
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker_zoom(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path, "zoom")
assert _mp_state["zoom"] is True
_mp_state.clear()
def test_init_worker_no_zoom(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path, "no-zoom")
assert _mp_state["zoom"] is False
_mp_state.clear()
def test_render_single_lake(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path, "zoom")
geojson = _lakes().to_json()
name, data = _render_single_lake(("Jezioro A", geojson))
assert name == "Jezioro A"
assert len(data) > 0
_mp_state.clear()
def test_render_not_initialized(self) -> None:
_mp_state.clear()
geojson = _lakes().to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_lake(("Jezioro A", geojson))
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_lakes(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_lakes(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
_mp_state.clear()
def test_progress_reporting(self) -> None:
lakes = gpd.GeoDataFrame(
[
{
"name": f"Lake{i}",
"area_km2": 50.0,
"geometry": Polygon([(18, 52), (19, 52), (19, 53), (18, 53)]),
}
for i in range(50)
],
crs="EPSG:4326",
)
with (
patch(f"{_MOD}.mp.Pool", _FakePool),
patch(f"{_MOD}.generate_lake_image_bytes", return_value=b"PNG"),
):
package = generate_anki_package(lakes, _boundary())
assert len(package.decks[0].notes) == 50
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_lakes", return_value=_lakes()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_no_zoom(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_lakes", return_value=_lakes()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out), "--no-zoom"])
assert result == 0
_mp_state.clear()
def test_limit(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_lakes", return_value=_lakes()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out), "--limit", "1"])
assert result == 0
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_lakes", return_value=_lakes()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_lakes", return_value=_lakes()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -304,8 +304,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_parks = list(parks.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_parks)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_parks)} preview images to {preview_dir}...\n"
)
for _, row in preview_parks:
park_name = row["name"]

View File

@ -0,0 +1,197 @@
"""Tests for the Polish landscape parks Anki generator."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Polygon
import python_pkg.anki_decks.polish_landscape_parks.polish_landscape_parks_anki as _mod
if TYPE_CHECKING:
from pathlib import Path
_init_worker = _mod._init_worker
_mp_state = _mod._mp_state
_render_single_park = _mod._render_single_park
create_park_map = _mod.create_park_map
generate_anki_package = _mod.generate_anki_package
generate_park_image_bytes = _mod.generate_park_image_bytes
main = _mod.main
_MOD = "python_pkg.anki_decks.polish_landscape_parks.polish_landscape_parks_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _parks() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Park A",
"area_km2": 300.0,
"geometry": Polygon([(16, 51), (17, 51), (17, 52), (16, 52)]),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(self, processes=None, initializer=None, initargs=()) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(self, func, items):
return [func(item) for item in items]
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestCreateParkMap:
"""Tests for create_park_map."""
def test_returns_figure(self) -> None:
fig = create_park_map(_parks(), _boundary())
assert fig is not None
plt.close(fig)
class TestGenerateParkImageBytes:
"""Tests for generate_park_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_park_image_bytes(_parks(), _boundary())
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
assert "poland_boundary" in _mp_state
_mp_state.clear()
def test_render_single_park(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
geojson = _parks().to_json()
name, data = _render_single_park(("Park A", geojson))
assert name == "Park A"
assert len(data) > 0
_mp_state.clear()
def test_render_not_initialized(self) -> None:
_mp_state.clear()
geojson = _parks().to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_park(("Park A", geojson))
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_parks(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_parks(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
_mp_state.clear()
def test_progress_reporting(self) -> None:
parks = gpd.GeoDataFrame(
[
{
"name": f"Park{i}",
"area_km2": 200.0,
"geometry": Polygon([(16, 51), (17, 51), (17, 52), (16, 52)]),
}
for i in range(25)
],
crs="EPSG:4326",
)
with (
patch(f"{_MOD}.mp.Pool", _FakePool),
patch(f"{_MOD}.generate_park_image_bytes", return_value=b"PNG"),
):
package = generate_anki_package(parks, _boundary())
assert len(package.decks[0].notes) == 25
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_landscape_parks", return_value=_parks()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_landscape_parks", return_value=_parks()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_landscape_parks", return_value=_parks()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -360,8 +360,7 @@ def main() -> int:
sys.stdout.write("\n")
sys.stdout.write("Data source: Wikipedia\n")
sys.stdout.write(
"URL: https://en.wikipedia.org/wiki/"
"Vehicle_registration_plates_of_Poland\n"
"URL: https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland\n"
)
sys.stdout.write(f"Cache location: {get_cache_path()}\n")
sys.stdout.write(f"Cache expiry: {CACHE_EXPIRY_DAYS} days\n")

View File

@ -0,0 +1,473 @@
"""Tests for the fetch_license_plates module."""
from __future__ import annotations
import importlib
from pathlib import Path
import sys
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.anki_decks.polish_license_plates.fetch_license_plates import (
fetch_wikipedia_html,
get_cache_path,
is_cache_valid,
parse_license_plates_from_html,
)
class TestImportError:
"""Tests for the ImportError handling at module level."""
def test_exits_when_packages_missing(self) -> None:
"""Should exit with error when bs4/requests not installed."""
module_name = "python_pkg.anki_decks.polish_license_plates.fetch_license_plates"
# Remove the module so it can be re-imported
saved_module = sys.modules.pop(module_name)
# Also remove bs4 to trigger ImportError
saved_bs4 = sys.modules.pop("bs4", None)
saved_requests = sys.modules.pop("requests", None)
import builtins
original_import = builtins.__import__
def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name in ("bs4", "requests"):
msg = f"No module named '{name}'"
raise ImportError(msg)
return original_import(name, *args, **kwargs)
try:
with patch("builtins.__import__", side_effect=mock_import):
with pytest.raises(SystemExit) as exc_info:
importlib.import_module(module_name)
assert exc_info.value.code == 1
finally:
# Restore modules
sys.modules[module_name] = saved_module
if saved_bs4 is not None:
sys.modules["bs4"] = saved_bs4
if saved_requests is not None:
sys.modules["requests"] = saved_requests
class TestGetCachePath:
"""Tests for get_cache_path."""
def test_returns_path_in_wikipedia_cache_dir(self) -> None:
"""Cache path should be under .wikipedia_cache directory."""
result = get_cache_path()
assert result.name == "license_plates.html"
assert result.parent.name == ".wikipedia_cache"
@patch.object(Path, "mkdir")
def test_creates_cache_directory(self, mock_mkdir: MagicMock) -> None:
"""Should create cache directory with exist_ok=True."""
get_cache_path()
mock_mkdir.assert_called_once_with(exist_ok=True)
class TestIsCacheValid:
"""Tests for is_cache_valid."""
def test_returns_false_when_file_does_not_exist(self, tmp_path: Path) -> None:
"""Should return False when cache file doesn't exist."""
cache_path = tmp_path / "nonexistent.html"
assert is_cache_valid(cache_path) is False
def test_returns_true_when_cache_is_fresh(self, tmp_path: Path) -> None:
"""Should return True when cache file is recent."""
cache_path = tmp_path / "cache.html"
cache_path.write_text("cached content")
assert is_cache_valid(cache_path) is True
def test_returns_false_when_cache_is_expired(self, tmp_path: Path) -> None:
"""Should return False when cache file is old."""
cache_path = tmp_path / "cache.html"
cache_path.write_text("cached content")
# Mock time to make the file appear old
with patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.time.time",
return_value=cache_path.stat().st_mtime + 8 * 24 * 60 * 60,
):
assert is_cache_valid(cache_path) is False
def test_custom_max_age_days(self, tmp_path: Path) -> None:
"""Should use custom max_age_days parameter."""
cache_path = tmp_path / "cache.html"
cache_path.write_text("cached content")
# With max_age_days=0, file should be considered expired
with patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.time.time",
return_value=cache_path.stat().st_mtime + 1,
):
assert is_cache_valid(cache_path, max_age_days=0) is False
class TestFetchWikipediaHtml:
"""Tests for fetch_wikipedia_html."""
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.get_cache_path"
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.is_cache_valid",
return_value=True,
)
def test_returns_cached_data_when_valid(
self,
_mock_valid: MagicMock,
mock_cache_path: MagicMock,
tmp_path: Path,
) -> None:
"""Should return cached data when cache is valid."""
cache_file = tmp_path / "cache.html"
cache_file.write_text("<html>cached</html>")
mock_cache_path.return_value = cache_file
result = fetch_wikipedia_html()
assert result == "<html>cached</html>"
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.get_cache_path"
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.is_cache_valid",
return_value=True,
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.requests.get"
)
def test_fetches_fresh_when_cache_read_fails(
self,
mock_get: MagicMock,
_mock_valid: MagicMock,
mock_cache_path: MagicMock,
tmp_path: Path,
) -> None:
"""Should fall through to fetch when cache read raises OSError."""
tmp_path / "cache.html"
mock_path = MagicMock(spec=Path)
mock_path.exists.return_value = True
mock_stat = MagicMock()
mock_stat.st_mtime = 0.0
mock_path.stat.return_value = mock_stat
mock_path.read_text.side_effect = OSError("read error")
# write_text should succeed for caching the new response
mock_path.write_text = MagicMock()
mock_cache_path.return_value = mock_path
mock_response = MagicMock()
mock_response.text = "<html>fresh</html>"
mock_get.return_value = mock_response
result = fetch_wikipedia_html()
assert result == "<html>fresh</html>"
mock_get.assert_called_once()
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.get_cache_path"
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.is_cache_valid",
return_value=False,
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.requests.get"
)
def test_fetches_from_wikipedia_when_cache_invalid(
self,
mock_get: MagicMock,
_mock_valid: MagicMock,
mock_cache_path: MagicMock,
tmp_path: Path,
) -> None:
"""Should fetch from Wikipedia when cache is invalid."""
cache_file = tmp_path / "cache.html"
mock_cache_path.return_value = cache_file
mock_response = MagicMock()
mock_response.text = "<html>wikipedia</html>"
mock_get.return_value = mock_response
result = fetch_wikipedia_html()
assert result == "<html>wikipedia</html>"
# Should have written cache
assert cache_file.read_text() == "<html>wikipedia</html>"
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.get_cache_path"
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.is_cache_valid",
return_value=False,
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.requests.get"
)
def test_force_refresh_ignores_cache(
self,
mock_get: MagicMock,
_mock_valid: MagicMock,
mock_cache_path: MagicMock,
tmp_path: Path,
) -> None:
"""Should fetch from Wikipedia when force_refresh is True."""
cache_file = tmp_path / "cache.html"
mock_cache_path.return_value = cache_file
mock_response = MagicMock()
mock_response.text = "<html>forced</html>"
mock_get.return_value = mock_response
result = fetch_wikipedia_html(force_refresh=True)
assert result == "<html>forced</html>"
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.get_cache_path"
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.is_cache_valid",
return_value=True,
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.requests.get"
)
def test_force_refresh_skips_valid_cache(
self,
mock_get: MagicMock,
_mock_valid: MagicMock,
mock_cache_path: MagicMock,
tmp_path: Path,
) -> None:
"""Even with valid cache, force_refresh should fetch fresh."""
cache_file = tmp_path / "cache.html"
mock_cache_path.return_value = cache_file
mock_response = MagicMock()
mock_response.text = "<html>forced fresh</html>"
mock_get.return_value = mock_response
result = fetch_wikipedia_html(force_refresh=True)
assert result == "<html>forced fresh</html>"
mock_get.assert_called_once()
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.get_cache_path"
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.is_cache_valid",
return_value=False,
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.requests.get"
)
def test_raises_runtime_error_on_request_exception(
self,
mock_get: MagicMock,
_mock_valid: MagicMock,
mock_cache_path: MagicMock,
tmp_path: Path,
) -> None:
"""Should raise RuntimeError when requests fails."""
import requests
cache_file = tmp_path / "cache.html"
mock_cache_path.return_value = cache_file
mock_get.side_effect = requests.RequestException("connection error")
with pytest.raises(RuntimeError, match="Failed to fetch Wikipedia page"):
fetch_wikipedia_html()
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.get_cache_path"
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.is_cache_valid",
return_value=False,
)
@patch(
"python_pkg.anki_decks.polish_license_plates.fetch_license_plates.requests.get"
)
def test_continues_when_cache_write_fails(
self,
mock_get: MagicMock,
_mock_valid: MagicMock,
mock_cache_path: MagicMock,
) -> None:
"""Should return data even when cache write fails."""
mock_path = MagicMock(spec=Path)
mock_path.write_text.side_effect = OSError("write error")
mock_cache_path.return_value = mock_path
mock_response = MagicMock()
mock_response.text = "<html>data</html>"
mock_get.return_value = mock_response
result = fetch_wikipedia_html()
assert result == "<html>data</html>"
class TestParseLicensePlatesFromHtml:
"""Tests for parse_license_plates_from_html."""
def test_raises_error_when_no_tables(self) -> None:
"""Should raise RuntimeError when no wikitable found."""
html = "<html><body><p>No tables here</p></body></html>"
with pytest.raises(RuntimeError, match="No wikitable found"):
parse_license_plates_from_html(html)
def test_extracts_valid_codes(self) -> None:
"""Should extract valid license plate codes from table."""
html = """
<html><body>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>WA</td><td>Warszawa</td></tr>
<tr><td>KR</td><td>Kraków</td></tr>
</table>
</body></html>
"""
result = parse_license_plates_from_html(html)
assert result == {"WA": "Warszawa", "KR": "Kraków"}
def test_skips_rows_with_too_few_columns(self) -> None:
"""Should skip rows with fewer than MIN_TABLE_COLUMNS cells."""
html = """
<html><body>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>Only one cell</td></tr>
<tr><td>WA</td><td>Warszawa</td></tr>
</table>
</body></html>
"""
result = parse_license_plates_from_html(html)
assert result == {"WA": "Warszawa"}
def test_skips_empty_codes(self) -> None:
"""Should skip entries where code is empty after cleaning."""
html = """
<html><body>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>123</td><td>Some place</td></tr>
<tr><td>WA</td><td>Warszawa</td></tr>
</table>
</body></html>
"""
result = parse_license_plates_from_html(html)
assert result == {"WA": "Warszawa"}
def test_skips_codes_longer_than_max(self) -> None:
"""Should skip codes longer than MAX_CODE_LENGTH."""
html = """
<html><body>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>ABCDE</td><td>Too long code</td></tr>
<tr><td>WA</td><td>Warszawa</td></tr>
</table>
</body></html>
"""
result = parse_license_plates_from_html(html)
assert result == {"WA": "Warszawa"}
def test_skips_empty_locations(self) -> None:
"""Should skip entries with empty location after cleaning."""
html = """
<html><body>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>WA</td><td> </td></tr>
<tr><td>KR</td><td>Kraków</td></tr>
</table>
</body></html>
"""
result = parse_license_plates_from_html(html)
assert result == {"KR": "Kraków"}
def test_removes_citation_references(self) -> None:
"""Should remove [1], [2] style citations from locations."""
html = """
<html><body>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>WA</td><td>Warszawa[1][23]</td></tr>
</table>
</body></html>
"""
result = parse_license_plates_from_html(html)
assert result == {"WA": "Warszawa"}
def test_cleans_whitespace_in_location(self) -> None:
"""Should collapse multiple spaces in location."""
html = """
<html><body>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>WA</td><td> Warszawa city </td></tr>
</table>
</body></html>
"""
result = parse_license_plates_from_html(html)
assert result == {"WA": "Warszawa city"}
def test_processes_multiple_tables(self) -> None:
"""Should process all wikitables on the page."""
html = """
<html><body>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>WA</td><td>Warszawa</td></tr>
</table>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>KR</td><td>Kraków</td></tr>
</table>
</body></html>
"""
result = parse_license_plates_from_html(html)
assert result == {"WA": "Warszawa", "KR": "Kraków"}
def test_uppercases_codes(self) -> None:
"""Should uppercase license plate codes."""
html = """
<html><body>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>wa</td><td>Warszawa</td></tr>
</table>
</body></html>
"""
result = parse_license_plates_from_html(html)
assert result == {"WA": "Warszawa"}
def test_removes_non_alpha_from_codes(self) -> None:
"""Should remove non-alphabetic characters from codes."""
html = """
<html><body>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>W-A 1</td><td>Warszawa</td></tr>
</table>
</body></html>
"""
result = parse_license_plates_from_html(html)
assert result == {"WA": "Warszawa"}
def test_returns_empty_dict_when_no_valid_entries(self) -> None:
"""Should return empty dict when table has no valid entries."""
html = """
<html><body>
<table class="wikitable">
<tr><th>Code</th><th>Location</th></tr>
<tr><td>12345</td><td>Numbers only</td></tr>
</table>
</body></html>
"""
result = parse_license_plates_from_html(html)
assert result == {}

View File

@ -0,0 +1,176 @@
"""Tests for fetch_license_plates module - part 2 (generate + main)."""
from __future__ import annotations
from io import StringIO
from pathlib import Path
from unittest.mock import MagicMock, patch
from python_pkg.anki_decks.polish_license_plates.fetch_license_plates import (
fetch_wikipedia_license_plates,
generate_license_plate_data_file,
main,
)
MOD = "python_pkg.anki_decks.polish_license_plates.fetch_license_plates"
# ── fetch_wikipedia_license_plates ───────────────────────────────────
class TestFetchWikipediaLicensePlates:
"""Tests for fetch_wikipedia_license_plates."""
@patch(f"{MOD}.parse_license_plates_from_html", return_value={"WA": "Warszawa"})
@patch(f"{MOD}.fetch_wikipedia_html", return_value="<html></html>")
def test_combines_fetch_and_parse(
self, mock_fetch: MagicMock, mock_parse: MagicMock
) -> None:
result = fetch_wikipedia_license_plates()
assert result == {"WA": "Warszawa"}
mock_fetch.assert_called_once_with(force_refresh=False)
mock_parse.assert_called_once_with("<html></html>")
@patch(f"{MOD}.parse_license_plates_from_html", return_value={"KR": "Kraków"})
@patch(f"{MOD}.fetch_wikipedia_html", return_value="<html></html>")
def test_force_refresh_passed(
self, mock_fetch: MagicMock, _mock_parse: MagicMock
) -> None:
fetch_wikipedia_license_plates(force_refresh=True)
mock_fetch.assert_called_once_with(force_refresh=True)
# ── generate_license_plate_data_file ─────────────────────────────────
class TestGenerateLicensePlateDataFile:
"""Tests for generate_license_plate_data_file."""
def test_generates_file_with_grouped_codes(self, tmp_path: Path) -> None:
plates = {
"WA": "Warszawa",
"KR": "Kraków",
"WB": "Warszawa-Bielany",
}
output = tmp_path / "license_plate_data.py"
generate_license_plate_data_file(plates, output)
content = output.read_text(encoding="utf-8")
assert "LICENSE_PLATE_CODES" in content
assert '"WA": "Warszawa"' in content
assert '"KR": "Kraków"' in content
assert '"WB": "Warszawa-Bielany"' in content
# Grouped by voivodeship
assert "# K - Małopolskie" in content
assert "# W - Mazowieckie" in content
def test_escapes_quotes_in_location(self, tmp_path: Path) -> None:
plates = {"WA": 'Warszawa "capital"'}
output = tmp_path / "out.py"
generate_license_plate_data_file(plates, output)
content = output.read_text(encoding="utf-8")
assert '\\"capital\\"' in content
def test_unknown_voivodeship_letter(self, tmp_path: Path) -> None:
plates = {"XA": "Xanadu"}
output = tmp_path / "out.py"
generate_license_plate_data_file(plates, output)
content = output.read_text(encoding="utf-8")
assert "Voivodeship X" in content
def test_writes_docstring_and_import(self, tmp_path: Path) -> None:
plates = {"BA": "Białystok"}
output = tmp_path / "out.py"
generate_license_plate_data_file(plates, output)
content = output.read_text(encoding="utf-8")
assert "from __future__ import annotations" in content
assert "Auto-generated by" in content
def test_shows_code_count_per_voivodeship(self, tmp_path: Path) -> None:
plates = {"BA": "Białystok", "BI": "Bielsk Podlaski"}
output = tmp_path / "out.py"
generate_license_plate_data_file(plates, output)
content = output.read_text(encoding="utf-8")
assert "(2 codes)" in content
# ── main ─────────────────────────────────────────────────────────────
class TestMain:
"""Tests for main entry point."""
@patch(f"{MOD}.get_cache_path", return_value=Path("/tmp/cache"))
@patch(f"{MOD}.generate_license_plate_data_file")
@patch(
f"{MOD}.fetch_wikipedia_license_plates",
return_value={"WA": "Warszawa", "KR": "Kraków"},
)
@patch(f"{MOD}.argparse.ArgumentParser.parse_args")
def test_success(
self,
mock_args: MagicMock,
_mock_fetch: MagicMock,
mock_gen: MagicMock,
_mock_cache: MagicMock,
) -> None:
mock_args.return_value = MagicMock(force=False)
with patch("sys.stdout", new_callable=StringIO):
result = main()
assert result == 0
mock_gen.assert_called_once()
@patch(
f"{MOD}.fetch_wikipedia_license_plates",
side_effect=RuntimeError("network fail"),
)
@patch(f"{MOD}.argparse.ArgumentParser.parse_args")
def test_runtime_error(
self,
mock_args: MagicMock,
_mock_fetch: MagicMock,
) -> None:
mock_args.return_value = MagicMock(force=False)
with patch("sys.stderr", new_callable=StringIO):
result = main()
assert result == 1
@patch(f"{MOD}.get_cache_path", return_value=Path("/tmp/cache"))
@patch(f"{MOD}.generate_license_plate_data_file")
@patch(
f"{MOD}.fetch_wikipedia_license_plates",
return_value={"WA": "Warszawa"},
)
@patch(f"{MOD}.argparse.ArgumentParser.parse_args")
def test_force_flag(
self,
mock_args: MagicMock,
mock_fetch: MagicMock,
_mock_gen: MagicMock,
_mock_cache: MagicMock,
) -> None:
mock_args.return_value = MagicMock(force=True)
with patch("sys.stdout", new_callable=StringIO):
result = main()
assert result == 0
mock_fetch.assert_called_once_with(force_refresh=True)
@patch(f"{MOD}.get_cache_path", return_value=Path("/tmp/cache"))
@patch(f"{MOD}.generate_license_plate_data_file")
@patch(
f"{MOD}.fetch_wikipedia_license_plates",
return_value={"WA": "Warszawa"},
)
@patch(f"{MOD}.argparse.ArgumentParser.parse_args")
def test_prints_summary(
self,
mock_args: MagicMock,
_mock_fetch: MagicMock,
_mock_gen: MagicMock,
_mock_cache: MagicMock,
) -> None:
mock_args.return_value = MagicMock(force=False)
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
main()
output = mock_stdout.getvalue()
assert "Total codes" in output
assert "LICENSE PLATE DATA UPDATE COMPLETE" in output

View File

@ -3,6 +3,7 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
@ -226,6 +227,16 @@ class TestMain:
main(["--help"])
assert exc_info.value.code == 0
def test_main_error_returns_1(self, tmp_path: Path) -> None:
"""Test that main returns 1 on error."""
with patch(
"python_pkg.anki_decks.polish_license_plates"
".polish_license_plates_anki.generate_anki_package",
side_effect=OSError("disk full"),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -345,8 +345,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_peaks = list(peaks.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_peaks)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_peaks)} preview images to {preview_dir}...\n"
)
for _, row in preview_peaks:
peak_name = row["name"]

View File

@ -0,0 +1,235 @@
"""Tests for the Polish mountain peaks Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Point, Polygon
try:
from python_pkg.anki_decks.polish_mountain_peaks.polish_mountain_peaks_anki import (
_init_worker,
_mp_state,
_render_single_peak,
create_peak_map,
generate_anki_package,
generate_peak_image_bytes,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.polish_mountain_peaks.polish_mountain_peaks_anki import (
_init_worker,
_mp_state,
_render_single_peak,
create_peak_map,
generate_anki_package,
generate_peak_image_bytes,
main,
)
_MOD = "python_pkg.anki_decks.polish_mountain_peaks.polish_mountain_peaks_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _peaks() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Rysy",
"elevation": 2499,
"geometry": Point(20.088, 49.179),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(self, processes=None, initializer=None, initargs=()) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(self, func, items):
return [func(item) for item in items]
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestCreatePeakMap:
"""Tests for create_peak_map."""
def test_zoom_true(self) -> None:
fig = create_peak_map(_peaks(), _boundary(), zoom=True)
assert fig is not None
plt.close(fig)
def test_zoom_false(self) -> None:
fig = create_peak_map(_peaks(), _boundary(), zoom=False)
assert fig is not None
plt.close(fig)
class TestGeneratePeakImageBytes:
"""Tests for generate_peak_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_peak_image_bytes(_peaks(), _boundary(), zoom=True)
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path, "zoom")
assert _mp_state["zoom"] is True
_mp_state.clear()
def test_render_single_peak(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path, "zoom")
geojson = _peaks().to_json()
name, data = _render_single_peak(("Rysy", geojson))
assert name == "Rysy"
assert len(data) > 0
_mp_state.clear()
def test_render_not_initialized(self) -> None:
_mp_state.clear()
geojson = _peaks().to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_peak(("Rysy", geojson))
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_peaks(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_peaks(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
_mp_state.clear()
def test_progress_reporting(self) -> None:
peaks = gpd.GeoDataFrame(
[
{
"name": f"Peak{i}",
"elevation": 1000 + i,
"geometry": Point(19 + i * 0.01, 50),
}
for i in range(50)
],
crs="EPSG:4326",
)
with (
patch(f"{_MOD}.mp.Pool", _FakePool),
patch(f"{_MOD}.generate_peak_image_bytes", return_value=b"PNG"),
):
package = generate_anki_package(peaks, _boundary())
assert len(package.decks[0].notes) == 50
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_mountain_peaks", return_value=_peaks()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_no_zoom(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_mountain_peaks", return_value=_peaks()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out), "--no-zoom"])
assert result == 0
_mp_state.clear()
def test_limit(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_mountain_peaks", return_value=_peaks()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out), "--limit", "1"])
assert result == 0
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_mountain_peaks", return_value=_peaks()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_mountain_peaks", return_value=_peaks()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -300,8 +300,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_ranges = list(ranges.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_ranges)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_ranges)} preview images to {preview_dir}...\n"
)
for _, row in preview_ranges:
range_name = row["name"]

View File

@ -0,0 +1,199 @@
"""Tests for the Polish mountain ranges Anki generator."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Polygon
import python_pkg.anki_decks.polish_mountain_ranges.polish_mountain_ranges_anki as _mod
if TYPE_CHECKING:
from pathlib import Path
_init_worker = _mod._init_worker
_mp_state = _mod._mp_state
_render_single_range = _mod._render_single_range
create_range_map = _mod.create_range_map
generate_anki_package = _mod.generate_anki_package
generate_range_image_bytes = _mod.generate_range_image_bytes
main = _mod.main
_MOD = "python_pkg.anki_decks.polish_mountain_ranges.polish_mountain_ranges_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _ranges() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Tatry",
"area_km2": 175.0,
"geometry": Polygon(
[(19.7, 49.1), (20.2, 49.1), (20.2, 49.3), (19.7, 49.3)]
),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(self, processes=None, initializer=None, initargs=()) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(self, func, items):
return [func(item) for item in items]
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestCreateRangeMap:
"""Tests for create_range_map."""
def test_returns_figure(self) -> None:
fig = create_range_map(_ranges(), _boundary())
assert fig is not None
plt.close(fig)
class TestGenerateRangeImageBytes:
"""Tests for generate_range_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_range_image_bytes(_ranges(), _boundary())
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
assert "poland_boundary" in _mp_state
_mp_state.clear()
def test_render_single_range(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
geojson = _ranges().to_json()
name, data = _render_single_range(("Tatry", geojson))
assert name == "Tatry"
assert len(data) > 0
_mp_state.clear()
def test_render_not_initialized(self) -> None:
_mp_state.clear()
geojson = _ranges().to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_range(("Tatry", geojson))
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_ranges(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_ranges(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
_mp_state.clear()
def test_progress_reporting(self) -> None:
ranges = gpd.GeoDataFrame(
[
{
"name": f"Range{i}",
"area_km2": 200.0,
"geometry": Polygon([(19, 49), (20, 49), (20, 50), (19, 50)]),
}
for i in range(10)
],
crs="EPSG:4326",
)
with (
patch(f"{_MOD}.mp.Pool", _FakePool),
patch(f"{_MOD}.generate_range_image_bytes", return_value=b"PNG"),
):
package = generate_anki_package(ranges, _boundary())
assert len(package.decks[0].notes) == 10
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_mountain_ranges", return_value=_ranges()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_mountain_ranges", return_value=_ranges()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_mountain_ranges", return_value=_ranges()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -316,8 +316,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_parks = list(parks.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_parks)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_parks)} preview images to {preview_dir}...\n"
)
for _, row in preview_parks:
park_name = row["name"]

View File

@ -0,0 +1,228 @@
"""Tests for the Polish national parks Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Polygon
try:
from python_pkg.anki_decks.polish_national_parks.polish_national_parks_anki import (
_init_worker,
_mp_state,
_render_single_park,
create_park_map,
generate_anki_package,
generate_park_image_bytes,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.polish_national_parks.polish_national_parks_anki import (
_init_worker,
_mp_state,
_render_single_park,
create_park_map,
generate_anki_package,
generate_park_image_bytes,
main,
)
_MOD = "python_pkg.anki_decks.polish_national_parks.polish_national_parks_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _large_park() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Bieszczadzki",
"area_km2": 292.0,
"geometry": Polygon([(22, 49), (22.5, 49), (22.5, 49.5), (22, 49.5)]),
},
],
crs="EPSG:4326",
)
def _small_park() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Ojcowski",
"area_km2": 21.0,
"geometry": Polygon(
[(19.8, 50.2), (19.9, 50.2), (19.9, 50.3), (19.8, 50.3)]
),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(self, processes=None, initializer=None, initargs=()) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(self, func, items):
return [func(item) for item in items]
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestCreateParkMap:
"""Tests for create_park_map - small/large park branches."""
def test_large_park_no_marker(self) -> None:
fig = create_park_map(_large_park(), _boundary())
assert fig is not None
plt.close(fig)
def test_small_park_has_marker(self) -> None:
fig = create_park_map(_small_park(), _boundary())
assert fig is not None
plt.close(fig)
class TestGenerateParkImageBytes:
"""Tests for generate_park_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_park_image_bytes(_large_park(), _boundary())
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
assert "poland_boundary" in _mp_state
_mp_state.clear()
def test_render_single_park(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
geojson = _large_park().to_json()
name, data = _render_single_park(("Bieszczadzki", geojson))
assert name == "Bieszczadzki"
assert len(data) > 0
_mp_state.clear()
def test_render_not_initialized(self) -> None:
_mp_state.clear()
geojson = _large_park().to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_park(("Bieszczadzki", geojson))
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_large_park(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_large_park(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
_mp_state.clear()
def test_progress_reporting(self) -> None:
parks = gpd.GeoDataFrame(
[
{
"name": f"Park{i}",
"area_km2": 200.0,
"geometry": Polygon([(20, 51), (21, 51), (21, 52), (20, 52)]),
}
for i in range(10)
],
crs="EPSG:4326",
)
with (
patch(f"{_MOD}.mp.Pool", _FakePool),
patch(f"{_MOD}.generate_park_image_bytes", return_value=b"PNG"),
):
package = generate_anki_package(parks, _boundary())
assert len(package.decks[0].notes) == 10
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_national_parks", return_value=_large_park()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_national_parks", return_value=_large_park()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_national_parks", return_value=_large_park()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -0,0 +1,208 @@
"""Tests for the Polish nature reserves Anki generator."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Polygon
import python_pkg.anki_decks.polish_nature_reserves.polish_nature_reserves_anki as _mod
if TYPE_CHECKING:
from pathlib import Path
_init_worker = _mod._init_worker
_mp_state = _mod._mp_state
_render_single_reserve = _mod._render_single_reserve
create_reserve_map = _mod.create_reserve_map
generate_anki_package = _mod.generate_anki_package
generate_reserve_image_bytes = _mod.generate_reserve_image_bytes
main = _mod.main
_MOD = "python_pkg.anki_decks.polish_nature_reserves.polish_nature_reserves_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _reserves() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Rezerwat A",
"area_km2": 0.5,
"geometry": Polygon([(17, 51), (17.1, 51), (17.1, 51.1), (17, 51.1)]),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(self, processes=None, initializer=None, initargs=()) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(self, func, items):
return [func(item) for item in items]
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestCreateReserveMap:
"""Tests for create_reserve_map."""
def test_returns_figure(self) -> None:
fig = create_reserve_map(_reserves(), _boundary())
assert fig is not None
plt.close(fig)
class TestGenerateReserveImageBytes:
"""Tests for generate_reserve_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_reserve_image_bytes(_reserves(), _boundary())
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
assert "poland_boundary" in _mp_state
_mp_state.clear()
def test_render_single_reserve(self, tmp_path: Path) -> None:
path = str(tmp_path / "boundary.geojson")
_boundary().to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
geojson = _reserves().to_json()
name, data = _render_single_reserve(("Rezerwat A", geojson))
assert name == "Rezerwat A"
assert len(data) > 0
_mp_state.clear()
def test_render_not_initialized(self) -> None:
_mp_state.clear()
geojson = _reserves().to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_reserve(("Rezerwat A", geojson))
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_reserves(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_reserves(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
_mp_state.clear()
def test_progress_reporting(self) -> None:
reserves = gpd.GeoDataFrame(
[
{
"name": f"Reserve{i}",
"area_km2": 50.0,
"geometry": Polygon([(17, 51), (18, 51), (18, 52), (17, 52)]),
}
for i in range(100)
],
crs="EPSG:4326",
)
with (
patch(f"{_MOD}.mp.Pool", _FakePool),
patch(f"{_MOD}.generate_reserve_image_bytes", return_value=b"PNG"),
):
package = generate_anki_package(reserves, _boundary())
assert len(package.decks[0].notes) == 100
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_nature_reserves", return_value=_reserves()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_limit(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_nature_reserves", return_value=_reserves()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out), "--limit", "1"])
assert result == 0
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_nature_reserves", return_value=_reserves()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_nature_reserves", return_value=_reserves()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -278,8 +278,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_powiaty = list(powiaty.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_powiaty)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_powiaty)} preview images to {preview_dir}...\n"
)
for _, row in preview_powiaty:
powiat_name = row["nazwa"]

View File

@ -0,0 +1,133 @@
"""Tests for the Polish powiaty Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Polygon
try:
from python_pkg.anki_decks.polish_powiaty.polish_powiaty_anki import (
create_powiat_map,
generate_anki_package,
generate_powiat_image_bytes,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.polish_powiaty.polish_powiaty_anki import (
create_powiat_map,
generate_anki_package,
generate_powiat_image_bytes,
main,
)
_MOD = "python_pkg.anki_decks.polish_powiaty.polish_powiaty_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _powiaty() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"nazwa": "powiat testowy",
"geometry": Polygon([(16, 51), (17, 51), (17, 52), (16, 52)]),
},
],
crs="EPSG:4326",
)
class TestCreatePowiatMap:
"""Tests for create_powiat_map."""
def test_returns_figure(self) -> None:
powiaty = _powiaty()
fig = create_powiat_map("powiat testowy", powiaty, _boundary(), powiaty)
assert fig is not None
plt.close(fig)
class TestGeneratePowiatImageBytes:
"""Tests for generate_powiat_image_bytes."""
def test_returns_bytes(self) -> None:
powiaty = _powiaty()
data = generate_powiat_image_bytes(
"powiat testowy", powiaty, _boundary(), powiaty
)
assert isinstance(data, bytes)
assert len(data) > 0
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
package = generate_anki_package(_powiaty(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
def test_custom_deck_name(self) -> None:
package = generate_anki_package(_powiaty(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_powiaty", return_value=_powiaty()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_powiaty", return_value=_powiaty()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_powiaty", return_value=_powiaty()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -325,8 +325,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_rivers = list(rivers.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_rivers)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_rivers)} preview images to {preview_dir}...\n"
)
for _, row in preview_rivers:
river_name = row["name"]

View File

@ -0,0 +1,243 @@
"""Tests for the Polish rivers Anki generator."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import LineString, Polygon
from typing_extensions import Self
try:
from python_pkg.anki_decks.polish_rivers.polish_rivers_anki import (
_init_worker,
_mp_state,
_render_single_river,
create_river_map,
generate_anki_package,
generate_river_image_bytes,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.polish_rivers.polish_rivers_anki import (
_init_worker,
_mp_state,
_render_single_river,
create_river_map,
generate_anki_package,
generate_river_image_bytes,
main,
)
_MOD = "python_pkg.anki_decks.polish_rivers.polish_rivers_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _river_inside() -> gpd.GeoDataFrame:
"""River that fits inside Poland."""
return gpd.GeoDataFrame(
[
{
"name": "TestRiver",
"length_km": 150.0,
"geometry": LineString([(18, 51), (19, 52), (20, 53)]),
},
],
crs="EPSG:4326",
)
def _river_outside() -> gpd.GeoDataFrame:
"""River that extends beyond Poland's borders."""
return gpd.GeoDataFrame(
[
{
"name": "BigRiver",
"length_km": 800.0,
"geometry": LineString([(13, 51), (18, 52), (25, 53)]),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(
self,
processes: int | None = None,
initializer: Any = None,
initargs: tuple[Any, ...] = (),
) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(
self,
func: Any,
items: Any,
) -> list[Any]:
return [func(item) for item in items]
def __enter__(self) -> Self:
return self
def __exit__(self, *a: object) -> None:
pass
class TestCreateRiverMap:
"""Tests for create_river_map."""
def test_river_inside_poland(self) -> None:
fig = create_river_map(_river_inside(), _boundary())
assert fig is not None
plt.close(fig)
def test_river_extends_beyond(self) -> None:
fig = create_river_map(_river_outside(), _boundary())
assert fig is not None
plt.close(fig)
class TestGenerateRiverImageBytes:
"""Tests for generate_river_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_river_image_bytes(_river_inside(), _boundary())
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker(self, tmp_path: Path) -> None:
boundary = _boundary()
path = str(tmp_path / "boundary.geojson")
boundary.to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
assert "poland_boundary" in _mp_state
_mp_state.clear()
def test_render_single_river(self, tmp_path: Path) -> None:
boundary = _boundary()
path = str(tmp_path / "boundary.geojson")
boundary.to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
river = _river_inside()
geojson = river.to_json()
name, data = _render_single_river(("TestRiver", geojson))
assert name == "TestRiver"
assert len(data) > 0
_mp_state.clear()
def test_render_not_initialized(self) -> None:
_mp_state.clear()
river = _river_inside()
geojson = river.to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_river(("TestRiver", geojson))
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_river_inside(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(
_river_inside(), _boundary(), "Custom Rivers"
)
assert package.decks[0].name == "Custom Rivers"
_mp_state.clear()
def test_progress_reporting(self) -> None:
"""Use 50 items to trigger the progress reporting branch."""
rivers = gpd.GeoDataFrame(
[
{
"name": f"River{i}",
"length_km": 100.0 + i,
"geometry": LineString([(18, 51 + i * 0.01), (19, 52)]),
}
for i in range(50)
],
crs="EPSG:4326",
)
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(rivers, _boundary())
assert len(package.decks[0].notes) == 50
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_rivers", return_value=_river_inside()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_rivers", return_value=_river_inside()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_rivers", return_value=_river_inside()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -333,8 +333,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_sites = list(sites.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_sites)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_sites)} preview images to {preview_dir}...\n"
)
for _, row in preview_sites:
site_name = row["name"]

View File

@ -0,0 +1,244 @@
"""Tests for the Polish UNESCO sites Anki generator."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Point, Polygon
from typing_extensions import Self
try:
from python_pkg.anki_decks.polish_unesco_sites.polish_unesco_sites_anki import (
_init_worker,
_mp_state,
_render_single_site,
create_unesco_map,
generate_anki_package,
generate_unesco_image_bytes,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.polish_unesco_sites.polish_unesco_sites_anki import (
_init_worker,
_mp_state,
_render_single_site,
create_unesco_map,
generate_anki_package,
generate_unesco_image_bytes,
main,
)
_MOD = "python_pkg.anki_decks.polish_unesco_sites.polish_unesco_sites_anki"
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[Polygon([(14, 49), (24, 49), (24, 55), (14, 55)])],
crs="EPSG:4326",
)
def _site_point() -> gpd.GeoDataFrame:
"""UNESCO site with Point geometry."""
return gpd.GeoDataFrame(
[
{
"name": "PointSite",
"inscribed_year": 1978,
"category": "Cultural",
"geometry": Point(20, 52),
},
],
crs="EPSG:4326",
)
def _site_polygon() -> gpd.GeoDataFrame:
"""UNESCO site with Polygon geometry (centroid branch)."""
return gpd.GeoDataFrame(
[
{
"name": "PolygonSite",
"inscribed_year": 2003,
"category": "Natural",
"geometry": Polygon([(19, 51), (20, 51), (20, 52), (19, 52)]),
},
],
crs="EPSG:4326",
)
class _FakePool:
def __init__(
self,
processes: int | None = None,
initializer: Any = None,
initargs: tuple[Any, ...] = (),
) -> None:
if initializer:
initializer(*initargs)
def imap_unordered(
self,
func: Any,
items: Any,
) -> list[Any]:
return [func(item) for item in items]
def __enter__(self) -> Self:
return self
def __exit__(self, *a: object) -> None:
pass
class TestCreateUnescoMap:
"""Tests for create_unesco_map."""
def test_point_geometry(self) -> None:
fig = create_unesco_map(_site_point(), _boundary())
assert fig is not None
plt.close(fig)
def test_polygon_geometry_uses_centroid(self) -> None:
fig = create_unesco_map(_site_polygon(), _boundary())
assert fig is not None
plt.close(fig)
class TestGenerateUnescoImageBytes:
"""Tests for generate_unesco_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_unesco_image_bytes(_site_point(), _boundary())
assert isinstance(data, bytes)
assert len(data) > 0
class TestWorkers:
"""Tests for multiprocessing worker functions."""
def test_init_worker(self, tmp_path: Path) -> None:
boundary = _boundary()
path = str(tmp_path / "boundary.geojson")
boundary.to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
assert "poland_boundary" in _mp_state
_mp_state.clear()
def test_render_single_site(self, tmp_path: Path) -> None:
boundary = _boundary()
path = str(tmp_path / "boundary.geojson")
boundary.to_file(path, driver="GeoJSON")
_mp_state.clear()
_init_worker(path)
site = _site_point()
geojson = site.to_json()
name, data = _render_single_site(("PointSite", geojson))
assert name == "PointSite"
assert len(data) > 0
_mp_state.clear()
def test_render_not_initialized(self) -> None:
_mp_state.clear()
site = _site_point()
geojson = site.to_json()
with pytest.raises(RuntimeError, match="Worker not initialized"):
_render_single_site(("PointSite", geojson))
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_site_point(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
_mp_state.clear()
def test_custom_deck_name(self) -> None:
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(_site_point(), _boundary(), "Custom UNESCO")
assert package.decks[0].name == "Custom UNESCO"
_mp_state.clear()
def test_progress_reporting(self) -> None:
"""Use 5 items to trigger the progress reporting branch."""
sites = gpd.GeoDataFrame(
[
{
"name": f"Site{i}",
"inscribed_year": 2000 + i,
"category": "Cultural",
"geometry": Point(19 + i * 0.1, 51),
}
for i in range(5)
],
crs="EPSG:4326",
)
with patch(f"{_MOD}.mp.Pool", _FakePool):
package = generate_anki_package(sites, _boundary())
assert len(package.decks[0].notes) == 5
_mp_state.clear()
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_polish_unesco_sites", return_value=_site_point()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
_mp_state.clear()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_polish_unesco_sites", return_value=_site_point()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.mp.Pool", _FakePool),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
_mp_state.clear()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_polish_unesco_sites", return_value=_site_point()),
patch(f"{_MOD}.get_poland_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -0,0 +1,198 @@
"""Tests for the Warsaw bridges Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import LineString, Polygon
import python_pkg.anki_decks.warsaw_bridges.warsaw_bridges_anki as _mod_ref
try:
from python_pkg.anki_decks.warsaw_bridges.warsaw_bridges_anki import (
create_bridge_map,
generate_anki_package,
generate_bridge_image_bytes,
load_warsaw_boundary,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.warsaw_bridges.warsaw_bridges_anki import (
create_bridge_map,
generate_anki_package,
generate_bridge_image_bytes,
load_warsaw_boundary,
main,
)
_MOD = "python_pkg.anki_decks.warsaw_bridges.warsaw_bridges_anki"
_WARSAW = Polygon([(20.8, 52.1), (21.2, 52.1), (21.2, 52.4), (20.8, 52.4)])
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(geometry=[_WARSAW], crs="EPSG:4326")
def _bridges() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Most Testowy",
"geometry": LineString([(20.9, 52.25), (21.1, 52.25)]),
},
],
crs="EPSG:4326",
)
def _vistula() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
geometry=[LineString([(21.0, 52.1), (21.0, 52.4)])],
crs="EPSG:4326",
)
class TestLoadWarsawBoundary:
"""Tests for load_warsaw_boundary."""
def test_with_warszawa_entry(self, tmp_path: Path) -> None:
districts_dir = tmp_path / "warsaw_districts"
districts_dir.mkdir()
gdf = gpd.GeoDataFrame(
[{"name": "Warszawa", "geometry": _WARSAW}],
crs="EPSG:4326",
)
gdf.to_file(str(districts_dir / "warszawa-dzielnice.geojson"), driver="GeoJSON")
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with patch.object(_mod_ref, "__file__", str(fake_file)):
result = load_warsaw_boundary()
assert len(result) == 1
def test_without_warszawa_dissolves(self, tmp_path: Path) -> None:
districts_dir = tmp_path / "warsaw_districts"
districts_dir.mkdir()
gdf = gpd.GeoDataFrame(
[
{
"name": "Mokotow",
"geometry": Polygon(
[
(20.8, 52.1),
(21.0, 52.1),
(21.0, 52.3),
(20.8, 52.3),
]
),
},
],
crs="EPSG:4326",
)
gdf.to_file(str(districts_dir / "warszawa-dzielnice.geojson"), driver="GeoJSON")
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with patch.object(_mod_ref, "__file__", str(fake_file)):
result = load_warsaw_boundary()
assert len(result) == 1
def test_file_not_found(self, tmp_path: Path) -> None:
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with (
patch.object(_mod_ref, "__file__", str(fake_file)),
pytest.raises(FileNotFoundError),
):
load_warsaw_boundary()
class TestCreateBridgeMap:
"""Tests for create_bridge_map."""
def test_returns_figure(self) -> None:
fig = create_bridge_map(_bridges(), _boundary(), _vistula())
assert fig is not None
plt.close(fig)
class TestGenerateBridgeImageBytes:
"""Tests for generate_bridge_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_bridge_image_bytes(_bridges(), _boundary(), _vistula())
assert isinstance(data, bytes)
assert len(data) > 0
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
package = generate_anki_package(_bridges(), _boundary(), _vistula())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
def test_custom_deck_name(self) -> None:
package = generate_anki_package(_bridges(), _boundary(), _vistula(), "Custom")
assert package.decks[0].name == "Custom"
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_warsaw_bridges", return_value=_bridges()),
patch(f"{_MOD}.get_vistula_river", return_value=_vistula()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_warsaw_bridges", return_value=_bridges()),
patch(f"{_MOD}.get_vistula_river", return_value=_vistula()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_warsaw_bridges", return_value=_bridges()),
patch(f"{_MOD}.get_vistula_river", return_value=_vistula()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -286,8 +286,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_bridges = list(bridges.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_bridges)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_bridges)} preview images to {preview_dir}...\n"
)
for _, row in preview_bridges:
bridge_name = row["name"]

View File

@ -3,6 +3,7 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import matplotlib.pyplot as plt
import pytest
@ -13,6 +14,7 @@ try:
create_district_map,
generate_anki_package,
generate_district_image_bytes,
load_district_data,
main,
)
except ImportError:
@ -24,6 +26,7 @@ except ImportError:
create_district_map,
generate_anki_package,
generate_district_image_bytes,
load_district_data,
main,
)
@ -170,6 +173,41 @@ class TestMain:
main(["--help"])
assert exc_info.value.code == 0
def test_main_error_returns_1(self, tmp_path: Path) -> None:
"""Test that main returns 1 on error."""
with patch(
"python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki"
".generate_anki_package",
side_effect=OSError("disk full"),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
class TestLoadDistrictData:
"""Tests for load_district_data."""
def test_missing_geojson_raises_file_not_found(self, tmp_path: Path) -> None:
"""Test FileNotFoundError when GeoJSON file is missing."""
with (
patch(
"python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki"
".GEOJSON_PATH",
tmp_path / "nonexistent.geojson",
),
pytest.raises(FileNotFoundError, match="GeoJSON file not found"),
):
load_district_data()
class TestCreateDistrictMapErrors:
"""Tests for create_district_map error paths."""
def test_unknown_district_raises_value_error(self) -> None:
"""Test ValueError when district name is not found."""
with pytest.raises(ValueError, match="not found in data"):
create_district_map("NonexistentDistrict123")
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -0,0 +1,182 @@
"""Tests for the Warsaw landmarks Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Point, Polygon
import python_pkg.anki_decks.warsaw_landmarks.warsaw_landmarks_anki as _mod_ref
try:
from python_pkg.anki_decks.warsaw_landmarks.warsaw_landmarks_anki import (
create_landmark_map,
generate_anki_package,
generate_landmark_image_bytes,
load_warsaw_boundary,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.warsaw_landmarks.warsaw_landmarks_anki import (
create_landmark_map,
generate_anki_package,
generate_landmark_image_bytes,
load_warsaw_boundary,
main,
)
_MOD = "python_pkg.anki_decks.warsaw_landmarks.warsaw_landmarks_anki"
_WARSAW = Polygon([(20.8, 52.1), (21.2, 52.1), (21.2, 52.4), (20.8, 52.4)])
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(geometry=[_WARSAW], crs="EPSG:4326")
def _landmarks() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[{"name": "Palac Kultury", "geometry": Point(21.0, 52.23)}],
crs="EPSG:4326",
)
class TestLoadWarsawBoundary:
"""Tests for load_warsaw_boundary."""
def test_with_warszawa_entry(self, tmp_path: Path) -> None:
districts_dir = tmp_path / "warsaw_districts"
districts_dir.mkdir()
gdf = gpd.GeoDataFrame(
[{"name": "Warszawa", "geometry": _WARSAW}], crs="EPSG:4326"
)
gdf.to_file(str(districts_dir / "warszawa-dzielnice.geojson"), driver="GeoJSON")
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with patch.object(_mod_ref, "__file__", str(fake_file)):
result = load_warsaw_boundary()
assert len(result) == 1
def test_without_warszawa_dissolves(self, tmp_path: Path) -> None:
districts_dir = tmp_path / "warsaw_districts"
districts_dir.mkdir()
gdf = gpd.GeoDataFrame(
[
{
"name": "Mokotow",
"geometry": Polygon(
[
(20.8, 52.1),
(21.0, 52.1),
(21.0, 52.3),
(20.8, 52.3),
]
),
},
],
crs="EPSG:4326",
)
gdf.to_file(str(districts_dir / "warszawa-dzielnice.geojson"), driver="GeoJSON")
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with patch.object(_mod_ref, "__file__", str(fake_file)):
result = load_warsaw_boundary()
assert len(result) == 1
def test_file_not_found(self, tmp_path: Path) -> None:
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with (
patch.object(_mod_ref, "__file__", str(fake_file)),
pytest.raises(FileNotFoundError),
):
load_warsaw_boundary()
class TestCreateLandmarkMap:
"""Tests for create_landmark_map."""
def test_returns_figure(self) -> None:
fig = create_landmark_map(_landmarks(), _boundary())
assert fig is not None
plt.close(fig)
class TestGenerateLandmarkImageBytes:
"""Tests for generate_landmark_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_landmark_image_bytes(_landmarks(), _boundary())
assert isinstance(data, bytes)
assert len(data) > 0
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
package = generate_anki_package(_landmarks(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
def test_custom_deck_name(self) -> None:
package = generate_anki_package(_landmarks(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_warsaw_landmarks", return_value=_landmarks()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_warsaw_landmarks", return_value=_landmarks()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_warsaw_landmarks", return_value=_landmarks()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -0,0 +1,182 @@
"""Tests for the Warsaw metro stations Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Point, Polygon
import python_pkg.anki_decks.warsaw_metro.warsaw_metro_anki as _mod_ref
try:
from python_pkg.anki_decks.warsaw_metro.warsaw_metro_anki import (
create_station_map,
generate_anki_package,
generate_station_image_bytes,
load_warsaw_boundary,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.warsaw_metro.warsaw_metro_anki import (
create_station_map,
generate_anki_package,
generate_station_image_bytes,
load_warsaw_boundary,
main,
)
_MOD = "python_pkg.anki_decks.warsaw_metro.warsaw_metro_anki"
_WARSAW = Polygon([(20.8, 52.1), (21.2, 52.1), (21.2, 52.4), (20.8, 52.4)])
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(geometry=[_WARSAW], crs="EPSG:4326")
def _stations() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[{"name": "Centrum", "line": "M1", "geometry": Point(21.0, 52.23)}],
crs="EPSG:4326",
)
class TestLoadWarsawBoundary:
"""Tests for load_warsaw_boundary."""
def test_with_warszawa_entry(self, tmp_path: Path) -> None:
districts_dir = tmp_path / "warsaw_districts"
districts_dir.mkdir()
gdf = gpd.GeoDataFrame(
[{"name": "Warszawa", "geometry": _WARSAW}], crs="EPSG:4326"
)
gdf.to_file(str(districts_dir / "warszawa-dzielnice.geojson"), driver="GeoJSON")
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with patch.object(_mod_ref, "__file__", str(fake_file)):
result = load_warsaw_boundary()
assert len(result) == 1
def test_without_warszawa_dissolves(self, tmp_path: Path) -> None:
districts_dir = tmp_path / "warsaw_districts"
districts_dir.mkdir()
gdf = gpd.GeoDataFrame(
[
{
"name": "Mokotow",
"geometry": Polygon(
[
(20.8, 52.1),
(21.0, 52.1),
(21.0, 52.3),
(20.8, 52.3),
]
),
},
],
crs="EPSG:4326",
)
gdf.to_file(str(districts_dir / "warszawa-dzielnice.geojson"), driver="GeoJSON")
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with patch.object(_mod_ref, "__file__", str(fake_file)):
result = load_warsaw_boundary()
assert len(result) == 1
def test_file_not_found(self, tmp_path: Path) -> None:
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with (
patch.object(_mod_ref, "__file__", str(fake_file)),
pytest.raises(FileNotFoundError),
):
load_warsaw_boundary()
class TestCreateStationMap:
"""Tests for create_station_map."""
def test_returns_figure(self) -> None:
fig = create_station_map(_stations(), _boundary())
assert fig is not None
plt.close(fig)
class TestGenerateStationImageBytes:
"""Tests for generate_station_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_station_image_bytes(_stations(), _boundary())
assert isinstance(data, bytes)
assert len(data) > 0
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
package = generate_anki_package(_stations(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
def test_custom_deck_name(self) -> None:
package = generate_anki_package(_stations(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_warsaw_metro_stations", return_value=_stations()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_warsaw_metro_stations", return_value=_stations()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_warsaw_metro_stations", return_value=_stations()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -0,0 +1,198 @@
"""Tests for the Warsaw osiedla Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import Polygon
import python_pkg.anki_decks.warsaw_osiedla.warsaw_osiedla_anki as _mod_ref
try:
from python_pkg.anki_decks.warsaw_osiedla.warsaw_osiedla_anki import (
create_osiedle_map,
generate_anki_package,
generate_osiedle_image_bytes,
load_warsaw_boundary,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.warsaw_osiedla.warsaw_osiedla_anki import (
create_osiedle_map,
generate_anki_package,
generate_osiedle_image_bytes,
load_warsaw_boundary,
main,
)
_MOD = "python_pkg.anki_decks.warsaw_osiedla.warsaw_osiedla_anki"
_WARSAW = Polygon([(20.8, 52.1), (21.2, 52.1), (21.2, 52.4), (20.8, 52.4)])
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(geometry=[_WARSAW], crs="EPSG:4326")
def _osiedla() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
[
{
"name": "Stare Miasto",
"geometry": Polygon(
[
(20.9, 52.2),
(21.0, 52.2),
(21.0, 52.3),
(20.9, 52.3),
]
),
},
],
crs="EPSG:4326",
)
class TestLoadWarsawBoundary:
"""Tests for load_warsaw_boundary."""
def test_with_warszawa_entry(self, tmp_path: Path) -> None:
districts_dir = tmp_path / "warsaw_districts"
districts_dir.mkdir()
gdf = gpd.GeoDataFrame(
[{"name": "Warszawa", "geometry": _WARSAW}], crs="EPSG:4326"
)
gdf.to_file(str(districts_dir / "warszawa-dzielnice.geojson"), driver="GeoJSON")
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with patch.object(_mod_ref, "__file__", str(fake_file)):
result = load_warsaw_boundary()
assert len(result) == 1
def test_without_warszawa_dissolves(self, tmp_path: Path) -> None:
districts_dir = tmp_path / "warsaw_districts"
districts_dir.mkdir()
gdf = gpd.GeoDataFrame(
[
{
"name": "Mokotow",
"geometry": Polygon(
[
(20.8, 52.1),
(21.0, 52.1),
(21.0, 52.3),
(20.8, 52.3),
]
),
},
],
crs="EPSG:4326",
)
gdf.to_file(str(districts_dir / "warszawa-dzielnice.geojson"), driver="GeoJSON")
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with patch.object(_mod_ref, "__file__", str(fake_file)):
result = load_warsaw_boundary()
assert len(result) == 1
def test_file_not_found(self, tmp_path: Path) -> None:
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with (
patch.object(_mod_ref, "__file__", str(fake_file)),
pytest.raises(FileNotFoundError),
):
load_warsaw_boundary()
class TestCreateOsiedleMap:
"""Tests for create_osiedle_map."""
def test_returns_figure(self) -> None:
osiedla = _osiedla()
fig = create_osiedle_map("Stare Miasto", osiedla, _boundary(), osiedla)
assert fig is not None
plt.close(fig)
class TestGenerateOsiedleImageBytes:
"""Tests for generate_osiedle_image_bytes."""
def test_returns_bytes(self) -> None:
osiedla = _osiedla()
data = generate_osiedle_image_bytes(
"Stare Miasto", osiedla, _boundary(), osiedla
)
assert isinstance(data, bytes)
assert len(data) > 0
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
package = generate_anki_package(_osiedla(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
def test_custom_deck_name(self) -> None:
package = generate_anki_package(_osiedla(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with (
patch(f"{_MOD}.get_warsaw_osiedla", return_value=_osiedla()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with (
patch(f"{_MOD}.get_warsaw_osiedla", return_value=_osiedla()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
def test_error_returns_1(self, tmp_path: Path) -> None:
with (
patch(f"{_MOD}.get_warsaw_osiedla", return_value=_osiedla()),
patch(f"{_MOD}.load_warsaw_boundary", return_value=_boundary()),
patch(f"{_MOD}.generate_anki_package", side_effect=OSError("fail")),
):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -295,8 +295,7 @@ def main(argv: Sequence[str] | None = None) -> int:
preview_dir.mkdir(parents=True, exist_ok=True)
preview_osiedla = list(osiedla.iterrows())[: args.preview_count]
sys.stdout.write(
f"Exporting {len(preview_osiedla)} preview images "
f"to {preview_dir}...\n"
f"Exporting {len(preview_osiedla)} preview images to {preview_dir}...\n"
)
for _, row in preview_osiedla:
osiedle_name = row["name"]

View File

@ -0,0 +1,255 @@
"""Tests for the Warsaw streets Anki generator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import geopandas as gpd
import matplotlib.pyplot as plt
import pytest
from shapely.geometry import LineString, Polygon
import python_pkg.anki_decks.warsaw_streets.warsaw_streets_anki as _mod_ref
try:
from python_pkg.anki_decks.warsaw_streets.warsaw_streets_anki import (
create_street_map,
generate_anki_package,
generate_street_image_bytes,
get_unique_streets,
load_street_data,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from python_pkg.anki_decks.warsaw_streets.warsaw_streets_anki import (
create_street_map,
generate_anki_package,
generate_street_image_bytes,
get_unique_streets,
load_street_data,
main,
)
_MOD = "python_pkg.anki_decks.warsaw_streets.warsaw_streets_anki"
_WARSAW = Polygon([(20.8, 52.1), (21.2, 52.1), (21.2, 52.4), (20.8, 52.4)])
def _boundary() -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(geometry=[_WARSAW], crs="EPSG:4326")
def _street_gdf() -> gpd.GeoDataFrame:
"""A single street GeoDataFrame for map/image tests."""
return gpd.GeoDataFrame(
[
{
"name": "Marszalkowska",
"geometry": LineString([(21.0, 52.2), (21.0, 52.35)]),
},
],
crs="EPSG:4326",
)
def _street_segments_gdf() -> gpd.GeoDataFrame:
"""Street segments with various branches for get_unique_streets tests."""
return gpd.GeoDataFrame(
[
# Two segments of the same long street → MultiLineString merge
{
"name": "Marszalkowska",
"geometry": LineString([(21.0, 52.2), (21.0, 52.3)]),
},
{
"name": "Marszalkowska",
"geometry": LineString([(21.0, 52.3), (21.0, 52.4)]),
},
# Single segment street (long enough)
{
"name": "Nowy Swiat",
"geometry": LineString([(21.01, 52.2), (21.01, 52.35)]),
},
# Short street (should be filtered out by MIN_STREET_LENGTH)
{
"name": "Krotka",
"geometry": LineString([(21.02, 52.25), (21.02, 52.2501)]),
},
# "Unknown" name (should be filtered)
{
"name": "Unknown",
"geometry": LineString([(21.03, 52.2), (21.03, 52.35)]),
},
# None name (should be filtered)
{
"name": None,
"geometry": LineString([(21.04, 52.2), (21.04, 52.35)]),
},
],
crs="EPSG:4326",
)
def _streets_list() -> list[tuple[str, gpd.GeoDataFrame, float]]:
"""Pre-built streets list for generate_anki_package tests."""
return [
("Marszalkowska", _street_gdf(), 5000.0),
]
class TestGetUniqueStreets:
"""Tests for get_unique_streets."""
def test_merges_segments_and_filters(self) -> None:
result = get_unique_streets(_street_segments_gdf())
names = [name for name, _, _ in result]
# "Unknown" and None should be filtered
assert "Unknown" not in names
# "Krotka" should be filtered (too short)
assert "Krotka" not in names
# Long streets should be present
assert "Marszalkowska" in names
assert "Nowy Swiat" in names
# Sorted by length descending
lengths = [length for _, _, length in result]
assert lengths == sorted(lengths, reverse=True)
class TestLoadStreetData:
"""Tests for load_street_data."""
def test_with_warszawa_entry(self, tmp_path: Path) -> None:
districts_dir = tmp_path / "warsaw_districts"
districts_dir.mkdir()
gdf = gpd.GeoDataFrame(
[{"name": "Warszawa", "geometry": _WARSAW}], crs="EPSG:4326"
)
gdf.to_file(str(districts_dir / "warszawa-dzielnice.geojson"), driver="GeoJSON")
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with (
patch.object(_mod_ref, "__file__", str(fake_file)),
patch(f"{_MOD}.get_warsaw_streets", return_value=_street_segments_gdf()),
):
streets, boundary = load_street_data()
assert len(boundary) == 1
assert len(streets) > 0
def test_without_warszawa_dissolves(self, tmp_path: Path) -> None:
districts_dir = tmp_path / "warsaw_districts"
districts_dir.mkdir()
gdf = gpd.GeoDataFrame(
[
{
"name": "Mokotow",
"geometry": Polygon(
[
(20.8, 52.1),
(21.0, 52.1),
(21.0, 52.3),
(20.8, 52.3),
]
),
},
],
crs="EPSG:4326",
)
gdf.to_file(str(districts_dir / "warszawa-dzielnice.geojson"), driver="GeoJSON")
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with (
patch.object(_mod_ref, "__file__", str(fake_file)),
patch(f"{_MOD}.get_warsaw_streets", return_value=_street_segments_gdf()),
):
streets, boundary = load_street_data()
assert len(boundary) == 1
def test_file_not_found(self, tmp_path: Path) -> None:
fake_file = tmp_path / "subdir" / "module.py"
fake_file.parent.mkdir(parents=True, exist_ok=True)
fake_file.touch()
with (
patch.object(_mod_ref, "__file__", str(fake_file)),
patch(f"{_MOD}.get_warsaw_streets", return_value=_street_segments_gdf()),
pytest.raises(FileNotFoundError),
):
load_street_data()
class TestCreateStreetMap:
"""Tests for create_street_map."""
def test_returns_figure(self) -> None:
fig = create_street_map(_street_gdf(), _boundary())
assert fig is not None
plt.close(fig)
class TestGenerateStreetImageBytes:
"""Tests for generate_street_image_bytes."""
def test_returns_bytes(self) -> None:
data = generate_street_image_bytes(_street_gdf(), _boundary())
assert isinstance(data, bytes)
assert len(data) > 0
class TestGenerateAnkiPackage:
"""Tests for generate_anki_package."""
def test_generates_package(self) -> None:
package = generate_anki_package(_streets_list(), _boundary())
assert len(package.decks) == 1
assert len(package.decks[0].notes) == 1
def test_custom_deck_name(self) -> None:
package = generate_anki_package(_streets_list(), _boundary(), "Custom")
assert package.decks[0].name == "Custom"
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
with patch(
f"{_MOD}.load_street_data", return_value=(_streets_list(), _boundary())
):
result = main(["--output", str(out)])
assert result == 0
assert out.exists()
def test_preview(self, tmp_path: Path) -> None:
out = tmp_path / "out.apkg"
preview = tmp_path / "preview"
with patch(
f"{_MOD}.load_street_data", return_value=(_streets_list(), _boundary())
):
result = main(
[
"--output",
str(out),
"--preview",
str(preview),
"--preview-count",
"1",
]
)
assert result == 0
assert preview.exists()
def test_error_returns_1(self, tmp_path: Path) -> None:
with patch(f"{_MOD}.load_street_data", side_effect=OSError("fail")):
result = main(["--output", str(tmp_path / "out.apkg")])
assert result == 1
def test_help(self) -> None:
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0

View File

@ -80,9 +80,9 @@ def get_unique_streets(
return result
def load_street_data() -> (
tuple[list[tuple[str, gpd.GeoDataFrame, float]], gpd.GeoDataFrame]
):
def load_street_data() -> tuple[
list[tuple[str, gpd.GeoDataFrame, float]], gpd.GeoDataFrame
]:
"""Load Warsaw streets and boundary.
Returns:

View File

View File

@ -30,7 +30,7 @@ def _req(
def test_crud_roundtrip(tmp_path: Path) -> None:
"""Test full CRUD lifecycle for articles API."""
# Build C server
here = Path(__file__).resolve().parent
here = Path(__file__).resolve().parent.parent
subprocess.run(["make", "-s", "server_c"], check=True, cwd=str(here))
# Find a free port
@ -100,6 +100,7 @@ def test_crud_roundtrip(tmp_path: Path) -> None:
with pytest.raises(urllib.error.HTTPError) as exc_info:
_req(base + f"/api/articles/{art_id}")
assert exc_info.value.code == HTTPStatus.NOT_FOUND
exc_info.value.close()
finally:
srv.terminate()

View File

@ -5,7 +5,7 @@ from pathlib import Path
# Budget for the entire website (single file) in bytes
BUDGET = 14 * 1024 # 14 KiB
HERE = Path(__file__).parent
HERE = Path(__file__).parent.parent
SITE_FILE = HERE / "index.html"

View File

@ -0,0 +1,222 @@
"""Tests for auto_brightness_daemon module."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.brightness_controller import auto_brightness_daemon
# ── _find_als_device ─────────────────────────────────────────────────────
class TestFindAlsDevice:
"""Tests for _find_als_device."""
@patch.object(
Path,
"glob",
return_value=[Path("/sys/bus/iio/devices/iio0/in_illuminance_raw")],
)
def test_found(self, _mock_glob: MagicMock) -> None:
result = auto_brightness_daemon._find_als_device()
assert result == Path("/sys/bus/iio/devices/iio0")
@patch.object(Path, "glob", return_value=[])
def test_not_found(self, _mock_glob: MagicMock) -> None:
assert auto_brightness_daemon._find_als_device() is None
# ── _read_lux ────────────────────────────────────────────────────────────
class TestReadLux:
"""Tests for _read_lux."""
def test_basic_read(self, tmp_path: Path) -> None:
(tmp_path / "in_illuminance_raw").write_text("100\n")
(tmp_path / "in_illuminance_scale").write_text("2.0\n")
(tmp_path / "in_illuminance_offset").write_text("5.0\n")
result = auto_brightness_daemon._read_lux(tmp_path)
assert result == pytest.approx((100 + 5.0) * 2.0)
def test_missing_scale(self, tmp_path: Path) -> None:
(tmp_path / "in_illuminance_raw").write_text("50\n")
# No scale file → default 1.0
(tmp_path / "in_illuminance_offset").write_text("0\n")
result = auto_brightness_daemon._read_lux(tmp_path)
assert result == pytest.approx(50.0)
def test_missing_offset(self, tmp_path: Path) -> None:
(tmp_path / "in_illuminance_raw").write_text("50\n")
(tmp_path / "in_illuminance_scale").write_text("1.0\n")
# No offset file → default 0.0
result = auto_brightness_daemon._read_lux(tmp_path)
assert result == pytest.approx(50.0)
def test_invalid_scale_value(self, tmp_path: Path) -> None:
(tmp_path / "in_illuminance_raw").write_text("50\n")
(tmp_path / "in_illuminance_scale").write_text("bad\n")
(tmp_path / "in_illuminance_offset").write_text("0\n")
result = auto_brightness_daemon._read_lux(tmp_path)
assert result == pytest.approx(50.0)
def test_invalid_offset_value(self, tmp_path: Path) -> None:
(tmp_path / "in_illuminance_raw").write_text("50\n")
(tmp_path / "in_illuminance_scale").write_text("1.0\n")
(tmp_path / "in_illuminance_offset").write_text("bad\n")
result = auto_brightness_daemon._read_lux(tmp_path)
assert result == pytest.approx(50.0)
# ── _lux_to_brightness ──────────────────────────────────────────────────
class TestLuxToBrightness:
"""Tests for _lux_to_brightness."""
def test_below_minimum(self) -> None:
assert auto_brightness_daemon._lux_to_brightness(-10.0) == 10
def test_at_minimum(self) -> None:
assert auto_brightness_daemon._lux_to_brightness(0.0) == 10
def test_above_maximum(self) -> None:
assert auto_brightness_daemon._lux_to_brightness(10000.0) == 100
def test_at_maximum(self) -> None:
assert auto_brightness_daemon._lux_to_brightness(5000.0) == 100
def test_interpolation_mid(self) -> None:
result = auto_brightness_daemon._lux_to_brightness(27.5)
assert result == 57
def test_interpolation_first_segment(self) -> None:
result = auto_brightness_daemon._lux_to_brightness(2.5)
assert result == 25
def test_fallback_return(self) -> None:
"""Exercise the post-loop fallback (unreachable with monotonic curves)."""
nan = float("nan")
with patch.object(
auto_brightness_daemon,
"LUX_CURVE",
[(nan, 10), (nan, 99)],
):
assert auto_brightness_daemon._lux_to_brightness(50.0) == 99
# ── _get_brightness ──────────────────────────────────────────────────────
class TestGetBrightness:
"""Tests for _get_brightness."""
@patch("python_pkg.brightness_controller.auto_brightness_daemon.subprocess.run")
def test_valid_output(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="intel_backlight,backlight,50,42%,120000"
)
assert auto_brightness_daemon._get_brightness() == 42
@patch("python_pkg.brightness_controller.auto_brightness_daemon.subprocess.run")
def test_no_backlight_device(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="kbd_backlight,leds,0,0%,3")
assert auto_brightness_daemon._get_brightness() == -1
@patch("python_pkg.brightness_controller.auto_brightness_daemon.subprocess.run")
def test_too_few_fields(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="a,b,c")
assert auto_brightness_daemon._get_brightness() == -1
@patch("python_pkg.brightness_controller.auto_brightness_daemon.subprocess.run")
def test_empty_output(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="")
assert auto_brightness_daemon._get_brightness() == -1
# ── _set_brightness ──────────────────────────────────────────────────────
class TestSetBrightness:
"""Tests for _set_brightness."""
@patch("python_pkg.brightness_controller.auto_brightness_daemon.subprocess.run")
def test_calls_brightnessctl(self, mock_run: MagicMock) -> None:
auto_brightness_daemon._set_brightness(75)
mock_run.assert_called_once_with(
[auto_brightness_daemon._BRIGHTNESSCTL, "-q", "set", "75%"],
check=False,
)
# ── _is_enabled ──────────────────────────────────────────────────────────
class TestIsEnabled:
"""Tests for _is_enabled."""
def test_enabled(self, tmp_path: Path) -> None:
enabled_file = tmp_path / "enabled"
enabled_file.write_text("1\n")
with patch.object(auto_brightness_daemon, "ENABLED_FILE", enabled_file):
assert auto_brightness_daemon._is_enabled() is True
def test_disabled(self, tmp_path: Path) -> None:
enabled_file = tmp_path / "enabled"
enabled_file.write_text("0\n")
with patch.object(auto_brightness_daemon, "ENABLED_FILE", enabled_file):
assert auto_brightness_daemon._is_enabled() is False
def test_missing_file(self, tmp_path: Path) -> None:
enabled_file = tmp_path / "nonexistent"
with patch.object(auto_brightness_daemon, "ENABLED_FILE", enabled_file):
assert auto_brightness_daemon._is_enabled() is False
# ── _set_enabled ─────────────────────────────────────────────────────────
class TestSetEnabled:
"""Tests for _set_enabled."""
def test_enable(self, tmp_path: Path) -> None:
config_dir = tmp_path / "config"
enabled_file = config_dir / "enabled"
with (
patch.object(auto_brightness_daemon, "CONFIG_DIR", config_dir),
patch.object(auto_brightness_daemon, "ENABLED_FILE", enabled_file),
):
auto_brightness_daemon._set_enabled(enabled=True)
assert enabled_file.read_text() == "1"
def test_disable(self, tmp_path: Path) -> None:
config_dir = tmp_path / "config"
enabled_file = config_dir / "enabled"
with (
patch.object(auto_brightness_daemon, "CONFIG_DIR", config_dir),
patch.object(auto_brightness_daemon, "ENABLED_FILE", enabled_file),
):
auto_brightness_daemon._set_enabled(enabled=False)
assert enabled_file.read_text() == "0"
# ── _clamp ───────────────────────────────────────────────────────────────
class TestClamp:
"""Tests for _clamp."""
def test_within_range(self) -> None:
assert auto_brightness_daemon._clamp(5, 0, 10) == 5
def test_below_low(self) -> None:
assert auto_brightness_daemon._clamp(-5, 0, 10) == 0
def test_above_high(self) -> None:
assert auto_brightness_daemon._clamp(15, 0, 10) == 10
# ── main ─────────────────────────────────────────────────────────────────

View File

@ -0,0 +1,251 @@
"""Tests for auto_brightness_daemon module - part 2 (main function)."""
from __future__ import annotations
import contextlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.brightness_controller import auto_brightness_daemon
MOD = "python_pkg.brightness_controller.auto_brightness_daemon"
class TestMainNoAls:
"""Tests for main() when no ALS device is found."""
@patch(f"{MOD}._find_als_device", return_value=None)
def test_exits_when_no_als(self, _mock_find: MagicMock) -> None:
with pytest.raises(SystemExit, match="1"):
auto_brightness_daemon.main()
class TestMainDaemonLoop:
"""Tests for main() daemon loop behaviour."""
def _run_main_with_iterations(
self,
*,
enabled: bool = True,
lux: float = 50.0,
current_brightness: int = 50,
enabled_file_exists: bool = True,
signal_after: int = 1,
) -> tuple[MagicMock, MagicMock]:
"""Helper to run main() with controlled loop iterations.
Returns (mock_set_brightness, mock_read_lux).
"""
als_path = Path("/fake/als")
iteration = 0
def fake_sleep(_t: float) -> None:
nonlocal iteration
iteration += 1
if iteration >= signal_after:
raise KeyboardInterrupt
mock_set_brightness = MagicMock()
mock_enabled_file = MagicMock()
mock_enabled_file.exists.return_value = enabled_file_exists
with (
patch(f"{MOD}._find_als_device", return_value=als_path),
patch(f"{MOD}.ENABLED_FILE", mock_enabled_file),
patch(f"{MOD}._set_enabled"),
patch(f"{MOD}.signal.signal"),
patch(f"{MOD}.time.sleep", side_effect=fake_sleep),
patch(f"{MOD}._is_enabled", return_value=enabled),
patch(f"{MOD}._read_lux", return_value=lux) as mock_lux,
patch(f"{MOD}._lux_to_brightness", return_value=75),
patch(f"{MOD}._get_brightness", return_value=current_brightness),
patch(f"{MOD}._set_brightness", mock_set_brightness),
):
# Simulate SIGINT by raising KeyboardInterrupt in sleep
with contextlib.suppress(KeyboardInterrupt):
auto_brightness_daemon.main()
return mock_set_brightness, mock_lux
def test_adjusts_brightness_when_delta_exceeds_threshold(self) -> None:
mock_set, _ = self._run_main_with_iterations(
enabled=True,
current_brightness=50,
)
# target=75, current=50, delta=25, step clamped to MAX_STEP_PER_TICK=5
mock_set.assert_called_with(55)
def test_skips_when_disabled(self) -> None:
mock_set, _ = self._run_main_with_iterations(enabled=False)
mock_set.assert_not_called()
def test_skips_when_delta_too_small(self) -> None:
# target=75, current=74 → delta=1 < MIN_CHANGE_PERCENT=2
with (
patch(f"{MOD}._find_als_device", return_value=Path("/fake")),
patch(
f"{MOD}.ENABLED_FILE", MagicMock(exists=MagicMock(return_value=True))
),
patch(f"{MOD}._set_enabled"),
patch(f"{MOD}.signal.signal"),
patch(f"{MOD}.time.sleep", side_effect=[None, KeyboardInterrupt]),
patch(f"{MOD}._is_enabled", return_value=True),
patch(f"{MOD}._read_lux", return_value=50.0),
patch(f"{MOD}._lux_to_brightness", return_value=74),
patch(f"{MOD}._get_brightness", return_value=74),
patch(f"{MOD}._set_brightness") as mock_set,
):
with contextlib.suppress(KeyboardInterrupt):
auto_brightness_daemon.main()
mock_set.assert_not_called()
def test_skips_when_brightness_negative(self) -> None:
# current=-1 means error → should not set brightness
with (
patch(f"{MOD}._find_als_device", return_value=Path("/fake")),
patch(
f"{MOD}.ENABLED_FILE", MagicMock(exists=MagicMock(return_value=True))
),
patch(f"{MOD}._set_enabled"),
patch(f"{MOD}.signal.signal"),
patch(f"{MOD}.time.sleep", side_effect=[None, KeyboardInterrupt]),
patch(f"{MOD}._is_enabled", return_value=True),
patch(f"{MOD}._read_lux", return_value=50.0),
patch(f"{MOD}._lux_to_brightness", return_value=75),
patch(f"{MOD}._get_brightness", return_value=-1),
patch(f"{MOD}._set_brightness") as mock_set,
):
with contextlib.suppress(KeyboardInterrupt):
auto_brightness_daemon.main()
mock_set.assert_not_called()
def test_creates_control_file_when_missing(self) -> None:
mock_set_enabled = MagicMock()
mock_enabled_file = MagicMock()
mock_enabled_file.exists.return_value = False
with (
patch(f"{MOD}._find_als_device", return_value=Path("/fake")),
patch(f"{MOD}.ENABLED_FILE", mock_enabled_file),
patch(f"{MOD}._set_enabled", mock_set_enabled),
patch(f"{MOD}.signal.signal"),
patch(f"{MOD}.time.sleep", side_effect=KeyboardInterrupt),
patch(f"{MOD}._is_enabled", return_value=False),
):
with contextlib.suppress(KeyboardInterrupt):
auto_brightness_daemon.main()
mock_set_enabled.assert_called_once_with(enabled=True)
def test_does_not_create_file_when_exists(self) -> None:
mock_set_enabled = MagicMock()
mock_enabled_file = MagicMock()
mock_enabled_file.exists.return_value = True
with (
patch(f"{MOD}._find_als_device", return_value=Path("/fake")),
patch(f"{MOD}.ENABLED_FILE", mock_enabled_file),
patch(f"{MOD}._set_enabled", mock_set_enabled),
patch(f"{MOD}.signal.signal"),
patch(f"{MOD}.time.sleep", side_effect=KeyboardInterrupt),
patch(f"{MOD}._is_enabled", return_value=False),
):
with contextlib.suppress(KeyboardInterrupt):
auto_brightness_daemon.main()
mock_set_enabled.assert_not_called()
def test_handles_exception_in_loop_gracefully(self) -> None:
"""Exception in the loop body is caught and logged."""
with (
patch(f"{MOD}._find_als_device", return_value=Path("/fake")),
patch(
f"{MOD}.ENABLED_FILE", MagicMock(exists=MagicMock(return_value=True))
),
patch(f"{MOD}._set_enabled"),
patch(f"{MOD}.signal.signal"),
patch(f"{MOD}.time.sleep", side_effect=[None, KeyboardInterrupt]),
patch(f"{MOD}._is_enabled", side_effect=OSError("disk fail")),
):
with contextlib.suppress(KeyboardInterrupt):
auto_brightness_daemon.main()
# No crash = exception was handled
def test_signal_handler_stops_loop(self) -> None:
"""SIGTERM handler sets running=False to stop the loop."""
captured_handler = {}
def capture_signal(signum: int, handler: object) -> None:
captured_handler[signum] = handler
import signal
with (
patch(f"{MOD}._find_als_device", return_value=Path("/fake")),
patch(
f"{MOD}.ENABLED_FILE", MagicMock(exists=MagicMock(return_value=True))
),
patch(f"{MOD}._set_enabled"),
patch(f"{MOD}.signal.signal", side_effect=capture_signal),
patch(f"{MOD}.time.sleep", side_effect=KeyboardInterrupt),
patch(f"{MOD}._is_enabled", return_value=False),
):
with contextlib.suppress(KeyboardInterrupt):
auto_brightness_daemon.main()
# Verify we captured a SIGTERM handler
assert signal.SIGTERM in captured_handler
# Call the handler to verify it doesn't crash
handler = captured_handler[signal.SIGTERM]
assert callable(handler)
handler(signal.SIGTERM, None)
def test_negative_delta_clamps_step_down(self) -> None:
"""When target < current, step is negative and clamped."""
# target=75 is set by _lux_to_brightness mock
# current=90 → delta=-15, step clamped to -MAX_STEP_PER_TICK=-5
with (
patch(f"{MOD}._find_als_device", return_value=Path("/fake")),
patch(
f"{MOD}.ENABLED_FILE", MagicMock(exists=MagicMock(return_value=True))
),
patch(f"{MOD}._set_enabled"),
patch(f"{MOD}.signal.signal"),
patch(f"{MOD}.time.sleep", side_effect=[None, KeyboardInterrupt]),
patch(f"{MOD}._is_enabled", return_value=True),
patch(f"{MOD}._read_lux", return_value=0.0),
patch(f"{MOD}._lux_to_brightness", return_value=10),
patch(f"{MOD}._get_brightness", return_value=90),
patch(f"{MOD}._set_brightness") as mock_set,
):
with contextlib.suppress(KeyboardInterrupt):
auto_brightness_daemon.main()
# delta=-80, step=-5, new_val=85
mock_set.assert_called_with(85)
def test_graceful_shutdown_via_signal(self) -> None:
"""When signal handler sets running=False, loop exits normally."""
captured_handler: dict[int, object] = {}
def capture_signal(signum: int, handler: object) -> None:
captured_handler[signum] = handler
import signal as sig_mod
def fake_sleep(_t: float) -> None:
# Call the SIGTERM handler on first sleep to stop the loop
handler = captured_handler.get(sig_mod.SIGTERM)
if callable(handler):
handler(sig_mod.SIGTERM, None)
with (
patch(f"{MOD}._find_als_device", return_value=Path("/fake")),
patch(
f"{MOD}.ENABLED_FILE", MagicMock(exists=MagicMock(return_value=True))
),
patch(f"{MOD}._set_enabled"),
patch(f"{MOD}.signal.signal", side_effect=capture_signal),
patch(f"{MOD}.time.sleep", side_effect=fake_sleep),
patch(f"{MOD}._is_enabled", return_value=False),
):
auto_brightness_daemon.main()

View File

@ -0,0 +1,473 @@
"""Tests for brightness_controller module."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.brightness_controller import brightness_controller
# ── _find_als_device ─────────────────────────────────────────────────────
class TestFindAlsDevice:
"""Tests for _find_als_device."""
@patch.object(
Path,
"glob",
return_value=[Path("/sys/bus/iio/devices/iio0/in_illuminance_raw")],
)
def test_found(self, _mock_glob: MagicMock) -> None:
result = brightness_controller._find_als_device()
assert result == Path("/sys/bus/iio/devices/iio0")
@patch.object(Path, "glob", return_value=[])
def test_not_found(self, _mock_glob: MagicMock) -> None:
assert brightness_controller._find_als_device() is None
# ── _read_lux ────────────────────────────────────────────────────────────
class TestReadLux:
"""Tests for _read_lux."""
def test_all_files_present(self, tmp_path: Path) -> None:
(tmp_path / "in_illuminance_raw").write_text("100\n")
(tmp_path / "in_illuminance_scale").write_text("2.0\n")
(tmp_path / "in_illuminance_offset").write_text("5.0\n")
assert brightness_controller._read_lux(tmp_path) == pytest.approx(210.0)
def test_missing_scale(self, tmp_path: Path) -> None:
(tmp_path / "in_illuminance_raw").write_text("50\n")
(tmp_path / "in_illuminance_offset").write_text("0\n")
assert brightness_controller._read_lux(tmp_path) == pytest.approx(50.0)
def test_missing_offset(self, tmp_path: Path) -> None:
(tmp_path / "in_illuminance_raw").write_text("50\n")
(tmp_path / "in_illuminance_scale").write_text("1.0\n")
assert brightness_controller._read_lux(tmp_path) == pytest.approx(50.0)
def test_invalid_scale(self, tmp_path: Path) -> None:
(tmp_path / "in_illuminance_raw").write_text("50\n")
(tmp_path / "in_illuminance_scale").write_text("bad\n")
(tmp_path / "in_illuminance_offset").write_text("0\n")
assert brightness_controller._read_lux(tmp_path) == pytest.approx(50.0)
def test_invalid_offset(self, tmp_path: Path) -> None:
(tmp_path / "in_illuminance_raw").write_text("50\n")
(tmp_path / "in_illuminance_scale").write_text("1.0\n")
(tmp_path / "in_illuminance_offset").write_text("bad\n")
assert brightness_controller._read_lux(tmp_path) == pytest.approx(50.0)
# ── _lux_to_brightness ──────────────────────────────────────────────────
class TestLuxToBrightness:
"""Tests for _lux_to_brightness."""
def test_below_minimum(self) -> None:
assert brightness_controller._lux_to_brightness(-1.0) == 10
def test_at_minimum(self) -> None:
assert brightness_controller._lux_to_brightness(0.0) == 10
def test_above_maximum(self) -> None:
assert brightness_controller._lux_to_brightness(10000.0) == 100
def test_at_maximum(self) -> None:
assert brightness_controller._lux_to_brightness(5000.0) == 100
def test_interpolation(self) -> None:
# Between (5.0, 40) and (50.0, 75), at lux=27.5
assert brightness_controller._lux_to_brightness(27.5) == 57
def test_fallback_return(self) -> None:
"""Exercise the post-loop fallback (unreachable with monotonic curves)."""
nan = float("nan")
with patch.object(
brightness_controller,
"LUX_CURVE",
[(nan, 10), (nan, 99)],
):
assert brightness_controller._lux_to_brightness(50.0) == 99
# ── _run_brightnessctl ───────────────────────────────────────────────────
class TestRunBrightnessctl:
"""Tests for _run_brightnessctl."""
@patch("python_pkg.brightness_controller.brightness_controller.subprocess.run")
def test_captures_stdout(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout=" some output ")
result = brightness_controller._run_brightnessctl("-l", "-m")
assert result == "some output"
mock_run.assert_called_once_with(
[brightness_controller._BRIGHTNESSCTL, "-l", "-m"],
capture_output=True,
text=True,
check=False,
)
# ── _get_devices ─────────────────────────────────────────────────────────
class TestGetDevices:
"""Tests for _get_devices."""
@patch("python_pkg.brightness_controller.brightness_controller._run_brightnessctl")
def test_returns_backlight_devices(self, mock_run: MagicMock) -> None:
mock_run.return_value = (
"intel_backlight,backlight,50,42%,120000\nkbd_backlight,leds,0,0%,3"
)
devices = brightness_controller._get_devices()
assert len(devices) == 1
assert devices[0].name == "intel_backlight"
assert devices[0].device_class == "backlight"
assert devices[0].current == 42
assert devices[0].percent == "42%"
assert devices[0].max_brightness == 120000
@patch("python_pkg.brightness_controller.brightness_controller._run_brightnessctl")
def test_empty_output(self, mock_run: MagicMock) -> None:
mock_run.return_value = ""
assert brightness_controller._get_devices() == []
@patch("python_pkg.brightness_controller.brightness_controller._run_brightnessctl")
def test_too_few_fields(self, mock_run: MagicMock) -> None:
mock_run.return_value = "a,b,c"
assert brightness_controller._get_devices() == []
# ── _get_brightness ──────────────────────────────────────────────────────
class TestGetBrightness:
"""Tests for _get_brightness."""
@patch("python_pkg.brightness_controller.brightness_controller._run_brightnessctl")
def test_valid(self, mock_run: MagicMock) -> None:
mock_run.side_effect = ["123", "intel_backlight,backlight,50,42%,120000"]
assert brightness_controller._get_brightness("intel_backlight") == 42
@patch("python_pkg.brightness_controller.brightness_controller._run_brightnessctl")
def test_empty_get_output(self, mock_run: MagicMock) -> None:
mock_run.return_value = ""
assert brightness_controller._get_brightness("intel_backlight") == -1
@patch("python_pkg.brightness_controller.brightness_controller._run_brightnessctl")
def test_info_no_valid_fields(self, mock_run: MagicMock) -> None:
mock_run.side_effect = ["123", "a,b,c"]
assert brightness_controller._get_brightness("intel_backlight") == -1
# ── _set_brightness ──────────────────────────────────────────────────────
class TestSetBrightness:
"""Tests for _set_brightness."""
@patch("python_pkg.brightness_controller.brightness_controller._run_brightnessctl")
def test_calls_brightnessctl(self, mock_run: MagicMock) -> None:
brightness_controller._set_brightness("intel_backlight", 75)
mock_run.assert_called_once_with("-d", "intel_backlight", "set", "75%")
# ── Device NamedTuple ────────────────────────────────────────────────────
class TestDevice:
"""Tests for Device NamedTuple."""
def test_create(self) -> None:
d = brightness_controller.Device("test", "backlight", 50, "50%", 1000)
assert d.name == "test"
assert d.max_brightness == 1000
# ── BrightnessController ────────────────────────────────────────────────
def _make_controller(
devices: list[brightness_controller.Device] | None = None,
als_path: Path | None = None,
*,
daemon_state: bool = False,
) -> brightness_controller.BrightnessController:
"""Create a BrightnessController with all Tk operations mocked."""
if devices is None:
devices = [
brightness_controller.Device(
"intel_backlight", "backlight", 50, "50%", 120000
)
]
with (
patch(
"python_pkg.brightness_controller.brightness_controller._get_devices",
return_value=devices,
),
patch(
"python_pkg.brightness_controller.brightness_controller._find_als_device",
return_value=als_path,
),
patch.object(
brightness_controller.BrightnessController,
"_read_daemon_state",
return_value=daemon_state,
),
patch(
"python_pkg.brightness_controller.brightness_controller.tk.Tk"
) as mock_tk,
patch(
"python_pkg.brightness_controller.brightness_controller.tk.StringVar"
) as mock_str_var,
patch(
"python_pkg.brightness_controller.brightness_controller.tk.IntVar"
) as mock_int_var,
patch("python_pkg.brightness_controller.brightness_controller.ttk"),
patch(
"python_pkg.brightness_controller.brightness_controller._get_brightness",
return_value=50,
),
):
mock_root = MagicMock()
mock_tk.return_value = mock_root
mock_root.after = MagicMock()
mock_str_var.return_value = MagicMock()
mock_int_var.return_value = MagicMock()
return brightness_controller.BrightnessController()
class TestBrightnessControllerInit:
"""Tests for BrightnessController.__init__."""
def test_single_device(self) -> None:
ctrl = _make_controller()
assert ctrl.current_device == "intel_backlight"
def test_no_devices(self) -> None:
ctrl = _make_controller(devices=[])
assert ctrl.current_device == ""
def test_multiple_devices(self) -> None:
devices = [
brightness_controller.Device("led0", "leds", 0, "0%", 3),
brightness_controller.Device("intel_bl", "backlight", 50, "50%", 120000),
]
ctrl = _make_controller(devices=devices)
# Should prefer backlight device
assert ctrl.current_device == "intel_bl"
def test_with_als(self, tmp_path: Path) -> None:
ctrl = _make_controller(als_path=tmp_path)
assert ctrl.als_path == tmp_path
def test_auto_mode_enabled(self) -> None:
ctrl = _make_controller(daemon_state=True)
assert ctrl.auto_mode is True
class TestSelectDefaultDevice:
"""Tests for _select_default_device."""
def test_no_devices_sets_message(self) -> None:
ctrl = _make_controller(devices=[])
ctrl.pct_var = MagicMock()
ctrl._select_default_device()
ctrl.pct_var.set.assert_called_with("No devices")
def test_prefers_backlight(self) -> None:
devices = [
brightness_controller.Device("led0", "leds", 0, "0%", 3),
brightness_controller.Device("bl", "backlight", 50, "50%", 120000),
]
ctrl = _make_controller(devices=devices)
ctrl._refresh_brightness = MagicMock()
ctrl._select_default_device()
assert ctrl.current_device == "bl"
def test_no_backlight_device(self) -> None:
"""When no backlight device exists, uses the first device."""
devices = [
brightness_controller.Device("led0", "leds", 0, "0%", 3),
brightness_controller.Device("led1", "leds", 0, "0%", 5),
]
ctrl = _make_controller(devices=devices)
ctrl._refresh_brightness = MagicMock()
ctrl._select_default_device()
assert ctrl.current_device == "led0"
class TestOnDeviceChange:
"""Tests for _on_device_change."""
def test_updates_current_device(self) -> None:
ctrl = _make_controller()
ctrl.device_var = MagicMock()
ctrl.device_var.get.return_value = "new_device"
ctrl._refresh_brightness = MagicMock()
ctrl._on_device_change(MagicMock())
assert ctrl.current_device == "new_device"
ctrl._refresh_brightness.assert_called_once()
class TestRefreshBrightness:
"""Tests for _refresh_brightness."""
@patch(
"python_pkg.brightness_controller.brightness_controller._get_brightness",
return_value=75,
)
def test_updates_ui(self, _mock_get: MagicMock) -> None:
ctrl = _make_controller()
ctrl.pct_var = MagicMock()
ctrl.slider_var = MagicMock()
ctrl._refresh_brightness()
ctrl.pct_var.set.assert_called_with("75%")
ctrl.slider_var.set.assert_called_with(75)
@patch(
"python_pkg.brightness_controller.brightness_controller._get_brightness",
return_value=-1,
)
def test_error(self, _mock_get: MagicMock) -> None:
ctrl = _make_controller()
ctrl.pct_var = MagicMock()
ctrl._refresh_brightness()
ctrl.pct_var.set.assert_called_with("Error")
def test_no_current_device(self) -> None:
ctrl = _make_controller(devices=[])
ctrl.pct_var = MagicMock()
ctrl._refresh_brightness()
ctrl.pct_var.set.assert_not_called()
class TestOnSliderMove:
"""Tests for _on_slider_move."""
@patch("python_pkg.brightness_controller.brightness_controller._set_brightness")
def test_sets_brightness(self, mock_set: MagicMock) -> None:
ctrl = _make_controller()
ctrl.pct_var = MagicMock()
ctrl._updating_slider = False
ctrl._on_slider_move("75.0")
mock_set.assert_called_once_with("intel_backlight", 75)
ctrl.pct_var.set.assert_called_with("75%")
def test_skips_during_update(self) -> None:
ctrl = _make_controller()
ctrl._updating_slider = True
ctrl.pct_var = MagicMock()
ctrl._on_slider_move("75.0")
ctrl.pct_var.set.assert_not_called()
def test_no_device(self) -> None:
ctrl = _make_controller(devices=[])
ctrl.pct_var = MagicMock()
ctrl._on_slider_move("75.0")
ctrl.pct_var.set.assert_not_called()
@patch("python_pkg.brightness_controller.brightness_controller._set_brightness")
def test_disables_auto_mode(self, _mock_set: MagicMock) -> None:
ctrl = _make_controller(daemon_state=True)
ctrl.auto_mode = True
ctrl.pct_var = MagicMock()
ctrl._set_auto = MagicMock()
ctrl._updating_slider = False
ctrl._on_slider_move("50.0")
ctrl._set_auto.assert_called_once_with(enabled=False)
class TestSetPct:
"""Tests for _set_pct."""
@patch("python_pkg.brightness_controller.brightness_controller._set_brightness")
@patch(
"python_pkg.brightness_controller.brightness_controller._get_brightness",
return_value=25,
)
def test_sets_brightness(self, _mock_get: MagicMock, mock_set: MagicMock) -> None:
ctrl = _make_controller()
ctrl.pct_var = MagicMock()
ctrl.slider_var = MagicMock()
ctrl._set_pct(25)
mock_set.assert_called_once_with("intel_backlight", 25)
def test_no_device(self) -> None:
ctrl = _make_controller(devices=[])
# Should not raise
ctrl._set_pct(50)
class TestDecrease:
"""Tests for _decrease."""
@patch("python_pkg.brightness_controller.brightness_controller._set_brightness")
@patch(
"python_pkg.brightness_controller.brightness_controller._get_brightness",
return_value=50,
)
def test_decrease(self, _mock_get: MagicMock, mock_set: MagicMock) -> None:
ctrl = _make_controller()
ctrl.pct_var = MagicMock()
ctrl.slider_var = MagicMock()
ctrl._decrease()
mock_set.assert_called_once_with("intel_backlight", 45)
@patch("python_pkg.brightness_controller.brightness_controller._set_brightness")
@patch(
"python_pkg.brightness_controller.brightness_controller._get_brightness",
return_value=2,
)
def test_clamps_to_zero(self, _mock_get: MagicMock, mock_set: MagicMock) -> None:
ctrl = _make_controller()
ctrl.pct_var = MagicMock()
ctrl.slider_var = MagicMock()
ctrl._decrease()
mock_set.assert_called_once_with("intel_backlight", 0)
def test_no_device(self) -> None:
ctrl = _make_controller(devices=[])
ctrl._decrease()
class TestIncrease:
"""Tests for _increase."""
@patch("python_pkg.brightness_controller.brightness_controller._set_brightness")
@patch(
"python_pkg.brightness_controller.brightness_controller._get_brightness",
return_value=50,
)
def test_increase(self, _mock_get: MagicMock, mock_set: MagicMock) -> None:
ctrl = _make_controller()
ctrl.pct_var = MagicMock()
ctrl.slider_var = MagicMock()
ctrl._increase()
mock_set.assert_called_once_with("intel_backlight", 55)
@patch("python_pkg.brightness_controller.brightness_controller._set_brightness")
@patch(
"python_pkg.brightness_controller.brightness_controller._get_brightness",
return_value=98,
)
def test_clamps_to_100(self, _mock_get: MagicMock, mock_set: MagicMock) -> None:
ctrl = _make_controller()
ctrl.pct_var = MagicMock()
ctrl.slider_var = MagicMock()
ctrl._increase()
mock_set.assert_called_once_with("intel_backlight", 100)
def test_no_device(self) -> None:
ctrl = _make_controller(devices=[])
ctrl._increase()

View File

@ -0,0 +1,232 @@
"""Tests for brightness_controller module - part 2 (poll + main)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.brightness_controller import brightness_controller
MOD = "python_pkg.brightness_controller.brightness_controller"
def _make_controller(
devices: list[brightness_controller.Device] | None = None,
als_path: Path | None = None,
*,
daemon_state: bool = False,
) -> brightness_controller.BrightnessController:
"""Create a BrightnessController with all Tk operations mocked."""
if devices is None:
devices = [
brightness_controller.Device(
"intel_backlight", "backlight", 50, "50%", 120000
)
]
with (
patch(f"{MOD}._get_devices", return_value=devices),
patch(f"{MOD}._find_als_device", return_value=als_path),
patch.object(
brightness_controller.BrightnessController,
"_read_daemon_state",
return_value=daemon_state,
),
patch(f"{MOD}.tk.Tk") as mock_tk,
patch(f"{MOD}.tk.StringVar") as mock_str_var,
patch(f"{MOD}.tk.IntVar") as mock_int_var,
patch(f"{MOD}.ttk"),
patch(f"{MOD}._get_brightness", return_value=50),
):
mock_root = MagicMock()
mock_tk.return_value = mock_root
mock_root.after = MagicMock()
mock_str_var.return_value = MagicMock()
mock_int_var.return_value = MagicMock()
return brightness_controller.BrightnessController()
# ── _sync_auto_ui ────────────────────────────────────────────────────
class TestSyncAutoUi:
"""Tests for _sync_auto_ui."""
def test_no_als_returns_early(self) -> None:
ctrl = _make_controller(als_path=None)
ctrl.als_path = None
ctrl.auto_btn_var = MagicMock()
ctrl.slider = MagicMock()
ctrl._sync_auto_ui()
ctrl.auto_btn_var.set.assert_not_called()
def test_auto_on(self) -> None:
ctrl = _make_controller(als_path=Path("/fake"))
ctrl.auto_mode = True
ctrl.auto_btn_var = MagicMock()
ctrl.slider = MagicMock()
ctrl._sync_auto_ui()
ctrl.auto_btn_var.set.assert_called_once()
assert "ON" in ctrl.auto_btn_var.set.call_args[0][0]
ctrl.slider.state.assert_called_once_with(["disabled"])
def test_auto_off(self) -> None:
ctrl = _make_controller(als_path=Path("/fake"))
ctrl.auto_mode = False
ctrl.auto_btn_var = MagicMock()
ctrl.slider = MagicMock()
ctrl._sync_auto_ui()
ctrl.auto_btn_var.set.assert_called_once()
assert "OFF" in ctrl.auto_btn_var.set.call_args[0][0]
ctrl.slider.state.assert_called_once_with(["!disabled"])
# ── _poll_als ────────────────────────────────────────────────────────
class TestPollAls:
"""Tests for _poll_als."""
@patch(f"{MOD}._read_lux", return_value=42.5)
def test_updates_lux_display(self, _mock_lux: MagicMock) -> None:
ctrl = _make_controller(als_path=Path("/fake"))
ctrl.lux_var = MagicMock()
ctrl.root = MagicMock()
with patch.object(
brightness_controller.BrightnessController,
"_read_daemon_state",
return_value=False,
):
ctrl._poll_als()
assert "42.5 lux" in ctrl.lux_var.set.call_args[0][0]
ctrl.root.after.assert_called_once()
@patch(f"{MOD}._read_lux", side_effect=OSError("sensor fail"))
def test_sensor_error(self, _mock_lux: MagicMock) -> None:
ctrl = _make_controller(als_path=Path("/fake"))
ctrl.lux_var = MagicMock()
ctrl.root = MagicMock()
with patch.object(
brightness_controller.BrightnessController,
"_read_daemon_state",
return_value=False,
):
ctrl._poll_als()
ctrl.lux_var.set.assert_called_with("sensor error")
@patch(f"{MOD}._read_lux", side_effect=ValueError("bad value"))
def test_sensor_value_error(self, _mock_lux: MagicMock) -> None:
ctrl = _make_controller(als_path=Path("/fake"))
ctrl.lux_var = MagicMock()
ctrl.root = MagicMock()
with patch.object(
brightness_controller.BrightnessController,
"_read_daemon_state",
return_value=False,
):
ctrl._poll_als()
ctrl.lux_var.set.assert_called_with("sensor error")
@patch(f"{MOD}._read_lux", return_value=10.0)
def test_syncs_daemon_state_change(self, _mock_lux: MagicMock) -> None:
"""When daemon state differs from auto_mode, syncs it."""
ctrl = _make_controller(als_path=Path("/fake"))
ctrl.auto_mode = False
ctrl.lux_var = MagicMock()
ctrl.auto_btn_var = MagicMock()
ctrl.slider = MagicMock()
ctrl.root = MagicMock()
with patch.object(
brightness_controller.BrightnessController,
"_read_daemon_state",
return_value=True,
):
ctrl._poll_als()
assert ctrl.auto_mode is True
@patch(f"{MOD}._read_lux", return_value=10.0)
def test_no_sync_when_same(self, _mock_lux: MagicMock) -> None:
"""When daemon state matches auto_mode, no sync needed."""
ctrl = _make_controller(als_path=Path("/fake"))
ctrl.auto_mode = False
ctrl.lux_var = MagicMock()
ctrl.root = MagicMock()
with patch.object(
brightness_controller.BrightnessController,
"_read_daemon_state",
return_value=False,
):
ctrl._poll_als()
# No assertion on auto_btn_var since auto_mode didn't change
def test_no_als_path(self) -> None:
ctrl = _make_controller(als_path=None)
ctrl.als_path = None
ctrl.root = MagicMock()
ctrl._poll_als()
ctrl.root.after.assert_called_once()
# ── _poll_brightness ─────────────────────────────────────────────────
class TestPollBrightness:
"""Tests for _poll_brightness."""
@patch(f"{MOD}._get_brightness", return_value=60)
def test_refreshes_when_not_auto(self, _mock_get: MagicMock) -> None:
ctrl = _make_controller()
ctrl.auto_mode = False
ctrl.pct_var = MagicMock()
ctrl.slider_var = MagicMock()
ctrl.root = MagicMock()
ctrl._poll_brightness()
ctrl.pct_var.set.assert_called_with("60%")
ctrl.root.after.assert_called_once()
def test_skips_refresh_when_auto(self) -> None:
ctrl = _make_controller()
ctrl.auto_mode = True
ctrl._refresh_brightness = MagicMock()
ctrl.root = MagicMock()
ctrl._poll_brightness()
ctrl._refresh_brightness.assert_not_called()
ctrl.root.after.assert_called_once()
# ── run ──────────────────────────────────────────────────────────────
class TestRun:
"""Tests for run method."""
def test_calls_mainloop(self) -> None:
ctrl = _make_controller()
ctrl.root = MagicMock()
ctrl.run()
ctrl.root.mainloop.assert_called_once()
# ── main ─────────────────────────────────────────────────────────────
class TestMain:
"""Tests for main() entry point."""
@patch(f"{MOD}.subprocess.run")
def test_brightnessctl_not_found(self, mock_run: MagicMock) -> None:
mock_run.side_effect = FileNotFoundError
with pytest.raises(SystemExit, match="1"):
brightness_controller.main()
@patch(f"{MOD}.BrightnessController")
@patch(f"{MOD}.subprocess.run")
def test_success(self, mock_run: MagicMock, mock_ctrl_cls: MagicMock) -> None:
mock_run.return_value = MagicMock()
mock_app = MagicMock()
mock_ctrl_cls.return_value = mock_app
brightness_controller.main()
mock_app.run.assert_called_once()

View File

@ -0,0 +1,122 @@
"""Tests for brightness_controller module - part 3 (toggle, daemon, auto)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.brightness_controller import brightness_controller
if TYPE_CHECKING:
from pathlib import Path
MOD = "python_pkg.brightness_controller.brightness_controller"
def _make_controller(
devices: list[brightness_controller.Device] | None = None,
als_path: Path | None = None,
*,
daemon_state: bool = False,
) -> brightness_controller.BrightnessController:
"""Create a BrightnessController with all Tk operations mocked."""
if devices is None:
devices = [
brightness_controller.Device(
"intel_backlight", "backlight", 50, "50%", 120000
)
]
with (
patch(f"{MOD}._get_devices", return_value=devices),
patch(f"{MOD}._find_als_device", return_value=als_path),
patch.object(
brightness_controller.BrightnessController,
"_read_daemon_state",
return_value=daemon_state,
),
patch(f"{MOD}.tk.Tk") as mock_tk,
patch(f"{MOD}.tk.StringVar") as mock_str_var,
patch(f"{MOD}.tk.IntVar") as mock_int_var,
patch(f"{MOD}.ttk"),
patch(f"{MOD}._get_brightness", return_value=50),
):
mock_root = MagicMock()
mock_tk.return_value = mock_root
mock_root.after = MagicMock()
mock_str_var.return_value = MagicMock()
mock_int_var.return_value = MagicMock()
return brightness_controller.BrightnessController()
class TestToggleAuto:
"""Tests for _toggle_auto."""
def test_toggles(self) -> None:
ctrl = _make_controller()
ctrl.auto_mode = False
ctrl._set_auto = MagicMock()
ctrl._toggle_auto()
ctrl._set_auto.assert_called_once_with(enabled=True)
class TestReadDaemonState:
"""Tests for _read_daemon_state."""
def test_enabled(self, tmp_path: Path) -> None:
enabled_file = tmp_path / "enabled"
enabled_file.write_text("1")
with patch.object(brightness_controller, "ENABLED_FILE", enabled_file):
assert (
brightness_controller.BrightnessController._read_daemon_state() is True
)
def test_disabled(self, tmp_path: Path) -> None:
enabled_file = tmp_path / "enabled"
enabled_file.write_text("0")
with patch.object(brightness_controller, "ENABLED_FILE", enabled_file):
assert (
brightness_controller.BrightnessController._read_daemon_state() is False
)
def test_missing_file(self, tmp_path: Path) -> None:
enabled_file = tmp_path / "nonexistent"
with patch.object(brightness_controller, "ENABLED_FILE", enabled_file):
assert (
brightness_controller.BrightnessController._read_daemon_state() is False
)
class TestSetAuto:
"""Tests for _set_auto."""
def test_enable(self, tmp_path: Path) -> None:
config_dir = tmp_path / "config"
enabled_file = config_dir / "enabled"
ctrl = _make_controller()
ctrl.als_path = tmp_path # So _sync_auto_ui does something
ctrl.auto_btn_var = MagicMock()
ctrl.slider = MagicMock()
with (
patch.object(brightness_controller, "CONFIG_DIR", config_dir),
patch.object(brightness_controller, "ENABLED_FILE", enabled_file),
):
ctrl._set_auto(enabled=True)
assert ctrl.auto_mode is True
assert enabled_file.read_text() == "1"
def test_disable(self, tmp_path: Path) -> None:
config_dir = tmp_path / "config"
enabled_file = config_dir / "enabled"
ctrl = _make_controller()
ctrl.als_path = tmp_path
ctrl.auto_btn_var = MagicMock()
ctrl.slider = MagicMock()
with (
patch.object(brightness_controller, "CONFIG_DIR", config_dir),
patch.object(brightness_controller, "ENABLED_FILE", enabled_file),
):
ctrl._set_auto(enabled=False)
assert ctrl.auto_mode is False
assert enabled_file.read_text() == "0"

View File

@ -78,26 +78,23 @@ BROTHER_STATUS_CODES: dict[int, tuple[str, str, str]] = {
40309: (
"critical",
"Replace Toner",
"The toner cartridge needs immediate replacement"
" (TN-1050/TN-1030 compatible).",
"The toner cartridge needs immediate replacement (TN-1050/TN-1030 compatible).",
),
40310: (
"critical",
"Toner End",
"The toner cartridge is empty." " Replace now (TN-1050/TN-1030 compatible).",
"The toner cartridge is empty. Replace now (TN-1050/TN-1030 compatible).",
),
# Drum
30201: (
"warn",
"Drum End Soon",
"The drum unit is nearing end of life."
" Order replacement (DR-1050 compatible).",
"The drum unit is nearing end of life. Order replacement (DR-1050 compatible).",
),
40201: (
"warn",
"Drum End Soon",
"The drum unit is nearing end of life."
" Order replacement (DR-1050 compatible).",
"The drum unit is nearing end of life. Order replacement (DR-1050 compatible).",
),
40019: (
"critical",

View File

@ -179,10 +179,7 @@ def _cups_restart_service() -> bool:
proc.kill()
proc.wait()
sys.stdout.write("\n")
_out(
f" {RED}CUPS restart timed out"
f" (stuck backend process?).{RESET}"
)
_out(f" {RED}CUPS restart timed out (stuck backend process?).{RESET}")
_out(
f" {DIM}Try: sudo kill -9 $(pgrep -f 'cups/backend/usb')"
f" && sudo systemctl restart cups{RESET}"
@ -193,9 +190,7 @@ def _cups_restart_service() -> bool:
time.sleep(1)
sys.stdout.write("\n")
if proc.returncode != 0:
_out(
f" {RED}CUPS restart failed" f" (exit code {proc.returncode}).{RESET}"
)
_out(f" {RED}CUPS restart failed (exit code {proc.returncode}).{RESET}")
return False
except OSError as e:
sys.stdout.write("\n")

View File

@ -233,9 +233,7 @@ def reset_consumable(name: str) -> None:
key = f"{name}_replaced_at"
state[key] = total
_save_consumable_state(state)
_out(
f"{GREEN}{name.capitalize()} counter reset at page count" f" {total}.{RESET}"
)
_out(f"{GREEN}{name.capitalize()} counter reset at page count {total}.{RESET}")
_out(f" State saved to {CONSUMABLE_STATE_FILE}")

View File

@ -87,10 +87,7 @@ def _display_page_count_estimate() -> None:
else:
drum_color = GREEN
drum_note = ""
_out(
f" {BOLD}Drum:{RESET} {drum_color}{drum_bar} ~{drum_pct}%"
f"{drum_note}{RESET}"
)
_out(f" {BOLD}Drum:{RESET} {drum_color}{drum_bar} ~{drum_pct}%{drum_note}{RESET}")
_out(
f" {DIM}Based on pages since last replacement"
f" vs rated capacity (toner ~{TONER_RATED_PAGES},"
@ -158,7 +155,7 @@ _SEVERITY_COLORS: dict[str, str] = {
_SEVERITY_SUMMARIES: dict[str, str] = {
"ok": f"{GREEN}{BOLD}✓ Printer is healthy. No replacements needed.{RESET}",
"info": (
f"{CYAN}{BOLD}i Printer is busy/processing." f" No replacements needed.{RESET}"
f"{CYAN}{BOLD}i Printer is busy/processing. No replacements needed.{RESET}"
),
"warn": (
f"{YELLOW}{BOLD}⚡ WARNING: Maintenance will be needed"
@ -166,7 +163,7 @@ _SEVERITY_SUMMARIES: dict[str, str] = {
f" now to avoid interruption.{RESET}"
),
"critical": (
f"{RED}{BOLD}⚠ ACTION REQUIRED:" f" Replacement or fix needed now!{RESET}"
f"{RED}{BOLD}⚠ ACTION REQUIRED: Replacement or fix needed now!{RESET}"
),
}

View File

@ -0,0 +1,211 @@
"""Tests for brother_printer.check_brother_printer module."""
from __future__ import annotations
from io import StringIO
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.brother_printer.check_brother_printer import (
_discover_network_printer,
_no_printer_found,
_run_network_mode,
_run_usb_mode,
main,
)
MOD = "python_pkg.brother_printer.check_brother_printer"
class TestDiscoverNetworkPrinter:
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_lpstat(self, _m: MagicMock) -> None:
assert _discover_network_printer() == ""
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_found_ip(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="device for BrotherHL1110: ipp://192.168.1.100/ipp\n",
)
assert _discover_network_printer() == "192.168.1.100"
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_socket(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="device for BrotherHL1110: socket://10.0.0.5:9100\n",
)
assert _discover_network_printer() == "10.0.0.5"
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_no_match(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="device for BrotherHL1110: usb://Brother/HL-1110\n",
)
assert _discover_network_printer() == ""
@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 _discover_network_printer() == ""
@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 _discover_network_printer() == ""
class TestRunNetworkMode:
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_snmpwalk(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
with pytest.raises(SystemExit):
_run_network_mode("1.2.3.4")
@patch(f"{MOD}.display_network_results")
@patch(f"{MOD}.query_network_snmp")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/snmpwalk")
def test_success(
self,
_w: MagicMock,
mock_query: MagicMock,
mock_display: MagicMock,
) -> None:
from python_pkg.brother_printer.data_classes import NetworkResult
mock_query.return_value = NetworkResult(ip="1.2.3.4")
with patch("sys.stdout", new_callable=StringIO):
_run_network_mode("1.2.3.4")
mock_display.assert_called_once()
class TestRunUsbMode:
@patch(f"{MOD}.display_usb_results")
@patch(f"{MOD}.query_usb_pjl")
def test_success(
self,
mock_query: MagicMock,
mock_display: MagicMock,
) -> None:
mock_query.return_value = USBResult()
with patch("sys.stdout", new_callable=StringIO):
_run_usb_mode("Brother USB line")
mock_display.assert_called_once()
class TestNoPrinterFound:
def test_exits(self) -> None:
with patch("sys.stdout", new_callable=StringIO):
with 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):
with 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()
@patch(f"{MOD}.display_network_results")
@patch(f"{MOD}.query_network_snmp")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/snmpwalk")
@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(
self,
_g: MagicMock,
_f: MagicMock,
_d: MagicMock,
_w: MagicMock,
mock_query: MagicMock,
mock_display: MagicMock,
) -> None:
from python_pkg.brother_printer.data_classes import NetworkResult
mock_query.return_value = NetworkResult(ip="192.168.1.100")
with patch("sys.stdout", new_callable=StringIO):
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):
with pytest.raises(SystemExit):
main(["1.2.3.4"])
from python_pkg.brother_printer.data_classes import USBResult

View File

@ -0,0 +1,119 @@
"""Tests for brother_printer.constants module."""
from __future__ import annotations
from io import StringIO
from unittest.mock import patch
from python_pkg.brother_printer.constants import (
BOLD,
BROTHER_STATUS_CODES,
BROTHER_USB_VENDOR_ID,
CYAN,
DIM,
DRUM_RATED_PAGES,
GREEN,
MIN_LPSTAT_JOB_PARTS,
PROGRESS_BAR_WIDTH,
RED,
RESET,
SNMP_LEVEL_LOW,
SNMP_LEVEL_OK,
SUPPLY_LOW_PCT,
SUPPLY_WARN_PCT,
TONER_RATED_PAGES,
YELLOW,
_out,
_prompt,
get_status_info,
)
class TestConstants:
"""Test that constants have expected values."""
def test_color_codes_are_strings(self) -> None:
for c in (RED, YELLOW, GREEN, CYAN, BOLD, DIM, RESET):
assert isinstance(c, str)
def test_snmp_sentinels(self) -> None:
assert SNMP_LEVEL_OK == -3
assert SNMP_LEVEL_LOW == -2
def test_supply_thresholds(self) -> None:
assert SUPPLY_LOW_PCT == 10
assert SUPPLY_WARN_PCT == 25
def test_progress_bar_width(self) -> None:
assert PROGRESS_BAR_WIDTH == 25
def test_page_ratings(self) -> None:
assert TONER_RATED_PAGES == 1000
assert DRUM_RATED_PAGES == 10000
def test_min_lpstat_job_parts(self) -> None:
assert MIN_LPSTAT_JOB_PARTS == 4
def test_vendor_id(self) -> None:
assert BROTHER_USB_VENDOR_ID == 0x04F9
class TestOut:
"""Test _out helper."""
def test_out_default(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as mock_out:
_out()
assert mock_out.getvalue() == "\n"
def test_out_with_text(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as mock_out:
_out("hello")
assert mock_out.getvalue() == "hello\n"
class TestPrompt:
"""Test _prompt helper."""
def test_prompt_reads_input(self) -> None:
with (
patch("sys.stdout", new_callable=StringIO),
patch("sys.stdin", new_callable=StringIO) as mock_in,
):
mock_in.write("answer\n")
mock_in.seek(0)
result = _prompt("Enter: ")
assert result == "answer"
class TestGetStatusInfo:
"""Test get_status_info lookup."""
def test_known_code(self) -> None:
severity, text, action = get_status_info("10001")
assert severity == "ok"
assert text == "Ready"
assert action == ""
def test_toner_low(self) -> None:
severity, text, action = get_status_info("30010")
assert severity == "warn"
assert "Toner Low" in text
def test_unknown_code(self) -> None:
severity, text, action = get_status_info("99999")
assert severity == "info"
assert "Unknown" in text
assert action != ""
def test_invalid_code(self) -> None:
severity, text, action = get_status_info("not_a_number")
assert severity == "info"
assert "Unknown" in text
def test_all_codes_present(self) -> None:
assert len(BROTHER_STATUS_CODES) > 0
for sev, text, action in BROTHER_STATUS_CODES.values():
assert sev in ("ok", "info", "warn", "critical")
assert isinstance(text, str)
assert isinstance(action, str)

View File

@ -0,0 +1,458 @@
"""Tests for brother_printer.cups_queue module."""
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,
)
MOD = "python_pkg.brother_printer.cups_queue"
class TestParseLpstatPrinterLine:
def test_enabled(self) -> None:
enabled, reason = _parse_lpstat_printer_line(
"printer BrotherHL1110 is idle. enabled since Mon 01 2025 - ok",
)
assert enabled is True
assert reason == "ok"
def test_disabled(self) -> None:
enabled, reason = _parse_lpstat_printer_line(
"printer BrotherHL1110 disabled since Mon 01 2025 - paused",
)
assert enabled is False
assert reason == "paused"
def test_no_reason(self) -> None:
enabled, reason = _parse_lpstat_printer_line(
"printer BrotherHL1110 is idle.",
)
assert enabled is True
assert reason == ""
class TestParseLpstatJobs:
def test_parse_jobs(self) -> None:
output = (
"BrotherHL1110-1 alice 1024 Mon 01 2025\n"
"BrotherHL1110-2 bob 2048 Tue 02 2025\n"
"HP-1 charlie 512 Wed 03 2025\n"
)
jobs = _parse_lpstat_jobs(output, "BrotherHL1110")
assert len(jobs) == 2
assert jobs[0].job_id == "BrotherHL1110-1"
assert jobs[0].user == "alice"
def test_too_few_parts(self) -> None:
output = "BrotherHL1110-1 alice 1024\n"
jobs = _parse_lpstat_jobs(output, "BrotherHL1110")
assert len(jobs) == 0
class TestGetCupsQueueStatus:
@patch(f"{MOD}.find_cups_printer_name", return_value="")
def test_no_printer(self, _f: MagicMock) -> None:
result = get_cups_queue_status()
assert result.printer_name == ""
@patch(f"{MOD}._check_cups_backend_errors", return_value=(False, ""))
@patch(f"{MOD}.shutil.which", return_value=None)
@patch(f"{MOD}.find_cups_printer_name", return_value="BrotherHL1110")
def test_no_lpstat(self, _f: MagicMock, _w: MagicMock, _c: MagicMock) -> None:
result = get_cups_queue_status()
assert result.printer_name == "BrotherHL1110"
@patch(f"{MOD}._check_cups_backend_errors", return_value=(False, ""))
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
@patch(f"{MOD}.find_cups_printer_name", return_value="BrotherHL1110")
def test_full_status(
self,
_f: MagicMock,
_w: MagicMock,
mock_run: MagicMock,
_c: MagicMock,
) -> None:
# First call for printer status, second for jobs
mock_run.side_effect = [
MagicMock(
stdout=(
"printer BrotherHL1110 is idle. enabled since Mon 01 2025 - ok\n"
),
),
MagicMock(
stdout="BrotherHL1110-1 alice 1024 Mon 01 2025\n",
),
]
result = get_cups_queue_status()
assert result.enabled is True
assert len(result.jobs) == 1
@patch(f"{MOD}._check_cups_backend_errors", return_value=(True, "backend error"))
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
@patch(f"{MOD}.find_cups_printer_name", return_value="BrotherHL1110")
def test_with_backend_errors(
self,
_f: MagicMock,
_w: MagicMock,
mock_run: MagicMock,
_c: MagicMock,
) -> None:
mock_run.side_effect = [
MagicMock(stdout="printer BrotherHL1110 disabled\n"),
MagicMock(stdout=""),
]
result = get_cups_queue_status()
assert result.has_backend_errors is True
@patch(f"{MOD}._check_cups_backend_errors", return_value=(False, ""))
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
@patch(f"{MOD}.find_cups_printer_name", return_value="BrotherHL1110")
def test_printer_status_timeout(
self,
_f: MagicMock,
_w: MagicMock,
mock_run: MagicMock,
_c: MagicMock,
) -> None:
mock_run.side_effect = [
subprocess.TimeoutExpired("lpstat", 5),
MagicMock(stdout=""),
]
result = get_cups_queue_status()
assert result.enabled is True # default
@patch(f"{MOD}._check_cups_backend_errors", return_value=(False, ""))
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
@patch(f"{MOD}.find_cups_printer_name", return_value="BrotherHL1110")
def test_job_status_timeout(
self,
_f: MagicMock,
_w: MagicMock,
mock_run: MagicMock,
_c: MagicMock,
) -> None:
mock_run.side_effect = [
MagicMock(stdout=""),
subprocess.TimeoutExpired("lpstat", 5),
]
result = get_cups_queue_status()
assert result.jobs == []
@patch(f"{MOD}._check_cups_backend_errors", return_value=(False, ""))
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
@patch(f"{MOD}.find_cups_printer_name", return_value="BrotherHL1110")
def test_no_matching_printer_line(
self,
_f: MagicMock,
_w: MagicMock,
mock_run: MagicMock,
_c: MagicMock,
) -> None:
mock_run.side_effect = [
MagicMock(stdout="printer HP is idle.\n"),
MagicMock(stdout=""),
]
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, ts, success_ts = _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, success_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, success_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",
]
err, ts, 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",
]
err, ts, 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",
]
err, ts, 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, msg = _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, msg = _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, msg = _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, msg = _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, msg = _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, msg = _check_cups_backend_errors("B")
assert has_errors is False

View File

@ -0,0 +1,278 @@
"""Tests for brother_printer.cups_queue module - part 2 (interactive fix)."""
from __future__ import annotations
from io import StringIO
from unittest.mock import MagicMock, patch
from python_pkg.brother_printer.cups_queue 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.cups_queue"
# ── _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."""
def _make_queue(self) -> CUPSQueueStatus:
return CUPSQueueStatus(
printer_name="B",
enabled=False,
jobs=[CUPSJob("j1", "alice", "1024", "Mon")],
)
@patch(f"{MOD}._cups_enable_printer", return_value=True)
def test_choice_1(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_with_jobs(self._make_queue(), "1")
@patch(f"{MOD}._cups_enable_printer", return_value=True)
@patch(f"{MOD}._cups_cancel_all_jobs", return_value=True)
def test_choice_2(self, _c: MagicMock, _e: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_with_jobs(self._make_queue(), "2")
@patch(f"{MOD}._cups_cancel_all_jobs", return_value=True)
def test_choice_3(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_with_jobs(self._make_queue(), "3")
@patch(f"{MOD}._cups_restart_service", return_value=True)
def test_choice_4(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_with_jobs(self._make_queue(), "4")
@patch(f"{MOD}._cups_enable_printer", return_value=True)
@patch(f"{MOD}._cups_restart_service", return_value=True)
def test_choice_5(self, _r: MagicMock, _e: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_with_jobs(self._make_queue(), "5")
def test_choice_6_no_action(self) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_with_jobs(self._make_queue(), "6")
def test_invalid_choice(self) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_with_jobs(self._make_queue(), "99")
# ── _handle_disabled_no_jobs ─────────────────────────────────────────
class TestHandleDisabledNoJobs:
"""Tests for _handle_disabled_no_jobs."""
def _make_queue(self) -> CUPSQueueStatus:
return CUPSQueueStatus(printer_name="B", enabled=False)
@patch(f"{MOD}._cups_enable_printer", return_value=True)
def test_choice_1_enable(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_no_jobs(self._make_queue(), "1")
@patch(f"{MOD}._cups_enable_printer", return_value=False)
def test_choice_1_enable_fails(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_no_jobs(self._make_queue(), "1")
@patch(f"{MOD}._cups_enable_printer", return_value=True)
@patch(f"{MOD}._cups_restart_service", return_value=True)
def test_choice_2_restart(self, _r: MagicMock, _e: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_no_jobs(self._make_queue(), "2")
@patch(f"{MOD}._cups_restart_service", return_value=False)
def test_choice_2_restart_fails(self, _r: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_no_jobs(self._make_queue(), "2")
def test_choice_3_no_action(self) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_disabled_no_jobs(self._make_queue(), "3")
# ── _handle_enabled_with_jobs ────────────────────────────────────────
class TestHandleEnabledWithJobs:
"""Tests for _handle_enabled_with_jobs."""
def _make_queue(self) -> CUPSQueueStatus:
return CUPSQueueStatus(
printer_name="B",
enabled=True,
jobs=[CUPSJob("j1", "alice", "1024", "Mon")],
)
@patch(f"{MOD}._cups_cancel_all_jobs", return_value=True)
def test_choice_1_cancel(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_enabled_with_jobs(self._make_queue(), "1")
@patch(f"{MOD}._cups_cancel_all_jobs", return_value=False)
def test_choice_1_cancel_fails(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_enabled_with_jobs(self._make_queue(), "1")
@patch(f"{MOD}._cups_restart_service", return_value=True)
def test_choice_2_restart(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_enabled_with_jobs(self._make_queue(), "2")
@patch(f"{MOD}._cups_restart_service", return_value=False)
def test_choice_2_restart_fails(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_enabled_with_jobs(self._make_queue(), "2")
def test_choice_3_no_action(self) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_enabled_with_jobs(self._make_queue(), "3")
# ── _handle_backend_errors_only ──────────────────────────────────────
class TestHandleBackendErrorsOnly:
"""Tests for _handle_backend_errors_only."""
@patch(f"{MOD}._cups_restart_service", return_value=True)
def test_choice_1_restart(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_backend_errors_only("1")
@patch(f"{MOD}._cups_restart_service", return_value=False)
def test_choice_1_restart_fails(self, _m: MagicMock) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_backend_errors_only("1")
def test_choice_2_no_action(self) -> None:
with patch("sys.stdout", new_callable=StringIO):
_handle_backend_errors_only("2")

View File

@ -0,0 +1,76 @@
"""Tests for brother_printer.cups_queue module - part 3 (display status)."""
from __future__ import annotations
from io import StringIO
from unittest.mock import MagicMock, patch
from python_pkg.brother_printer.cups_queue import (
display_cups_queue_status,
)
from python_pkg.brother_printer.data_classes import CUPSJob, CUPSQueueStatus
MOD = "python_pkg.brother_printer.cups_queue"
class TestDisplayCupsQueueStatus:
def test_no_printer(self) -> None:
queue = CUPSQueueStatus(printer_name="")
with patch("sys.stdout", new_callable=StringIO) as out:
display_cups_queue_status(queue)
assert out.getvalue() == ""
def test_all_ok(self) -> None:
queue = CUPSQueueStatus(
printer_name="B",
enabled=True,
jobs=[],
has_backend_errors=False,
)
with patch("sys.stdout", new_callable=StringIO) as out:
display_cups_queue_status(queue)
assert out.getvalue() == ""
@patch(f"{MOD}._offer_queue_fix")
def test_disabled(self, mock_fix: MagicMock) -> None:
queue = CUPSQueueStatus(
printer_name="B",
enabled=False,
reason="paused",
)
with patch("sys.stdout", new_callable=StringIO):
display_cups_queue_status(queue)
mock_fix.assert_called_once()
@patch(f"{MOD}._offer_queue_fix")
def test_with_jobs(self, mock_fix: MagicMock) -> None:
queue = CUPSQueueStatus(
printer_name="B",
enabled=True,
jobs=[CUPSJob("j1", "alice", "1024", "Mon")],
)
with patch("sys.stdout", new_callable=StringIO):
display_cups_queue_status(queue)
mock_fix.assert_called_once()
@patch(f"{MOD}._offer_queue_fix")
def test_backend_errors_only(self, mock_fix: MagicMock) -> None:
queue = CUPSQueueStatus(
printer_name="B",
enabled=True,
has_backend_errors=True,
)
with patch("sys.stdout", new_callable=StringIO):
display_cups_queue_status(queue)
mock_fix.assert_called_once()
@patch(f"{MOD}._offer_queue_fix")
def test_disabled_no_reason(self, mock_fix: MagicMock) -> None:
queue = CUPSQueueStatus(
printer_name="B",
enabled=False,
reason="",
)
with patch("sys.stdout", new_callable=StringIO):
display_cups_queue_status(queue)
mock_fix.assert_called_once()

View File

@ -0,0 +1,454 @@
"""Tests for brother_printer.cups_service module."""
from __future__ import annotations
import json
import subprocess
from unittest.mock import MagicMock, patch
from python_pkg.brother_printer.cups_service import (
_ensure_cups_running,
_get_cups_total_pages,
_get_pyusb_device_info,
_load_consumable_state,
_query_usb_port_status_raw,
_save_consumable_state,
_stop_cups,
is_cups_scheduler_running,
reset_consumable,
start_cups,
)
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 TestStopCups:
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_systemctl(self, _m: MagicMock) -> None:
assert _stop_cups() is False
@patch(f"{MOD}.time.sleep")
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl")
def test_success(self, _w: MagicMock, mock_run: MagicMock, _s: MagicMock) -> None:
mock_run.return_value = MagicMock()
assert _stop_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 _stop_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 _stop_cups() is False
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/systemctl")
def test_oserror(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = OSError("fail")
assert _stop_cups() is False
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: 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: 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: MagicMock, _st: MagicMock) -> None:
assert _ensure_cups_running() is False
class TestQueryUsbPortStatusRaw:
def test_import_error(self) -> None:
with patch(f"{MOD}._stop_cups"):
# Simulate ImportError for usb.core
with patch.dict(
"sys.modules", {"usb": None, "usb.core": None, "usb.util": None}
):
result = _query_usb_port_status_raw()
assert result is None
@patch(f"{MOD}.start_cups")
@patch(f"{MOD}._stop_cups", return_value=False)
def test_stop_cups_fails(self, _st: MagicMock, _s: MagicMock) -> None:
import sys as _sys
mock_usb = MagicMock()
mock_usb.core.find.return_value = MagicMock()
with patch.dict(
_sys.modules,
{"usb": mock_usb, "usb.core": mock_usb.core, "usb.util": mock_usb.util},
):
result = _query_usb_port_status_raw()
assert result is None
@patch(f"{MOD}.start_cups")
@patch(f"{MOD}._stop_cups", return_value=True)
def test_dev_none_after_reset(self, _st: MagicMock, _s: MagicMock) -> None:
import sys as _sys
mock_usb = MagicMock()
mock_dev = MagicMock()
mock_usb.core.find.side_effect = [mock_dev, None]
with (
patch.dict(
_sys.modules,
{"usb": mock_usb, "usb.core": mock_usb.core, "usb.util": mock_usb.util},
),
patch(f"{MOD}.time.sleep"),
):
result = _query_usb_port_status_raw()
assert result is None
@patch(f"{MOD}.start_cups")
@patch(f"{MOD}._stop_cups", return_value=True)
def test_success(self, _stop: MagicMock, _start: 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_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},
),
patch(f"{MOD}.time.sleep"),
):
result = _query_usb_port_status_raw()
assert result is not None
assert result.online is True
@patch(f"{MOD}.start_cups")
@patch(f"{MOD}._stop_cups", return_value=True)
def test_kernel_driver_not_active(
self, _stop: MagicMock, _start: 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},
),
patch(f"{MOD}.time.sleep"),
):
result = _query_usb_port_status_raw()
assert result is not None
@patch(f"{MOD}.start_cups")
@patch(f"{MOD}._stop_cups", return_value=True)
def test_kernel_driver_usberror(self, _stop: MagicMock, _start: 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},
),
patch(f"{MOD}.time.sleep"),
):
result = _query_usb_port_status_raw()
assert result is not None
@patch(f"{MOD}.start_cups")
@patch(f"{MOD}._stop_cups", return_value=True)
def test_oserror_during_transfer(self, _stop: MagicMock, _start: 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},
),
patch(f"{MOD}.time.sleep"),
):
result = _query_usb_port_status_raw()
assert result is None
@patch(f"{MOD}.start_cups")
@patch(f"{MOD}._stop_cups", return_value=True)
def test_dev_none_initial(self, _stop: MagicMock, _start: 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},
):
result = _query_usb_port_status_raw()
assert result is None
class TestGetCupsTotalPages:
@patch(f"{MOD}.CUPS_PAGE_LOG")
def test_no_log(self, mock_log: MagicMock) -> None:
mock_log.exists.return_value = False
assert _get_cups_total_pages() == 0
@patch(f"{MOD}.CUPS_PAGE_LOG")
def test_with_entries(self, mock_log: MagicMock) -> None:
mock_log.exists.return_value = True
mock_log.read_text.return_value = (
"printer 1 [2025-01-01] total 5\n"
"printer 2 [2025-01-01] total 3\n"
"printer 1 [2025-01-01] total 10\n"
)
assert _get_cups_total_pages() == 13 # max(5,10) + 3
@patch(f"{MOD}.CUPS_PAGE_LOG")
def test_oserror(self, mock_log: MagicMock) -> None:
mock_log.exists.return_value = True
mock_log.read_text.side_effect = OSError("fail")
assert _get_cups_total_pages() == 0
@patch(f"{MOD}.CUPS_PAGE_LOG")
def test_no_matching_lines(self, mock_log: MagicMock) -> None:
mock_log.exists.return_value = True
mock_log.read_text.return_value = "some garbage\n"
assert _get_cups_total_pages() == 0
class TestLoadConsumableState:
@patch(f"{MOD}.CONSUMABLE_STATE_FILE")
def test_no_file(self, mock_file: MagicMock) -> None:
mock_file.exists.return_value = False
result = _load_consumable_state()
assert result == {"toner_replaced_at": 0, "drum_replaced_at": 0}
@patch(f"{MOD}.CONSUMABLE_STATE_FILE")
def test_valid_file(self, mock_file: MagicMock) -> None:
mock_file.exists.return_value = True
mock_file.read_text.return_value = json.dumps(
{"toner_replaced_at": 100, "drum_replaced_at": 200},
)
result = _load_consumable_state()
assert result["toner_replaced_at"] == 100
assert result["drum_replaced_at"] == 200
@patch(f"{MOD}.CONSUMABLE_STATE_FILE")
def test_oserror(self, mock_file: MagicMock) -> None:
mock_file.exists.return_value = True
mock_file.read_text.side_effect = OSError("fail")
result = _load_consumable_state()
assert result["toner_replaced_at"] == 0
@patch(f"{MOD}.CONSUMABLE_STATE_FILE")
def test_bad_json(self, mock_file: MagicMock) -> None:
mock_file.exists.return_value = True
mock_file.read_text.return_value = "not json"
result = _load_consumable_state()
assert result["toner_replaced_at"] == 0
@patch(f"{MOD}.CONSUMABLE_STATE_FILE")
def test_bad_values(self, mock_file: MagicMock) -> None:
mock_file.exists.return_value = True
mock_file.read_text.return_value = json.dumps(
{"toner_replaced_at": "bad"},
)
result = _load_consumable_state()
assert result["toner_replaced_at"] == 0
class TestSaveConsumableState:
@patch(f"{MOD}.CONSUMABLE_STATE_FILE")
def test_saves(self, mock_file: MagicMock) -> None:
mock_file.parent = MagicMock()
_save_consumable_state({"toner_replaced_at": 100, "drum_replaced_at": 200})
mock_file.write_text.assert_called_once()
written = mock_file.write_text.call_args[0][0]
data = json.loads(written)
assert data["toner_replaced_at"] == 100
class TestResetConsumable:
@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=500)
def test_reset_toner(
self,
_pages: MagicMock,
_load: MagicMock,
mock_save: MagicMock,
_out: MagicMock,
) -> None:
_load.return_value = {"toner_replaced_at": 0, "drum_replaced_at": 0}
reset_consumable("toner")
saved_state = mock_save.call_args[0][0]
assert saved_state["toner_replaced_at"] == 500

View File

@ -0,0 +1,285 @@
"""Tests for brother_printer.cups_service module - part 2."""
from __future__ import annotations
import subprocess
from unittest.mock import MagicMock, patch
from python_pkg.brother_printer.cups_service import (
_cups_reasons_to_error,
_get_cups_economode,
_get_printer_info_from_cups,
_map_cups_to_status_code,
_parse_cups_usb_uri,
_port_status_to_status_code,
find_cups_printer_name,
)
from python_pkg.brother_printer.data_classes import (
USBPortStatus,
)
MOD = "python_pkg.brother_printer.cups_service"
# ── _get_cups_economode ──────────────────────────────────────────────
class TestGetCupsEconomode:
"""Tests for _get_cups_economode."""
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_lpoptions(self, _m: MagicMock) -> None:
assert _get_cups_economode("Brother") == ""
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_economode_on(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="BREconomode/Toner Save Mode: *True False\n"
)
assert _get_cups_economode("Brother") == "ON"
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_economode_off(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="BREconomode/Toner Save Mode: True *False\n"
)
assert _get_cups_economode("Brother") == "OFF"
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_no_economode_line(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="Resolution/Output Resolution: 600dpi *1200dpi\n"
)
assert _get_cups_economode("Brother") == ""
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_economode_no_star_match(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="BREconomode/Toner Save Mode: True False\n"
)
assert _get_cups_economode("Brother") == ""
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_timeout(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.TimeoutExpired("lpoptions", 5)
assert _get_cups_economode("Brother") == ""
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpoptions")
def test_oserror(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = OSError("fail")
assert _get_cups_economode("Brother") == ""
# ── _map_cups_to_status_code ─────────────────────────────────────────
class TestMapCupsToStatusCode:
"""Tests for _map_cups_to_status_code."""
def test_reason_match(self) -> None:
result = _map_cups_to_status_code("idle", "toner-low-report")
assert result == "30010"
def test_state_match(self) -> None:
result = _map_cups_to_status_code("idle", "none")
assert result == "10001"
def test_processing_state(self) -> None:
result = _map_cups_to_status_code("processing", "none")
assert result == "10007"
def test_stopped_state(self) -> None:
result = _map_cups_to_status_code("stopped", "none")
assert result == "10023"
def test_unknown_state(self) -> None:
result = _map_cups_to_status_code("mystery", "none")
assert result == "10001"
def test_state_with_parenthetical(self) -> None:
result = _map_cups_to_status_code("idle (on fire)", "none")
assert result == "10001"
# ── _cups_reasons_to_error ───────────────────────────────────────────
class TestCupsReasonsToError:
"""Tests for _cups_reasons_to_error."""
def test_media_jam(self) -> None:
code, display = _cups_reasons_to_error("media-jam-report")
assert code == "40000"
assert display == "Paper Jam"
def test_cover_open(self) -> None:
code, display = _cups_reasons_to_error("cover-open")
assert code == "41000"
def test_door_open(self) -> None:
code, display = _cups_reasons_to_error("door-open")
assert code == "41000"
def test_toner_empty(self) -> None:
code, display = _cups_reasons_to_error("toner-empty")
assert code == "40310"
def test_toner_low(self) -> None:
code, display = _cups_reasons_to_error("toner-low")
assert code == "30010"
def test_unknown_reason(self) -> None:
code, display = _cups_reasons_to_error("something-weird")
assert code == "42000"
assert display == "Printer Error"
# ── _port_status_to_status_code ──────────────────────────────────────
class TestPortStatusToStatusCode:
"""Tests for _port_status_to_status_code."""
def test_error_and_paper_empty(self) -> None:
ps = USBPortStatus(error=True, paper_empty=True, online=True)
code, display = _port_status_to_status_code(ps, "none")
assert code == "40302"
assert display == "No Paper"
def test_error_and_not_online(self) -> None:
ps = USBPortStatus(error=True, paper_empty=False, online=False)
code, display = _port_status_to_status_code(ps, "none")
assert code == "41000"
assert display == "Cover Open"
def test_error_only(self) -> None:
ps = USBPortStatus(error=True, paper_empty=False, online=True)
code, display = _port_status_to_status_code(ps, "media-jam")
assert code == "40000"
def test_paper_empty_no_error(self) -> None:
ps = USBPortStatus(error=False, paper_empty=True, online=True)
code, display = _port_status_to_status_code(ps, "none")
assert code == "40302"
def test_not_online_no_error(self) -> None:
ps = USBPortStatus(error=False, paper_empty=False, online=False)
code, display = _port_status_to_status_code(ps, "none")
assert code == "10002"
assert display == "Offline / Sleep"
def test_all_ok(self) -> None:
ps = USBPortStatus(error=False, paper_empty=False, online=True)
code, display = _port_status_to_status_code(ps, "none")
assert code == ""
assert display == ""
# ── find_cups_printer_name ───────────────────────────────────────────
class TestFindCupsPrinterName:
"""Tests for find_cups_printer_name."""
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_lpstat(self, _m: MagicMock) -> None:
assert find_cups_printer_name() == ""
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_found(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="device for BrotherHL1110: usb://Brother/HL-1110\n"
)
assert find_cups_printer_name() == "BrotherHL1110"
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_no_brother(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="device for HP: ipp://hp.local\n")
assert find_cups_printer_name() == ""
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lpstat")
def test_brother_no_match(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="brother printer found but format unexpected\n"
)
assert find_cups_printer_name() == ""
@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 find_cups_printer_name() == ""
@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 find_cups_printer_name() == ""
# ── _parse_cups_usb_uri ─────────────────────────────────────────────
class TestParseCupsUsbUri:
"""Tests for _parse_cups_usb_uri."""
def test_full_uri(self) -> None:
info: dict[str, str] = {"product": "", "serial": ""}
_parse_cups_usb_uri("usb://Brother/HL-1110%20series?serial=ABC123", info)
assert info["product"] == "HL-1110 series"
assert info["serial"] == "ABC123"
def test_no_serial(self) -> None:
info: dict[str, str] = {"product": "", "serial": ""}
_parse_cups_usb_uri("usb://Brother/HL-1110", info)
assert info["product"] == "HL-1110"
assert info["serial"] == ""
# ── _get_printer_info_from_cups ──────────────────────────────────────
class TestGetPrinterInfoFromCups:
"""Tests for _get_printer_info_from_cups."""
@patch(f"{MOD}.subprocess.run")
def test_found(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="device for B: usb://Brother/HL-1110?serial=XYZ\n"
)
result = _get_printer_info_from_cups()
assert result["product"] == "HL-1110"
assert result["serial"] == "XYZ"
@patch(f"{MOD}.subprocess.run")
def test_no_brother(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="device for HP: ipp://hp.local\n")
result = _get_printer_info_from_cups()
assert result["product"] == ""
@patch(f"{MOD}.subprocess.run")
def test_brother_no_usb(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="device for B: ipp://Brother.local\n")
result = _get_printer_info_from_cups()
assert result["product"] == ""
@patch(f"{MOD}.subprocess.run")
def test_timeout(self, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.TimeoutExpired("lpstat", 5)
result = _get_printer_info_from_cups()
assert result["product"] == ""
@patch(f"{MOD}.subprocess.run")
def test_oserror(self, mock_run: MagicMock) -> None:
mock_run.side_effect = OSError("fail")
result = _get_printer_info_from_cups()
assert result["product"] == ""

View File

@ -0,0 +1,308 @@
"""Tests for brother_printer.cups_service module - part 3 (query_usb_via_cups)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from python_pkg.brother_printer.cups_service import (
query_usb_via_cups,
)
from python_pkg.brother_printer.data_classes import (
PageCountEstimate,
USBPortStatus,
)
MOD = "python_pkg.brother_printer.cups_service"
# ── query_usb_via_cups ───────────────────────────────────────────────
class TestQueryUsbViaCups:
"""Tests for query_usb_via_cups."""
@patch(f"{MOD}.find_cups_printer_name", return_value="")
@patch(f"{MOD}._ensure_cups_running", return_value=True)
def test_no_printer(self, _e: MagicMock, _f: MagicMock) -> None:
result = query_usb_via_cups()
assert result.error != ""
@patch(f"{MOD}._query_usb_port_status_raw", return_value=None)
@patch(f"{MOD}._get_cups_economode", return_value="ON")
@patch(
f"{MOD}._get_cups_ipp_status",
return_value={
"printer-state": "idle",
"printer-state-reasons": "none",
"printer-state-message": "Ready",
},
)
@patch(
f"{MOD}._get_printer_info_from_cups",
return_value={"product": "HL-1110", "serial": "ABC"},
)
@patch(f"{MOD}._get_pyusb_device_info", return_value={})
@patch(f"{MOD}.find_cups_printer_name", return_value="Brother")
@patch(f"{MOD}._ensure_cups_running", return_value=True)
def test_no_port_status_idle(
self,
_e: MagicMock,
_f: MagicMock,
_py: MagicMock,
_cups: MagicMock,
_ipp: MagicMock,
_eco: MagicMock,
_port: MagicMock,
) -> None:
result = query_usb_via_cups()
assert result.online == "TRUE"
assert result.product == "HL-1110"
assert result.economode == "ON"
@patch(f"{MOD}._query_usb_port_status_raw", return_value=None)
@patch(f"{MOD}._get_cups_economode", return_value="")
@patch(
f"{MOD}._get_cups_ipp_status",
return_value={
"printer-state": "stopped",
"printer-state-reasons": "none",
},
)
@patch(
f"{MOD}._get_printer_info_from_cups",
return_value={"product": "", "serial": ""},
)
@patch(f"{MOD}._get_pyusb_device_info", return_value={})
@patch(f"{MOD}.find_cups_printer_name", return_value="Brother")
@patch(f"{MOD}._ensure_cups_running", return_value=True)
def test_no_port_status_stopped(
self,
_e: MagicMock,
_f: MagicMock,
_py: MagicMock,
_cups: MagicMock,
_ipp: MagicMock,
_eco: MagicMock,
_port: MagicMock,
) -> None:
result = query_usb_via_cups()
assert result.online == "FALSE"
assert result.product == "Brother Laser Printer"
@patch(
f"{MOD}._query_usb_port_status_raw",
return_value=USBPortStatus(
error=True,
paper_empty=True,
online=False,
raw_byte=0x20,
),
)
@patch(f"{MOD}._get_cups_economode", return_value="")
@patch(
f"{MOD}._get_cups_ipp_status",
return_value={
"printer-state": "stopped",
"printer-state-reasons": "none",
},
)
@patch(
f"{MOD}._get_printer_info_from_cups",
return_value={"product": "", "serial": ""},
)
@patch(f"{MOD}._get_pyusb_device_info", return_value={})
@patch(f"{MOD}.find_cups_printer_name", return_value="Brother")
@patch(f"{MOD}._ensure_cups_running", return_value=True)
def test_port_status_hw_error(
self,
_e: MagicMock,
_f: MagicMock,
_py: MagicMock,
_cups: MagicMock,
_ipp: MagicMock,
_eco: MagicMock,
_port: MagicMock,
) -> None:
result = query_usb_via_cups()
assert result.status_code == "40302"
assert result.online == "FALSE"
@patch(
f"{MOD}.estimate_consumable_life",
return_value=PageCountEstimate(
toner_exhausted=True,
total_pages=1000,
toner_pages=1000,
),
)
@patch(
f"{MOD}._query_usb_port_status_raw",
return_value=USBPortStatus(
error=False,
paper_empty=False,
online=True,
raw_byte=0x18,
),
)
@patch(f"{MOD}._get_cups_economode", return_value="")
@patch(
f"{MOD}._get_cups_ipp_status",
return_value={
"printer-state": "idle",
"printer-state-reasons": "none",
},
)
@patch(
f"{MOD}._get_printer_info_from_cups",
return_value={"product": "", "serial": ""},
)
@patch(f"{MOD}._get_pyusb_device_info", return_value={})
@patch(f"{MOD}.find_cups_printer_name", return_value="Brother")
@patch(f"{MOD}._ensure_cups_running", return_value=True)
def test_port_ok_toner_exhausted(
self,
_e: MagicMock,
_f: MagicMock,
_py: MagicMock,
_cups: MagicMock,
_ipp: MagicMock,
_eco: MagicMock,
_port: MagicMock,
_est: MagicMock,
) -> None:
result = query_usb_via_cups()
assert result.status_code == "40310"
assert "Toner End" in result.display
@patch(
f"{MOD}.estimate_consumable_life",
return_value=PageCountEstimate(
toner_low=True,
total_pages=800,
toner_pages=800,
),
)
@patch(
f"{MOD}._query_usb_port_status_raw",
return_value=USBPortStatus(
error=False,
paper_empty=False,
online=True,
raw_byte=0x18,
),
)
@patch(f"{MOD}._get_cups_economode", return_value="")
@patch(
f"{MOD}._get_cups_ipp_status",
return_value={
"printer-state": "idle",
"printer-state-reasons": "none",
},
)
@patch(
f"{MOD}._get_printer_info_from_cups",
return_value={"product": "", "serial": ""},
)
@patch(f"{MOD}._get_pyusb_device_info", return_value={})
@patch(f"{MOD}.find_cups_printer_name", return_value="Brother")
@patch(f"{MOD}._ensure_cups_running", return_value=True)
def test_port_ok_toner_low(
self,
_e: MagicMock,
_f: MagicMock,
_py: MagicMock,
_cups: MagicMock,
_ipp: MagicMock,
_eco: MagicMock,
_port: MagicMock,
_est: MagicMock,
) -> None:
result = query_usb_via_cups()
assert result.status_code == "30010"
assert "Toner Low" in result.display
@patch(
f"{MOD}.estimate_consumable_life",
return_value=PageCountEstimate(total_pages=100, toner_pages=100),
)
@patch(
f"{MOD}._query_usb_port_status_raw",
return_value=USBPortStatus(
error=False,
paper_empty=False,
online=True,
raw_byte=0x18,
),
)
@patch(f"{MOD}._get_cups_economode", return_value="")
@patch(
f"{MOD}._get_cups_ipp_status",
return_value={
"printer-state": "idle",
"printer-state-reasons": "none",
"printer-state-message": "Ready",
},
)
@patch(
f"{MOD}._get_printer_info_from_cups",
return_value={"product": "", "serial": ""},
)
@patch(f"{MOD}._get_pyusb_device_info", return_value={})
@patch(f"{MOD}.find_cups_printer_name", return_value="Brother")
@patch(f"{MOD}._ensure_cups_running", return_value=True)
def test_port_ok_normal(
self,
_e: MagicMock,
_f: MagicMock,
_py: MagicMock,
_cups: MagicMock,
_ipp: MagicMock,
_eco: MagicMock,
_port: MagicMock,
_est: MagicMock,
) -> None:
result = query_usb_via_cups()
assert result.online == "TRUE"
assert result.display == "Ready"
@patch(
f"{MOD}._query_usb_port_status_raw",
return_value=USBPortStatus(
error=True,
paper_empty=False,
online=True,
raw_byte=0x00,
),
)
@patch(f"{MOD}._get_cups_economode", return_value="")
@patch(
f"{MOD}._get_cups_ipp_status",
return_value={
"printer-state": "stopped",
"printer-state-reasons": "media-jam",
},
)
@patch(
f"{MOD}._get_printer_info_from_cups",
return_value={"product": "", "serial": ""},
)
@patch(
f"{MOD}._get_pyusb_device_info",
return_value={"product": "HL-1110", "serial": "SN1"},
)
@patch(f"{MOD}.find_cups_printer_name", return_value="Brother")
@patch(f"{MOD}._ensure_cups_running", return_value=True)
def test_port_error_uses_cups_reasons(
self,
_e: MagicMock,
_f: MagicMock,
_py: MagicMock,
_cups: MagicMock,
_ipp: MagicMock,
_eco: MagicMock,
_port: MagicMock,
) -> None:
result = query_usb_via_cups()
assert result.status_code == "40000"
assert result.product == "HL-1110"
assert result.online == "TRUE"

View File

@ -0,0 +1,86 @@
"""Tests for brother_printer.cups_service module - part 4 (consumable life, IPP)."""
from __future__ import annotations
import subprocess
from unittest.mock import MagicMock, patch
from python_pkg.brother_printer.cups_service import (
_get_cups_ipp_status,
_parse_ipp_attributes,
estimate_consumable_life,
)
MOD = "python_pkg.brother_printer.cups_service"
class TestEstimateConsumableLife:
@patch(f"{MOD}._load_consumable_state")
@patch(f"{MOD}._get_cups_total_pages", return_value=0)
def test_no_pages(self, _p: MagicMock, _l: 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}
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}
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}
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}
result = estimate_consumable_life()
assert result.drum_near_end is True
class TestParseIppAttributes:
def test_parse(self) -> None:
output = " printer-state (enum) = idle\n printer-name (name) = Brother\n"
result = _parse_ipp_attributes(output)
assert result["printer-state"] == "idle"
assert result["printer-name"] == "Brother"
def test_no_match(self) -> None:
result = _parse_ipp_attributes("no attributes here\n")
assert result == {}
class TestGetCupsIppStatus:
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_ipptool(self, _m: MagicMock) -> None:
assert _get_cups_ipp_status("Brother") == {}
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/ipptool")
def test_success(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout=" printer-state (enum) = idle\n",
)
result = _get_cups_ipp_status("Brother")
assert result["printer-state"] == "idle"
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/ipptool")
def test_timeout(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.TimeoutExpired("ipptool", 10)
assert _get_cups_ipp_status("Brother") == {}

View File

@ -0,0 +1,93 @@
"""Tests for brother_printer.data_classes module."""
from __future__ import annotations
from python_pkg.brother_printer.data_classes import (
CUPSJob,
CUPSQueueStatus,
NetworkResult,
PageCountEstimate,
SupplyStatus,
USBPortStatus,
USBResult,
)
class TestCUPSJob:
def test_create(self) -> None:
job = CUPSJob(job_id="job-1", user="alice", size="1024", date="2025-01-01")
assert job.job_id == "job-1"
assert job.user == "alice"
assert job.size == "1024"
assert job.date == "2025-01-01"
class TestCUPSQueueStatus:
def test_defaults(self) -> None:
s = CUPSQueueStatus()
assert s.printer_name == ""
assert s.enabled is True
assert s.reason == ""
assert s.jobs == []
assert s.has_backend_errors is False
assert s.last_backend_error == ""
class TestPageCountEstimate:
def test_defaults(self) -> None:
p = PageCountEstimate()
assert p.total_pages == 0
assert p.toner_pct_remaining == 100
assert p.drum_pct_remaining == 100
assert p.toner_exhausted is False
assert p.toner_low is False
assert p.drum_near_end is False
class TestUSBPortStatus:
def test_defaults(self) -> None:
ps = USBPortStatus()
assert ps.paper_empty is False
assert ps.online is True
assert ps.error is False
assert ps.raw_byte == 0
class TestUSBResult:
def test_defaults(self) -> None:
r = USBResult()
assert r.connection == "usb"
assert r.device == ""
assert r.product == "Brother Laser Printer"
assert r.serial == ""
assert r.status_code == ""
assert r.display == ""
assert r.online == ""
assert r.economode == ""
assert r.error == ""
assert r.port_status is None
class TestNetworkResult:
def test_defaults(self) -> None:
r = NetworkResult()
assert r.connection == "network"
assert r.ip == ""
assert r.product == "Unknown"
assert r.supply_descriptions == []
assert r.supply_max == []
assert r.supply_levels == []
assert r.error == ""
class TestSupplyStatus:
def test_create(self) -> None:
s = SupplyStatus(
color="red",
bar="[###]",
status_text="50%",
warning="low",
needs_replacement=True,
)
assert s.color == "red"
assert s.needs_replacement is True

View File

@ -0,0 +1,446 @@
"""Tests for brother_printer.display module."""
from __future__ import annotations
from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.brother_printer.data_classes import (
NetworkResult,
PageCountEstimate,
USBPortStatus,
USBResult,
)
from python_pkg.brother_printer.display import (
_classify_percentage_level,
_classify_supply_level,
_collect_supply_items,
_display_consumables_reference,
_display_cups_fallback_note,
_display_page_count_estimate,
_display_pjl_status,
_display_report_header,
_display_supply_levels,
_display_supply_warnings,
_display_usb_device_info,
_format_status_detail,
_format_supply_bar,
_parse_supply_value,
_process_supply_item,
display_usb_results,
)
MOD = "python_pkg.brother_printer.display"
class TestDisplayReportHeader:
def test_prints_header(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as out:
_display_report_header()
assert "Brother Laser Printer" in out.getvalue()
class TestDisplayPageCountEstimate:
@patch(f"{MOD}.estimate_consumable_life")
def test_no_pages(self, mock_est: MagicMock) -> None:
mock_est.return_value = PageCountEstimate(total_pages=0)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate()
assert out.getvalue() == ""
@patch(f"{MOD}.estimate_consumable_life")
def test_healthy(self, mock_est: MagicMock) -> None:
mock_est.return_value = PageCountEstimate(
total_pages=100,
toner_pages=100,
drum_pages=100,
toner_pct_remaining=90,
drum_pct_remaining=99,
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate()
assert "Total pages" in out.getvalue()
@patch(f"{MOD}.estimate_consumable_life")
def test_toner_exhausted(self, mock_est: MagicMock) -> None:
mock_est.return_value = PageCountEstimate(
total_pages=1000,
toner_pages=1000,
drum_pages=100,
toner_pct_remaining=0,
drum_pct_remaining=99,
toner_exhausted=True,
toner_low=True,
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate()
assert "REPLACE NOW" in out.getvalue()
@patch(f"{MOD}.estimate_consumable_life")
def test_toner_low(self, mock_est: MagicMock) -> None:
mock_est.return_value = PageCountEstimate(
total_pages=800,
toner_pages=800,
drum_pages=100,
toner_pct_remaining=20,
drum_pct_remaining=99,
toner_low=True,
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate()
assert "order soon" in out.getvalue()
@patch(f"{MOD}.estimate_consumable_life")
def test_drum_near_end(self, mock_est: MagicMock) -> None:
mock_est.return_value = PageCountEstimate(
total_pages=9000,
toner_pages=100,
drum_pages=9000,
toner_pct_remaining=90,
drum_pct_remaining=10,
drum_near_end=True,
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_page_count_estimate()
assert "nearing end" in out.getvalue()
class TestDisplayConsumablesReference:
def test_prints(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as out:
_display_consumables_reference()
assert "TN-1050" in out.getvalue()
class TestDisplayUsbDeviceInfo:
def test_full_info(self) -> None:
r = USBResult(
product="HL-1110",
serial="SN123",
online="TRUE",
economode="ON",
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
text = out.getvalue()
assert "HL-1110" in text
assert "SN123" in text
assert "Yes" in text
assert "Toner Save" in text
def test_offline(self) -> None:
r = USBResult(online="FALSE")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "No (needs attention)" in out.getvalue()
def test_no_online(self) -> None:
r = USBResult(online="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "Online" not in out.getvalue()
def test_economode_off(self) -> None:
r = USBResult(economode="OFF")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "OFF" in out.getvalue()
def test_no_economode(self) -> None:
r = USBResult(economode="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "Toner Save" not in out.getvalue()
def test_no_serial(self) -> None:
r = USBResult(serial="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "Serial" not in out.getvalue()
def test_no_product(self) -> None:
r = USBResult(product="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_usb_device_info(r)
assert "Unknown" in out.getvalue()
class TestFormatStatusDetail:
def test_with_action(self) -> None:
r = USBResult(
status_code="30010",
display="Toner Low Display",
)
with patch("sys.stdout", new_callable=StringIO) as out:
_format_status_detail("warn", "Toner Low", "Replace toner", r)
text = out.getvalue()
assert "Toner Low" in text
assert "Replace toner" in text
assert "Display:" in text
def test_no_action(self) -> None:
r = USBResult(status_code="10001", display="Ready")
with patch("sys.stdout", new_callable=StringIO) as out:
_format_status_detail("ok", "Ready", "", r)
assert "Action" not in out.getvalue()
def test_display_same_as_text(self) -> None:
r = USBResult(status_code="10001", display="Ready")
with patch("sys.stdout", new_callable=StringIO) as out:
_format_status_detail("ok", "Ready", "", r)
assert "Display:" not in out.getvalue()
def test_unknown_severity(self) -> None:
r = USBResult(status_code="99999", display="")
with patch("sys.stdout", new_callable=StringIO):
_format_status_detail("unknown", "Test", "", r)
# Should not crash
def test_critical(self) -> None:
r = USBResult(status_code="40310", display="Toner End")
with patch("sys.stdout", new_callable=StringIO) as out:
_format_status_detail("critical", "Toner End", "Replace", r)
assert "ACTION REQUIRED" in out.getvalue()
def test_info(self) -> None:
r = USBResult(status_code="10006", display="Processing")
with patch("sys.stdout", new_callable=StringIO) as out:
_format_status_detail("info", "Processing", "", r)
assert "busy" in out.getvalue()
class TestDisplayPjlStatus:
def test_no_code(self) -> None:
r = USBResult(status_code="", display="hello")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_pjl_status(r)
assert "Could not read status" in out.getvalue()
assert "hello" in out.getvalue()
def test_no_code_no_display(self) -> None:
r = USBResult(status_code="", display="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_pjl_status(r)
assert "Could not read status" in out.getvalue()
@patch(f"{MOD}._format_status_detail")
@patch(f"{MOD}.get_status_info", return_value=("ok", "Ready", ""))
def test_with_code(self, _g: MagicMock, mock_fmt: MagicMock) -> None:
r = USBResult(status_code="10001")
with patch("sys.stdout", new_callable=StringIO):
_display_pjl_status(r)
mock_fmt.assert_called_once()
class TestDisplayCupsFallbackNote:
def test_with_port_status(self) -> None:
r = USBResult(port_status=USBPortStatus())
with patch("sys.stdout", new_callable=StringIO) as out:
_display_cups_fallback_note(r)
assert "USB port query" in out.getvalue()
def test_without_port_status(self) -> None:
r = USBResult(port_status=None)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_cups_fallback_note(r)
assert "pyusb not available" in out.getvalue()
class TestDisplayUsbResults:
@patch(f"{MOD}.display_cups_queue_status")
@patch(f"{MOD}.get_cups_queue_status")
@patch(f"{MOD}._display_consumables_reference")
@patch(f"{MOD}._display_page_count_estimate")
@patch(f"{MOD}._display_pjl_status")
@patch(f"{MOD}._display_usb_device_info")
@patch(f"{MOD}._display_report_header")
def test_normal(
self,
_h: MagicMock,
_d: MagicMock,
_p: MagicMock,
_pe: MagicMock,
_c: MagicMock,
_gq: MagicMock,
_dq: MagicMock,
) -> None:
r = USBResult(device="/dev/usb/lp0")
with patch("sys.stdout", new_callable=StringIO):
display_usb_results(r)
@patch(f"{MOD}._display_cups_fallback_note")
@patch(f"{MOD}.display_cups_queue_status")
@patch(f"{MOD}.get_cups_queue_status")
@patch(f"{MOD}._display_consumables_reference")
@patch(f"{MOD}._display_page_count_estimate")
@patch(f"{MOD}._display_pjl_status")
@patch(f"{MOD}._display_usb_device_info")
@patch(f"{MOD}._display_report_header")
def test_cups_device(
self,
_h: MagicMock,
_d: MagicMock,
_p: MagicMock,
_pe: MagicMock,
_c: MagicMock,
_gq: MagicMock,
_dq: MagicMock,
mock_fallback: MagicMock,
) -> None:
r = USBResult(device="cups")
with patch("sys.stdout", new_callable=StringIO):
display_usb_results(r)
mock_fallback.assert_called_once()
def test_error(self) -> None:
r = USBResult(error="fail")
with (
patch("sys.stdout", new_callable=StringIO),
pytest.raises(SystemExit),
):
display_usb_results(r)
class TestClassifyPercentageLevel:
def test_low(self) -> None:
pct, text, color, warn, replace = _classify_percentage_level("Toner", 5)
assert pct == 5
assert replace is True
def test_warn(self) -> None:
pct, text, color, warn, replace = _classify_percentage_level("Toner", 20)
assert replace is False
assert "order soon" in warn
def test_ok(self) -> None:
pct, text, color, warn, replace = _classify_percentage_level("Toner", 80)
assert replace is False
assert warn == ""
class TestClassifySupplyLevel:
def test_snmp_ok(self) -> None:
pct, text, color, warn, replace = _classify_supply_level("Toner", 100, -3)
assert text == "OK"
assert replace is False
def test_snmp_low(self) -> None:
pct, text, color, warn, replace = _classify_supply_level("Toner", 100, -2)
assert text == "LOW"
assert replace is True
def test_empty(self) -> None:
pct, text, color, warn, replace = _classify_supply_level("Toner", 100, 0)
assert text == "EMPTY"
assert replace is True
def test_normal_percentage(self) -> None:
pct, text, color, warn, replace = _classify_supply_level("Toner", 100, 80)
assert pct == 80
assert replace is False
def test_no_max_val(self) -> None:
pct, text, color, warn, replace = _classify_supply_level("Toner", 0, 50)
assert pct == -1
assert text == ""
def test_over_100_capped(self) -> None:
pct, text, color, warn, replace = _classify_supply_level("Toner", 50, 100)
assert pct == 100
class TestFormatSupplyBar:
def test_negative(self) -> None:
assert _format_supply_bar(-1) == ""
def test_zero(self) -> None:
bar = _format_supply_bar(0)
assert "" in bar
def test_full(self) -> None:
bar = _format_supply_bar(100)
assert "" in bar
class TestProcessSupplyItem:
def test_normal(self) -> None:
item = _process_supply_item("Toner", 100, 80)
assert item.status_text == "80%"
def test_empty(self) -> None:
item = _process_supply_item("Toner", 100, 0)
assert item.needs_replacement is True
class TestDisplaySupplyWarnings:
def test_replacement_needed(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as out:
_display_supply_warnings(
needs_replacement=True,
warnings=["Toner low"],
)
assert "ACTION NEEDED" in out.getvalue()
def test_warnings_only(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as out:
_display_supply_warnings(
needs_replacement=False,
warnings=["Toner at 20%"],
)
assert "HEADS UP" in out.getvalue()
def test_all_healthy(self) -> None:
with patch("sys.stdout", new_callable=StringIO) as out:
_display_supply_warnings(
needs_replacement=False,
warnings=[],
)
assert "healthy" in out.getvalue()
class TestParseSupplyValue:
def test_valid(self) -> None:
assert _parse_supply_value(["10", "20"], 0) == 10
def test_index_error(self) -> None:
assert _parse_supply_value([], 0) == 0
def test_value_error(self) -> None:
assert _parse_supply_value(["abc"], 0) == 0
class TestCollectSupplyItems:
def test_collect(self) -> None:
result = NetworkResult(
supply_descriptions=["Toner", "Drum"],
supply_max=["100", "200"],
supply_levels=["80", "150"],
)
items, descs = _collect_supply_items(result)
assert len(items) == 2
assert descs == ["Toner", "Drum"]
class TestDisplaySupplyLevels:
def test_with_items(self) -> None:
result = NetworkResult(
supply_descriptions=["Toner"],
supply_max=["100"],
supply_levels=["80"],
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_supply_levels(result)
assert "Toner" in out.getvalue()
def test_needs_replacement_and_warning(self) -> None:
result = NetworkResult(
supply_descriptions=["Toner", "Drum"],
supply_max=["100", "100"],
supply_levels=["0", "15"],
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_supply_levels(result)
text = out.getvalue()
assert "ACTION NEEDED" in text

View File

@ -0,0 +1,90 @@
"""Tests for brother_printer.display module - part 2 (network display)."""
from __future__ import annotations
from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.brother_printer.data_classes import (
NetworkResult,
)
from python_pkg.brother_printer.display import (
_display_network_device_info,
display_network_results,
)
MOD = "python_pkg.brother_printer.display"
class TestDisplayNetworkDeviceInfo:
def test_full_info(self) -> None:
result = NetworkResult(
ip="1.2.3.4",
product="HL-1110",
serial="SN1",
display="Ready",
page_count="500",
)
with patch("sys.stdout", new_callable=StringIO) as out:
_display_network_device_info(result)
text = out.getvalue()
assert "HL-1110" in text
assert "1.2.3.4" in text
assert "SN1" in text
assert "500" in text
def test_no_serial(self) -> None:
result = NetworkResult(ip="1.2.3.4")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_network_device_info(result)
assert "Serial" not in out.getvalue()
def test_no_display(self) -> None:
result = NetworkResult(ip="1.2.3.4")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_network_device_info(result)
assert "Display" not in out.getvalue()
def test_non_digit_page_count(self) -> None:
result = NetworkResult(ip="1.2.3.4", page_count="abc")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_network_device_info(result)
assert "Pages" not in out.getvalue()
def test_no_page_count(self) -> None:
result = NetworkResult(ip="1.2.3.4", page_count="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_network_device_info(result)
assert "Pages" not in out.getvalue()
def test_no_product(self) -> None:
result = NetworkResult(ip="1.2.3.4", product="")
with patch("sys.stdout", new_callable=StringIO) as out:
_display_network_device_info(result)
assert "Unknown" in out.getvalue()
class TestDisplayNetworkResults:
@patch(f"{MOD}._display_supply_levels")
@patch(f"{MOD}._display_network_device_info")
@patch(f"{MOD}._display_report_header")
def test_normal(
self,
_h: MagicMock,
_d: MagicMock,
_s: MagicMock,
) -> None:
r = NetworkResult(ip="1.2.3.4")
with patch("sys.stdout", new_callable=StringIO) as out:
display_network_results(r)
assert "1.2.3.4" in out.getvalue()
def test_error(self) -> None:
r = NetworkResult(error="fail")
with (
patch("sys.stdout", new_callable=StringIO),
pytest.raises(SystemExit),
):
display_network_results(r)

View File

@ -0,0 +1,29 @@
"""Tests for brother_printer.__main__ module."""
from __future__ import annotations
import importlib
import types
from unittest.mock import MagicMock, patch
class TestMain:
def test_main_called(self) -> None:
"""Test that __main__ calls main()."""
mock_main = MagicMock()
# Create a fake brother_printer.check_brother_printer module
fake_module = types.ModuleType("brother_printer.check_brother_printer")
vars(fake_module)["main"] = mock_main
with patch.dict(
"sys.modules",
{
"brother_printer": types.ModuleType("brother_printer"),
"brother_printer.check_brother_printer": fake_module,
},
):
# Remove cached __main__ module so it gets re-imported
import sys
sys.modules.pop("python_pkg.brother_printer.__main__", None)
importlib.import_module("python_pkg.brother_printer.__main__")
mock_main.assert_called_once()

View File

@ -0,0 +1,189 @@
"""Tests for brother_printer.network_query module."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from python_pkg.brother_printer.network_query import (
_build_network_result,
_check_snmp_connectivity,
_snmpget_cmd,
_snmpwalk_cmd,
query_network_snmp,
snmp_walk,
)
class TestSnmpwalkCmd:
def test_builds_correct_command(self) -> None:
cmd = _snmpwalk_cmd("/usr/bin/snmpwalk", "public", 5, "1.2.3.4", "1.3.6")
assert cmd == [
"/usr/bin/snmpwalk",
"-v",
"2c",
"-c",
"public",
"-t",
"5",
"-OQvs",
"1.2.3.4",
"1.3.6",
]
class TestSnmpgetCmd:
def test_builds_correct_command(self) -> None:
cmd = _snmpget_cmd("/usr/bin/snmpget", "public", 5, "1.2.3.4", "1.3.6")
assert cmd == [
"/usr/bin/snmpget",
"-v",
"2c",
"-c",
"public",
"-t",
"5",
"1.2.3.4",
"1.3.6",
]
class TestSnmpWalk:
@patch("python_pkg.brother_printer.network_query.shutil.which", return_value=None)
def test_no_snmpwalk(self, _mock: MagicMock) -> None:
assert snmp_walk("1.2.3.4", "1.3.6", "public", 5) == []
@patch("python_pkg.brother_printer.network_query.subprocess.run")
@patch(
"python_pkg.brother_printer.network_query.shutil.which",
return_value="/usr/bin/snmpwalk",
)
def test_success(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout=' "Brother HL-1110" \n "SN123" \n',
)
result = snmp_walk("1.2.3.4", "1.3.6", "public", 5)
assert result == ["Brother HL-1110", "SN123"]
@patch("python_pkg.brother_printer.network_query.subprocess.run")
@patch(
"python_pkg.brother_printer.network_query.shutil.which",
return_value="/usr/bin/snmpwalk",
)
def test_empty_lines_stripped(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout=" \n value \n \n")
result = snmp_walk("1.2.3.4", "1.3.6", "public", 5)
assert result == ["value"]
@patch("python_pkg.brother_printer.network_query.subprocess.run")
@patch(
"python_pkg.brother_printer.network_query.shutil.which",
return_value="/usr/bin/snmpwalk",
)
def test_timeout(self, _w: MagicMock, mock_run: MagicMock) -> None:
import subprocess
mock_run.side_effect = subprocess.TimeoutExpired("snmpwalk", 15)
assert snmp_walk("1.2.3.4", "1.3.6", "public", 5) == []
@patch("python_pkg.brother_printer.network_query.subprocess.run")
@patch(
"python_pkg.brother_printer.network_query.shutil.which",
return_value="/usr/bin/snmpwalk",
)
def test_oserror(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = OSError("fail")
assert snmp_walk("1.2.3.4", "1.3.6", "public", 5) == []
class TestCheckSnmpConnectivity:
@patch(
"python_pkg.brother_printer.network_query.shutil.which",
return_value=None,
)
def test_no_snmpget(self, _mock: MagicMock) -> None:
result = _check_snmp_connectivity("1.2.3.4", "public", 5)
assert result is not None
assert "snmpget not found" in result
@patch("python_pkg.brother_printer.network_query.subprocess.run")
@patch(
"python_pkg.brother_printer.network_query.shutil.which",
return_value="/usr/bin/snmpget",
)
def test_success(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock()
assert _check_snmp_connectivity("1.2.3.4", "public", 5) is None
@patch("python_pkg.brother_printer.network_query.subprocess.run")
@patch(
"python_pkg.brother_printer.network_query.shutil.which",
return_value="/usr/bin/snmpget",
)
def test_timeout(self, _w: MagicMock, mock_run: MagicMock) -> None:
import subprocess
mock_run.side_effect = subprocess.TimeoutExpired("snmpget", 10)
result = _check_snmp_connectivity("1.2.3.4", "public", 5)
assert result is not None
assert "Cannot reach" in result
@patch("python_pkg.brother_printer.network_query.subprocess.run")
@patch(
"python_pkg.brother_printer.network_query.shutil.which",
return_value="/usr/bin/snmpget",
)
def test_called_process_error(self, _w: MagicMock, mock_run: MagicMock) -> None:
import subprocess
mock_run.side_effect = subprocess.CalledProcessError(1, "snmpget")
result = _check_snmp_connectivity("1.2.3.4", "public", 5)
assert result is not None
@patch("python_pkg.brother_printer.network_query.subprocess.run")
@patch(
"python_pkg.brother_printer.network_query.shutil.which",
return_value="/usr/bin/snmpget",
)
def test_oserror(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = OSError("fail")
result = _check_snmp_connectivity("1.2.3.4", "public", 5)
assert result is not None
class TestBuildNetworkResult:
@patch("python_pkg.brother_printer.network_query.snmp_walk")
def test_builds_result(self, mock_walk: MagicMock) -> None:
mock_walk.return_value = ["Test Value"]
result = _build_network_result("1.2.3.4", "public", 5)
assert result.ip == "1.2.3.4"
assert result.product == "Test Value"
@patch("python_pkg.brother_printer.network_query.snmp_walk")
def test_empty_values(self, mock_walk: MagicMock) -> None:
mock_walk.return_value = []
result = _build_network_result("1.2.3.4", "public", 5)
assert result.product == "Unknown"
assert result.serial == ""
class TestQueryNetworkSnmp:
@patch("python_pkg.brother_printer.network_query._build_network_result")
@patch(
"python_pkg.brother_printer.network_query._check_snmp_connectivity",
return_value=None,
)
def test_success(self, _c: MagicMock, mock_build: MagicMock) -> None:
from python_pkg.brother_printer.data_classes import NetworkResult
mock_build.return_value = NetworkResult(ip="1.2.3.4")
result = query_network_snmp("1.2.3.4")
assert result.ip == "1.2.3.4"
assert result.error == ""
@patch(
"python_pkg.brother_printer.network_query._check_snmp_connectivity",
return_value="Error msg",
)
def test_connectivity_error(self, _c: MagicMock) -> None:
result = query_network_snmp("1.2.3.4")
assert result.error == "Error msg"

View File

@ -0,0 +1,498 @@
"""Tests for brother_printer.usb_query module."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from python_pkg.brother_printer.data_classes import USBResult
from python_pkg.brother_printer.usb_query import (
_drain_buffer,
_init_usb_result,
_parse_cups_usb_uri,
_parse_status,
_parse_variables,
_read_nonblocking,
_retry_pjl_query,
_run_pjl_queries,
_wait_for_pjl_response,
find_brother_usb,
find_usb_printer_dev,
get_printer_info_from_cups,
pjl_query,
query_usb_pjl,
)
MOD = "python_pkg.brother_printer.usb_query"
class TestFindBrotherUsb:
@patch(f"{MOD}.shutil.which", return_value=None)
def test_no_lsusb(self, _m: MagicMock) -> None:
assert find_brother_usb() == ""
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb")
def test_found(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="Bus 001 Device 005: ID 04f9:0042 Brother Industries\n",
)
result = find_brother_usb()
assert "Brother" in result
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb")
def test_not_found(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="Bus 001 Device 001: Hub\n")
assert find_brother_usb() == ""
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb")
def test_line_with_colon_sep(self, _w: MagicMock, mock_run: MagicMock) -> None:
"""Line contains 04f9: but no ': ' separator → returns full line."""
mock_run.return_value = MagicMock(stdout="ID 04f9:0042\n")
result = find_brother_usb()
assert result == "ID 04f9:0042"
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb")
def test_no_match(self, _w: MagicMock, mock_run: MagicMock) -> None:
"""Line without 04f9: vendor id is ignored."""
mock_run.return_value = MagicMock(stdout="04f9 brother no colon\n")
assert find_brother_usb() == ""
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb")
def test_timeout(self, _w: MagicMock, mock_run: MagicMock) -> None:
import subprocess
mock_run.side_effect = subprocess.TimeoutExpired("lsusb", 5)
assert find_brother_usb() == ""
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/lsusb")
def test_oserror(self, _w: MagicMock, mock_run: MagicMock) -> None:
mock_run.side_effect = OSError("fail")
assert find_brother_usb() == ""
class TestFindUsbPrinterDev:
@patch(f"{MOD}.Path")
def test_found(self, mock_path_cls: MagicMock) -> None:
mock_path_cls.return_value = mock_path_cls
mock_path_cls.__truediv__ = lambda self, x: mock_path_cls
lp0 = MagicMock()
lp0.__str__ = lambda s: "/dev/usb/lp0"
lp0.__lt__ = lambda s, o: str(s) < str(o)
mock_usb = MagicMock()
mock_usb.glob.return_value = [lp0]
mock_path_cls.side_effect = None
with patch(f"{MOD}.Path", return_value=mock_usb):
result = find_usb_printer_dev()
assert result == "/dev/usb/lp0"
@patch(f"{MOD}.Path")
def test_not_found(self, mock_path_cls: MagicMock) -> None:
mock_usb = MagicMock()
mock_usb.glob.return_value = []
mock_path_cls.return_value = mock_usb
result = find_usb_printer_dev()
assert result is None
class TestParseCupsUsbUri:
def test_basic_uri(self) -> None:
info: dict[str, str] = {"product": "", "serial": ""}
_parse_cups_usb_uri(
"usb://Brother/HL-1110%20series?serial=ABC123",
info,
)
assert info["product"] == "HL-1110 series"
assert info["serial"] == "ABC123"
def test_no_serial(self) -> None:
info: dict[str, str] = {"product": "", "serial": ""}
_parse_cups_usb_uri("usb://Brother/HL-1110%20series", info)
assert info["product"] == "HL-1110 series"
assert info["serial"] == ""
class TestGetPrinterInfoFromCups:
@patch(f"{MOD}.subprocess.run")
def test_found(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="device for Brother: usb://Brother/HL-1110?serial=SN1\n",
)
info = get_printer_info_from_cups()
assert info["product"] == "HL-1110"
assert info["serial"] == "SN1"
@patch(f"{MOD}.subprocess.run")
def test_no_brother(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="device for HP: ipp://hp\n")
info = get_printer_info_from_cups()
assert info["product"] == ""
@patch(f"{MOD}.subprocess.run")
def test_brother_no_usb_uri(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="device for Brother: ipp://1.2.3.4\n",
)
info = get_printer_info_from_cups()
assert info["product"] == ""
@patch(f"{MOD}.subprocess.run")
def test_timeout(self, mock_run: MagicMock) -> None:
import subprocess
mock_run.side_effect = subprocess.TimeoutExpired("lpstat", 5)
info = get_printer_info_from_cups()
assert info == {"product": "", "serial": ""}
@patch(f"{MOD}.subprocess.run")
def test_oserror(self, mock_run: MagicMock) -> None:
mock_run.side_effect = OSError("fail")
info = get_printer_info_from_cups()
assert info == {"product": "", "serial": ""}
class TestDrainBuffer:
@patch(f"{MOD}.os.read")
@patch(f"{MOD}.fcntl.fcntl")
def test_drain(self, mock_fcntl: MagicMock, mock_read: MagicMock) -> None:
mock_fcntl.return_value = 0
mock_read.side_effect = [b"data", OSError("done")]
_drain_buffer(42)
assert mock_read.called
@patch(f"{MOD}.os.read")
@patch(f"{MOD}.fcntl.fcntl")
def test_drain_empty_buffer(
self,
mock_fcntl: MagicMock,
mock_read: MagicMock,
) -> None:
"""Buffer is already empty — os.read returns b'' immediately."""
mock_fcntl.return_value = 0
mock_read.return_value = b""
_drain_buffer(42)
mock_read.assert_called_once()
class TestReadNonblocking:
@patch(f"{MOD}.os.read")
@patch(f"{MOD}.fcntl.fcntl")
def test_reads_chunks(self, mock_fcntl: MagicMock, mock_read: MagicMock) -> None:
mock_fcntl.return_value = 0
mock_read.side_effect = [b"hello", b"", OSError]
result = _read_nonblocking(42, 0)
assert result == b"hello"
@patch(f"{MOD}.os.read")
@patch(f"{MOD}.fcntl.fcntl")
def test_oserror_suppressed(
self,
mock_fcntl: MagicMock,
mock_read: MagicMock,
) -> None:
mock_fcntl.return_value = 0
mock_read.side_effect = OSError("would block")
result = _read_nonblocking(42, 0)
assert result == b""
class TestWaitForPjlResponse:
@patch(f"{MOD}._read_nonblocking")
@patch(f"{MOD}.select.select")
@patch(f"{MOD}.time.time")
def test_response_with_equals(
self,
mock_time: MagicMock,
mock_select: MagicMock,
mock_read: MagicMock,
) -> None:
mock_time.side_effect = [0.0, 0.5, 1.0]
mock_select.return_value = ([42], [], [])
mock_read.return_value = b"CODE=10001"
result = _wait_for_pjl_response(42, 0, 5.0)
assert b"CODE=10001" in result
@patch(f"{MOD}._read_nonblocking")
@patch(f"{MOD}.select.select")
@patch(f"{MOD}.time.time")
def test_response_with_pjl(
self,
mock_time: MagicMock,
mock_select: MagicMock,
mock_read: MagicMock,
) -> None:
mock_time.side_effect = [0.0, 0.5, 1.0]
mock_select.return_value = ([42], [], [])
mock_read.return_value = b"@PJL INFO"
result = _wait_for_pjl_response(42, 0, 5.0)
assert b"@PJL" in result
@patch(f"{MOD}.select.select")
@patch(f"{MOD}.time.time")
def test_timeout_no_data(
self,
mock_time: MagicMock,
mock_select: MagicMock,
) -> None:
mock_time.side_effect = [10.0, 11.0]
result = _wait_for_pjl_response(42, 0, 5.0)
assert result == b""
@patch(f"{MOD}._read_nonblocking")
@patch(f"{MOD}.select.select")
@patch(f"{MOD}.time.time")
def test_not_readable_then_timeout(
self,
mock_time: MagicMock,
mock_select: MagicMock,
mock_read: MagicMock,
) -> None:
mock_time.side_effect = [0.0, 0.5, 6.0]
mock_select.return_value = ([], [], [])
result = _wait_for_pjl_response(42, 0, 5.0)
assert result == b""
@patch(f"{MOD}._read_nonblocking")
@patch(f"{MOD}.select.select")
@patch(f"{MOD}.time.time")
def test_remaining_lte_zero(
self,
mock_time: MagicMock,
mock_select: MagicMock,
mock_read: MagicMock,
) -> None:
"""Inner remaining check triggers break."""
mock_time.side_effect = [0.0, 6.0, 6.0]
result = _wait_for_pjl_response(42, 0, 5.0)
assert result == b""
mock_select.assert_not_called()
@patch(f"{MOD}._read_nonblocking")
@patch(f"{MOD}.select.select")
@patch(f"{MOD}.time.time")
def test_response_no_eq_or_pjl(
self,
mock_time: MagicMock,
mock_select: MagicMock,
mock_read: MagicMock,
) -> None:
"""Data read but no '=' or '@PJL' → continues loop then times out."""
mock_time.side_effect = [0.0, 0.5, 1.0, 6.0]
mock_select.return_value = ([42], [], [])
mock_read.return_value = b"garbage"
result = _wait_for_pjl_response(42, 0, 5.0)
assert result == b"garbage"
class TestPjlQuery:
@patch(f"{MOD}._wait_for_pjl_response")
@patch(f"{MOD}.os.write")
@patch(f"{MOD}.fcntl.fcntl")
@patch(f"{MOD}.time.time", return_value=100.0)
def test_query(
self,
_t: MagicMock,
mock_fcntl: MagicMock,
mock_write: MagicMock,
mock_wait: MagicMock,
) -> None:
mock_fcntl.return_value = 0
mock_wait.return_value = b"CODE=10001"
result = pjl_query(42, "@PJL INFO STATUS")
assert "CODE=10001" in result
class TestParseStatus:
def test_found(self) -> None:
result = USBResult()
resp = 'CODE=10001\nDISPLAY= "Ready" \nONLINE=TRUE\n'
assert _parse_status(resp, result) is True
assert result.status_code == "10001"
assert result.display == "Ready"
assert result.online == "TRUE"
def test_not_found(self) -> None:
result = USBResult()
assert _parse_status("nothing here\n", result) is False
def test_partial(self) -> None:
result = USBResult()
resp = "DISPLAY=Hello\n"
assert _parse_status(resp, result) is False
assert result.display == "Hello"
class TestParseVariables:
def test_found(self) -> None:
result = USBResult()
resp = "ECONOMODE=ON extra\n"
assert _parse_variables(resp, result) is True
assert result.economode == "ON"
def test_not_found(self) -> None:
result = USBResult()
assert _parse_variables("nothing\n", result) is False
class TestRetryPjlQuery:
@patch(f"{MOD}.time.sleep")
@patch(f"{MOD}._drain_buffer")
@patch(f"{MOD}.pjl_query")
def test_success_first_attempt(
self,
mock_pjl: MagicMock,
_d: MagicMock,
_s: MagicMock,
) -> None:
result = USBResult()
mock_pjl.return_value = "CODE=10001\n"
_retry_pjl_query(42, "@PJL INFO STATUS", _parse_status, result, 2)
assert result.status_code == "10001"
assert mock_pjl.call_count == 1
@patch(f"{MOD}.time.sleep")
@patch(f"{MOD}._drain_buffer")
@patch(f"{MOD}.pjl_query")
def test_retry_then_success(
self,
mock_pjl: MagicMock,
_d: MagicMock,
_s: MagicMock,
) -> None:
result = USBResult()
mock_pjl.side_effect = ["garbage\n", "CODE=10001\n"]
_retry_pjl_query(42, "@PJL INFO STATUS", _parse_status, result, 2)
assert result.status_code == "10001"
assert mock_pjl.call_count == 2
@patch(f"{MOD}.time.sleep")
@patch(f"{MOD}._drain_buffer")
@patch(f"{MOD}.pjl_query")
def test_all_retries_fail(
self,
mock_pjl: MagicMock,
_d: MagicMock,
_s: MagicMock,
) -> None:
result = USBResult()
mock_pjl.return_value = "garbage\n"
_retry_pjl_query(42, "@PJL INFO STATUS", _parse_status, result, 2)
assert result.status_code == ""
assert mock_pjl.call_count == 3
class TestRunPjlQueries:
@patch(f"{MOD}._retry_pjl_query")
@patch(f"{MOD}.time.sleep")
@patch(f"{MOD}._drain_buffer")
@patch(f"{MOD}.os.write")
def test_runs_both_queries(
self,
mock_write: MagicMock,
_d: MagicMock,
_s: MagicMock,
mock_retry: MagicMock,
) -> None:
result = USBResult()
_run_pjl_queries(42, result, 2)
assert mock_retry.call_count == 2
class TestInitUsbResult:
@patch(f"{MOD}.get_printer_info_from_cups")
def test_from_cups(self, mock_cups: MagicMock) -> None:
mock_cups.return_value = {"product": "HL-1110", "serial": "SN1"}
result = _init_usb_result("/dev/usb/lp0")
assert result.device == "/dev/usb/lp0"
assert result.product == "HL-1110"
assert result.serial == "SN1"
@patch(f"{MOD}.get_printer_info_from_cups")
def test_no_product(self, mock_cups: MagicMock) -> None:
mock_cups.return_value = {"product": "", "serial": ""}
result = _init_usb_result("/dev/usb/lp0")
assert result.product == "Brother Laser Printer"
class TestQueryUsbPjl:
@patch(f"{MOD}.os.close")
@patch(f"{MOD}._run_pjl_queries")
@patch(f"{MOD}.fcntl.fcntl", return_value=0)
@patch(f"{MOD}.os.open", return_value=10)
@patch(f"{MOD}.os.access", return_value=True)
@patch(f"{MOD}._init_usb_result")
@patch(f"{MOD}.find_usb_printer_dev", return_value="/dev/usb/lp0")
def test_success(
self,
_f: MagicMock,
mock_init: MagicMock,
_a: MagicMock,
_o: MagicMock,
_fc: MagicMock,
_r: MagicMock,
_c: MagicMock,
) -> None:
mock_init.return_value = USBResult(device="/dev/usb/lp0")
result = query_usb_pjl()
assert result.device == "/dev/usb/lp0"
@patch(f"{MOD}.find_usb_printer_dev", return_value=None)
def test_no_dev_falls_back_to_cups(self, _f: MagicMock) -> None:
with patch(
"python_pkg.brother_printer.cups_service.query_usb_via_cups",
) as mock_cups:
mock_cups.return_value = USBResult(device="cups")
result = query_usb_pjl()
assert result.device == "cups"
@patch(f"{MOD}.os.access", return_value=False)
@patch(f"{MOD}._init_usb_result")
@patch(f"{MOD}.find_usb_printer_dev", return_value="/dev/usb/lp0")
def test_permission_denied(
self,
_f: MagicMock,
mock_init: MagicMock,
_a: MagicMock,
) -> None:
mock_init.return_value = USBResult(device="/dev/usb/lp0")
result = query_usb_pjl()
assert "Permission denied" in result.error
@patch(f"{MOD}.os.close")
@patch(f"{MOD}.fcntl.fcntl", side_effect=OSError("bad fd"))
@patch(f"{MOD}.os.open", return_value=10)
@patch(f"{MOD}.os.access", return_value=True)
@patch(f"{MOD}._init_usb_result")
@patch(f"{MOD}.find_usb_printer_dev", return_value="/dev/usb/lp0")
def test_oserror_on_open(
self,
_f: MagicMock,
mock_init: MagicMock,
_a: MagicMock,
_o: MagicMock,
_fc: MagicMock,
_c: MagicMock,
) -> None:
mock_init.return_value = USBResult(device="/dev/usb/lp0")
result = query_usb_pjl()
assert result.error != ""
@patch(f"{MOD}.os.open", side_effect=OSError("no device"))
@patch(f"{MOD}.os.access", return_value=True)
@patch(f"{MOD}._init_usb_result")
@patch(f"{MOD}.find_usb_printer_dev", return_value="/dev/usb/lp0")
def test_oserror_fd_none(
self,
_f: MagicMock,
mock_init: MagicMock,
_a: MagicMock,
_o: MagicMock,
) -> None:
"""os.open raises OSError before fd is set → fd stays None."""
mock_init.return_value = USBResult(device="/dev/usb/lp0")
result = query_usb_pjl()
assert result.error == "no device"

View File

@ -107,7 +107,7 @@ def _format_single_schedule(
f"{screening.end_str()} {screening.movie}\n"
)
output.write(
f" Duration: {hours}h {mins}m " f"(movie starts ~{actual_start_str})\n"
f" Duration: {hours}h {mins}m (movie starts ~{actual_start_str})\n"
)
if i < len(schedule):
gap = schedule[i].start - screening.end
@ -143,9 +143,7 @@ def _format_schedules(
output.write(f" OPTIMAL CINEMA SCHEDULES - {date}\n")
else:
output.write(" OPTIMAL CINEMA SCHEDULES\n")
output.write(
f" {num_movies} movies, " f"{num_schedules} possible combination(s)\n"
)
output.write(f" {num_movies} movies, {num_schedules} possible combination(s)\n")
output.write(f"{sep}\n\n")
display_count = min(num_schedules, max_display)
@ -158,9 +156,7 @@ def _format_schedules(
if num_schedules > display_count:
output.write(f"{thin_sep}\n")
output.write(
f" ... and {num_schedules - display_count} " "more combinations\n"
)
output.write(f" ... and {num_schedules - display_count} more combinations\n")
output.write(" (use -n to show more, e.g., -n 10)\n")
output.write("\n")

View File

@ -44,7 +44,7 @@ DEFAULT_EXCLUDED_GENRES = {"horror"}
def _build_parser() -> argparse.ArgumentParser:
"""Build the argument parser for the cinema planner."""
parser = argparse.ArgumentParser(
description=("Plan your cinema day to watch " "as many movies as possible."),
description=("Plan your cinema day to watch as many movies as possible."),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Supports Cinema City HTML/PDF schedules (auto-detected).
@ -270,7 +270,7 @@ def _output_schedules(
f.write(f"Movies considered: {len(all_movie_names)}\n")
f.write(f"Buffer time: {args.buffer} minutes\n")
if excluded_genres:
f.write("Excluded genres: " f"{', '.join(sorted(excluded_genres))}\n")
f.write(f"Excluded genres: {', '.join(sorted(excluded_genres))}\n")
f.write(schedule_output)
logger.info("Schedule saved to: %s", output_file)

View File

@ -0,0 +1,480 @@
"""Tests for _cinema_parsing module."""
from __future__ import annotations
from pathlib import Path
import subprocess
from typing import Any
from unittest.mock import MagicMock, mock_open, patch
import pytest
from python_pkg.cinema_planner._cinema_parsing import (
_exit_no_pdf_support,
_parse_cinema_city_pdf_basic,
_try_parse_interactive_line,
_try_parse_manual_line,
_try_parse_time,
extract_date_from_html,
parse_cinema_city_html,
parse_cinema_city_pdf,
parse_cinema_city_text,
parse_duration,
parse_manual_line,
parse_time,
)
class TestParseTime:
"""Tests for parse_time."""
def test_standard_time(self) -> None:
assert parse_time("18:20") == 18 * 60 + 20
def test_time_with_spaces(self) -> None:
assert parse_time(" 09:05 ") == 9 * 60 + 5
def test_time_with_dot(self) -> None:
assert parse_time("14.30") == 14 * 60 + 30
def test_single_digit_hour(self) -> None:
assert parse_time("9:05") == 9 * 60 + 5
def test_midnight(self) -> None:
assert parse_time("0:00") == 0
def test_invalid_format(self) -> None:
with pytest.raises(ValueError, match="Invalid time format"):
parse_time("abc")
def test_invalid_no_colon(self) -> None:
with pytest.raises(ValueError, match="Invalid time format"):
parse_time("1820")
class TestParseDuration:
"""Tests for parse_duration."""
def test_minutes_with_min(self) -> None:
assert parse_duration("110 min") == 110
def test_minutes_with_min_no_space(self) -> None:
assert parse_duration("90min") == 90
def test_hours_and_minutes(self) -> None:
assert parse_duration("1h 46m") == 106
def test_hours_only(self) -> None:
assert parse_duration("2h") == 120
def test_minutes_only_m(self) -> None:
assert parse_duration("46m") == 46
def test_colon_format(self) -> None:
assert parse_duration("1:46") == 106
def test_pure_number(self) -> None:
assert parse_duration("110") == 110
def test_invalid_format(self) -> None:
with pytest.raises(ValueError, match="Invalid duration format"):
parse_duration("abc")
class TestParseManualLine:
"""Tests for parse_manual_line."""
def test_basic_line(self) -> None:
result = parse_manual_line("Inception, 10:30 or 14:00, 2h 28m")
assert result is not None
assert result.name == "Inception"
assert result.start_times == [10 * 60 + 30, 14 * 60]
assert result.duration == 148
def test_empty_line(self) -> None:
assert parse_manual_line("") is None
def test_comment_line(self) -> None:
assert parse_manual_line("# comment") is None
def test_whitespace_line(self) -> None:
assert parse_manual_line(" ") is None
def test_too_few_parts(self) -> None:
with pytest.raises(ValueError, match="Invalid line format"):
parse_manual_line("Movie, 10:30")
def test_single_time(self) -> None:
result = parse_manual_line("Movie A, 18:20, 1h 46m")
assert result is not None
assert result.start_times == [18 * 60 + 20]
def test_multiple_times(self) -> None:
result = parse_manual_line("Movie B, 10:00 or 14:00 or 18:00, 120")
assert result is not None
assert len(result.start_times) == 3
def test_duration_with_comma(self) -> None:
# If duration part contains comma, the rest after parts[1] is duration
result = parse_manual_line("Movie C, 10:00, 1h, 30m")
assert result is not None
class TestTryParseTime:
"""Tests for _try_parse_time."""
def test_valid(self) -> None:
assert _try_parse_time("10:30") == 10 * 60 + 30
def test_invalid(self) -> None:
assert _try_parse_time("abc") is None
class TestTryParseManualLine:
"""Tests for _try_parse_manual_line."""
def test_valid_line(self) -> None:
result = _try_parse_manual_line("Movie, 10:00, 90min")
assert result is not None
assert result.name == "Movie"
def test_invalid_line_with_error_stream(self) -> None:
stream = MagicMock()
result = _try_parse_manual_line("bad line", stream)
assert result is None
stream.write.assert_called_once()
def test_invalid_line_no_error_stream(self) -> None:
result = _try_parse_manual_line("bad line")
assert result is None
def test_empty_line(self) -> None:
result = _try_parse_manual_line("")
assert result is None
class TestTryParseInteractiveLine:
"""Tests for _try_parse_interactive_line."""
def test_valid_line(self) -> None:
result = _try_parse_interactive_line("Movie, 10:00, 90min")
assert result is not None
assert result.name == "Movie"
def test_invalid_line(self) -> None:
result = _try_parse_interactive_line("bad line")
assert result is None
def test_empty_line(self) -> None:
result = _try_parse_interactive_line("")
assert result is None
class TestExtractDateFromHtml:
"""Tests for extract_date_from_html."""
def test_found_date(self) -> None:
assert extract_date_from_html("schedule 2025-01-25 data") == "2025-01-25"
def test_no_date(self) -> None:
assert extract_date_from_html("no date here") is None
def test_non_202x_date(self) -> None:
assert extract_date_from_html("1999-01-01") is None
class TestParseCinemaCityHtml:
"""Tests for parse_cinema_city_html."""
def _make_html_section(
self,
name: str,
duration: int,
times: list[str],
*,
genre: str = "",
) -> str:
genre_html = ""
if genre:
genre_html = f'<span class="mr-sm">{genre}<span>x</span></span>'
times_html = "".join(
f'<button class="btn btn-primary btn-lg">{t}</button>' for t in times
)
return (
f'class="row movie-row">'
f'<span class="qb-movie-name">{name}</span>'
f"{genre_html}"
f"<span>{duration} min</span>"
f"{times_html}"
)
def _patch_open(self, html: str) -> Any:
return patch.object(Path, "open", mock_open(read_data=html))
def test_parse_single_movie(self) -> None:
html = "header" + self._make_html_section("Movie A", 120, ["10:00", "14:00"])
with self._patch_open(html):
movies, date = parse_cinema_city_html("test.html")
assert len(movies) == 1
assert movies[0].name == "Movie A"
assert movies[0].duration == 120
assert len(movies[0].start_times) == 2
def test_parse_with_date(self) -> None:
html = "2025-01-25 stuff" + self._make_html_section("Movie A", 90, ["18:00"])
with self._patch_open(html):
movies, date = parse_cinema_city_html("test.html")
assert date == "2025-01-25"
def test_parse_with_genres(self) -> None:
html = "header" + self._make_html_section(
"Horror Film", 100, ["20:00"], genre="Horror, Thriller"
)
with self._patch_open(html):
movies, date = parse_cinema_city_html("test.html")
assert len(movies) == 1
assert "Horror" in movies[0].genres
assert "Thriller" in movies[0].genres
def test_no_name_match(self) -> None:
html = 'header class="row movie-row"> no name here'
with self._patch_open(html):
movies, date = parse_cinema_city_html("test.html")
assert len(movies) == 0
def test_no_duration_match(self) -> None:
html = (
'header class="row movie-row">'
'<span class="qb-movie-name">Movie</span>'
"no duration here"
'<button class="btn btn-primary btn-lg">10:00</button>'
)
with self._patch_open(html):
movies, date = parse_cinema_city_html("test.html")
assert len(movies) == 0
def test_no_times_match(self) -> None:
html = (
'header class="row movie-row">'
'<span class="qb-movie-name">Movie</span>'
"<span>100 min</span>"
)
with self._patch_open(html):
movies, date = parse_cinema_city_html("test.html")
assert len(movies) == 0
def test_alternate_time_pattern(self) -> None:
html = (
'header class="row movie-row">'
'<span class="qb-movie-name">Movie</span>'
"<span>100 min</span>"
"> 10:00 (HTTPS://something"
)
with self._patch_open(html):
movies, date = parse_cinema_city_html("test.html")
assert len(movies) == 1
def test_deduplicate_movies(self) -> None:
section = self._make_html_section("Movie A", 120, ["10:00"])
html = "header" + section + section
with self._patch_open(html):
movies, _ = parse_cinema_city_html("test.html")
assert len(movies) == 1
def test_no_genre_match(self) -> None:
html = (
'header class="row movie-row">'
'<span class="qb-movie-name">Movie</span>'
"<span>100 min</span>"
'<button class="btn btn-primary btn-lg">10:00</button>'
)
with self._patch_open(html):
movies, _ = parse_cinema_city_html("test.html")
assert len(movies) == 1
assert movies[0].genres == []
class TestParseCinemaCityPdf:
"""Tests for parse_cinema_city_pdf."""
@patch("python_pkg.cinema_planner._cinema_parsing._pdfplumber")
def test_with_pdfplumber(self, mock_pdfplumber: MagicMock) -> None:
mock_page = MagicMock()
mock_page.extract_text.return_value = "MOVIE TITLE\n110 min\n10:00\n"
mock_pdf = MagicMock()
mock_pdf.pages = [mock_page]
mock_pdfplumber.open.return_value.__enter__ = MagicMock(
return_value=mock_pdf,
)
mock_pdfplumber.open.return_value.__exit__ = MagicMock(return_value=False)
result = parse_cinema_city_pdf("test.pdf")
assert isinstance(result, list)
@patch(
"python_pkg.cinema_planner._cinema_parsing._pdfplumber",
None,
)
@patch(
"python_pkg.cinema_planner._cinema_parsing._parse_cinema_city_pdf_basic",
)
def test_fallback_to_basic(self, mock_basic: MagicMock) -> None:
mock_basic.return_value = []
result = parse_cinema_city_pdf("test.pdf")
mock_basic.assert_called_once_with("test.pdf")
assert result == []
@patch("python_pkg.cinema_planner._cinema_parsing._pdfplumber")
def test_pdfplumber_page_no_text(
self,
mock_pdfplumber: MagicMock,
) -> None:
mock_page = MagicMock()
mock_page.extract_text.return_value = None
mock_pdf = MagicMock()
mock_pdf.pages = [mock_page]
mock_pdfplumber.open.return_value.__enter__ = MagicMock(
return_value=mock_pdf,
)
mock_pdfplumber.open.return_value.__exit__ = MagicMock(return_value=False)
result = parse_cinema_city_pdf("test.pdf")
assert result == []
class TestParseCinemaCityPdfBasic:
"""Tests for _parse_cinema_city_pdf_basic."""
@patch("python_pkg.cinema_planner._cinema_parsing._fitz")
def test_with_fitz(self, mock_fitz: MagicMock) -> None:
mock_page = MagicMock()
mock_page.get_text.return_value = "MOVIE TITLE\n110 min\n10:00\n"
mock_doc = MagicMock()
mock_doc.__iter__ = MagicMock(return_value=iter([mock_page]))
mock_fitz.open.return_value = mock_doc
result = _parse_cinema_city_pdf_basic("test.pdf")
mock_doc.close.assert_called_once()
assert isinstance(result, list)
@patch("python_pkg.cinema_planner._cinema_parsing._fitz", None)
@patch("python_pkg.cinema_planner._cinema_parsing.shutil")
def test_pdftotext_success(self, mock_shutil: MagicMock) -> None:
mock_shutil.which.return_value = "/usr/bin/pdftotext"
mock_result = MagicMock()
mock_result.stdout = "MOVIE TITLE\n110 min\n10:00\n"
with patch(
"python_pkg.cinema_planner._cinema_parsing.subprocess.run",
return_value=mock_result,
):
result = _parse_cinema_city_pdf_basic("test.pdf")
assert isinstance(result, list)
@patch("python_pkg.cinema_planner._cinema_parsing._fitz", None)
@patch("python_pkg.cinema_planner._cinema_parsing.shutil")
def test_no_pdftotext(self, mock_shutil: MagicMock) -> None:
mock_shutil.which.return_value = None
with pytest.raises(SystemExit):
_parse_cinema_city_pdf_basic("test.pdf")
@patch("python_pkg.cinema_planner._cinema_parsing._fitz", None)
@patch("python_pkg.cinema_planner._cinema_parsing.shutil")
def test_pdftotext_process_error(self, mock_shutil: MagicMock) -> None:
mock_shutil.which.return_value = "/usr/bin/pdftotext"
with (
patch(
"python_pkg.cinema_planner._cinema_parsing.subprocess.run",
side_effect=subprocess.CalledProcessError(1, "pdftotext"),
),
pytest.raises(SystemExit),
):
_parse_cinema_city_pdf_basic("test.pdf")
class TestExitNoPdfSupport:
"""Tests for _exit_no_pdf_support."""
def test_exits(self) -> None:
with pytest.raises(SystemExit):
_exit_no_pdf_support()
class TestParseCinemaCityText:
"""Tests for parse_cinema_city_text."""
def test_single_movie(self) -> None:
text = "MOVIE TITLE\n110 min\n10:00\n14:00\n"
result = parse_cinema_city_text(text)
assert len(result) == 1
assert result[0].name == "Movie Title"
assert result[0].duration == 110
assert len(result[0].start_times) == 2
def test_multiple_movies(self) -> None:
text = "FIRST MOVIE\n90 min\n10:00\nSECOND MOVIE\n120 min\n14:00\n18:00\n"
result = parse_cinema_city_text(text)
assert len(result) == 2
def test_movie_without_duration(self) -> None:
text = "MOVIE TITLE\n10:00\n14:00\n"
result = parse_cinema_city_text(text)
assert len(result) == 1
assert result[0].duration == 120 # default
def test_no_times(self) -> None:
text = "MOVIE TITLE\n110 min\nno times here\n"
result = parse_cinema_city_text(text)
assert len(result) == 0
def test_empty_text(self) -> None:
result = parse_cinema_city_text("")
assert result == []
def test_title_too_short(self) -> None:
text = "AB\n110 min\n10:00\n"
result = parse_cinema_city_text(text)
assert len(result) == 0
def test_lowercase_line_ignored_as_title(self) -> None:
text = "some lowercase text\n110 min\n10:00\n"
result = parse_cinema_city_text(text)
assert len(result) == 0
def test_duration_in_lookahead(self) -> None:
text = "MOVIE TITLE\nsome other line\n95 min\n10:00\n"
result = parse_cinema_city_text(text)
assert len(result) == 1
assert result[0].duration == 95
def test_deduplicates_times(self) -> None:
text = "MOVIE TITLE\n110 min\n10:00\n10:00\n"
result = parse_cinema_city_text(text)
assert len(result) == 1
assert len(result[0].start_times) == 1
def test_movie_saved_when_new_title_found(self) -> None:
text = "FIRST MOVIE\n90 min\n10:00\nSECOND MOVIE\n120 min\n14:00\n"
result = parse_cinema_city_text(text)
assert len(result) == 2
assert result[0].name == "First Movie"
assert result[1].name == "Second Movie"
def test_time_on_same_line_as_other_text(self) -> None:
text = "MOVIE TITLE\n110 min\nSome text 10:00 more text\n"
result = parse_cinema_city_text(text)
assert len(result) == 1
def test_try_parse_time_returns_none(self) -> None:
# Time pattern \b(\d{1,2}:\d{2})\b matches but parse_time fails
# This can happen when parse_time validates more strictly
text = "MOVIE TITLE\n110 min\n10:00\n"
with patch(
"python_pkg.cinema_planner._cinema_parsing._try_parse_time",
side_effect=lambda t: None,
):
result = parse_cinema_city_text(text)
assert len(result) == 0
def test_movie_no_times_not_saved(self) -> None:
# Movie with title but no valid times on subsequent lines
text = "MOVIE ONE\n110 min\nno times\nMOVIE TWO\n90 min\n10:00\n"
result = parse_cinema_city_text(text)
assert len(result) == 1
assert result[0].name == "Movie Two"

Some files were not shown because too many files have changed in this diff Show More