mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 13:23:15 +02:00
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:
parent
72c6c3788c
commit
2545d72710
4
.github/workflows/python-tests.yml
vendored
4
.github/workflows/python-tests.yml
vendored
@ -28,5 +28,5 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
- name: Run pytest
|
- name: Run pytest with coverage
|
||||||
run: pytest -q
|
run: pytest --cov=python_pkg --cov-branch --cov-report=term-missing --cov-fail-under=100
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -319,3 +319,7 @@ CPP/miscelanious/howManyValidISBNNumbersAreThere/ISBN.txt
|
|||||||
|
|
||||||
# Focus mode secrets (contains home GPS coordinates)
|
# Focus mode secrets (contains home GPS coordinates)
|
||||||
phone_focus_mode/config_secrets.sh
|
phone_focus_mode/config_secrets.sh
|
||||||
|
|
||||||
|
# Generated output files
|
||||||
|
out.txt
|
||||||
|
cinema_plan_*.txt
|
||||||
|
|||||||
@ -75,7 +75,7 @@ repos:
|
|||||||
# RUFF - Fast Python linter and formatter (replaces black, isort, flake8, etc.)
|
# RUFF - Fast Python linter and formatter (replaces black, isort, flake8, etc.)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.8.1
|
rev: v0.15.2
|
||||||
hooks:
|
hooks:
|
||||||
# Linter - run first to catch issues
|
# Linter - run first to catch issues
|
||||||
- id: ruff
|
- id: ruff
|
||||||
@ -165,6 +165,18 @@ repos:
|
|||||||
additional_dependencies: ["bandit[toml]"]
|
additional_dependencies: ["bandit[toml]"]
|
||||||
exclude: ^(Bash/|\.venv/|tests/|.*test.*\.py$)
|
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)
|
# VULTURE - Dead code detection (disabled - doesn't work well with pre-commit)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@ -196,7 +208,7 @@ repos:
|
|||||||
- id: codespell
|
- id: codespell
|
||||||
args:
|
args:
|
||||||
- --skip=*.json,*.lock,*.min.js,*.min.css,.git,__pycache__,.venv,*.txt
|
- --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$)
|
exclude: ^(Bash/ffmpeg-build/|LaTeX/|CPP/|.*\.geojson$)
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
@ -220,7 +220,7 @@ class FocusMode:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Handle updates when no mode is active."""
|
"""Handle updates when no mode is active."""
|
||||||
if steam_running and browser_running:
|
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.current_mode = "gaming"
|
||||||
self.mode_start_time = datetime.now(tz=timezone.utc)
|
self.mode_start_time = datetime.now(tz=timezone.utc)
|
||||||
kill_browsers()
|
kill_browsers()
|
||||||
@ -228,13 +228,13 @@ class FocusMode:
|
|||||||
self._enter_mode(
|
self._enter_mode(
|
||||||
"gaming",
|
"gaming",
|
||||||
"Steam detected - entering GAMING mode",
|
"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:
|
elif browser_running:
|
||||||
self._enter_mode(
|
self._enter_mode(
|
||||||
"browsing",
|
"browsing",
|
||||||
"Browser detected - entering BROWSING mode",
|
"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(
|
def _handle_gaming(
|
||||||
@ -254,7 +254,7 @@ class FocusMode:
|
|||||||
"normal",
|
"normal",
|
||||||
)
|
)
|
||||||
elif browser_running:
|
elif browser_running:
|
||||||
log("Browser detected during GAMING mode " "- killing browsers")
|
log("Browser detected during GAMING mode - killing browsers")
|
||||||
kill_browsers()
|
kill_browsers()
|
||||||
|
|
||||||
def _handle_browsing(
|
def _handle_browsing(
|
||||||
@ -274,7 +274,7 @@ class FocusMode:
|
|||||||
"normal",
|
"normal",
|
||||||
)
|
)
|
||||||
elif steam_running:
|
elif steam_running:
|
||||||
log("Steam detected during BROWSING mode " "- killing Steam")
|
log("Steam detected during BROWSING mode - killing Steam")
|
||||||
kill_steam()
|
kill_steam()
|
||||||
|
|
||||||
def update(self, processes: set[str]) -> None:
|
def update(self, processes: set[str]) -> None:
|
||||||
@ -310,8 +310,8 @@ class FocusMode:
|
|||||||
duration = f" (active for {minutes}m)"
|
duration = f" (active for {minutes}m)"
|
||||||
|
|
||||||
if self.current_mode == "gaming":
|
if self.current_mode == "gaming":
|
||||||
return f"\U0001f3ae GAMING mode{duration}" " - browsers blocked"
|
return f"\U0001f3ae GAMING mode{duration} - browsers blocked"
|
||||||
return f"\U0001f310 BROWSING mode{duration}" " - Steam blocked"
|
return f"\U0001f310 BROWSING mode{duration} - Steam blocked"
|
||||||
|
|
||||||
|
|
||||||
def write_status(focus: FocusMode) -> None:
|
def write_status(focus: FocusMode) -> None:
|
||||||
|
|||||||
@ -63,7 +63,7 @@ def _probe_with_ffprobe(path: str) -> float | None:
|
|||||||
"-show_entries",
|
"-show_entries",
|
||||||
"format=duration",
|
"format=duration",
|
||||||
"-of",
|
"-of",
|
||||||
"default=" "noprint_wrappers=1:nokey=1",
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
path,
|
path,
|
||||||
],
|
],
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
@ -246,7 +246,7 @@ def _load_audio(
|
|||||||
alt = _ffmpeg_transcode_to_wav16_mono(audio_path)
|
alt = _ffmpeg_transcode_to_wav16_mono(audio_path)
|
||||||
if alt is None:
|
if alt is None:
|
||||||
logger.warning(
|
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,
|
exc,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@ -334,7 +334,7 @@ def diarize_segments(
|
|||||||
torch_mod = _try_import("torch")
|
torch_mod = _try_import("torch")
|
||||||
if torch_mod is None:
|
if torch_mod is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Diarization dependencies missing; " "skipping speaker labels.",
|
"Diarization dependencies missing; skipping speaker labels.",
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@ -88,7 +88,7 @@ def _download_files(
|
|||||||
repo_id,
|
repo_id,
|
||||||
)
|
)
|
||||||
logger.info(
|
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)
|
_log_total_download_size(repo_id, required_files)
|
||||||
@ -156,7 +156,7 @@ def download_model_with_progress(
|
|||||||
hh = _try_import("huggingface_hub")
|
hh = _try_import("huggingface_hub")
|
||||||
if hh is None:
|
if hh is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"huggingface_hub not available, " "falling back to default download",
|
"huggingface_hub not available, falling back to default download",
|
||||||
)
|
)
|
||||||
return model_name
|
return model_name
|
||||||
|
|
||||||
@ -181,7 +181,7 @@ def download_model_with_progress(
|
|||||||
return _download_files(repo_id, required_files)
|
return _download_files(repo_id, required_files)
|
||||||
except (OSError, RuntimeError) as exc:
|
except (OSError, RuntimeError) as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Custom download failed (%s), " "falling back to default",
|
"Custom download failed (%s), falling back to default",
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return model_name
|
return model_name
|
||||||
|
|||||||
@ -15,7 +15,7 @@ def format_timestamp(seconds: float) -> str:
|
|||||||
minutes = (total_seconds % 3600) // 60
|
minutes = (total_seconds % 3600) // 60
|
||||||
secs = total_seconds % 60
|
secs = total_seconds % 60
|
||||||
millis = int((seconds - int(seconds)) * 1000)
|
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:
|
def write_srt(segments: list[Any], srt_path: str) -> None:
|
||||||
@ -56,7 +56,7 @@ def write_srt_with_speakers(
|
|||||||
spk = f"SPK{lab + 1}"
|
spk = f"SPK{lab + 1}"
|
||||||
start_ts = format_timestamp(seg.start)
|
start_ts = format_timestamp(seg.start)
|
||||||
end_ts = format_timestamp(seg.end)
|
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(
|
def write_txt_with_speakers(
|
||||||
@ -87,9 +87,7 @@ def write_rttm(
|
|||||||
dur = max(0.0, end - start)
|
dur = max(0.0, end - start)
|
||||||
name = f"SPK{lab + 1}"
|
name = f"SPK{lab + 1}"
|
||||||
f.write(
|
f.write(
|
||||||
f"SPEAKER {file_id} 1 "
|
f"SPEAKER {file_id} 1 {start:.3f} {dur:.3f} <NA> <NA> {name} <NA>\n"
|
||||||
f"{start:.3f} {dur:.3f} "
|
|
||||||
f"<NA> <NA> {name} <NA>\n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -33,7 +33,7 @@ def _try_import(name: str) -> types.ModuleType | None:
|
|||||||
def _parse_args() -> argparse.Namespace:
|
def _parse_args() -> argparse.Namespace:
|
||||||
"""Parse command-line arguments."""
|
"""Parse command-line arguments."""
|
||||||
parser = argparse.ArgumentParser(
|
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("input", help="Path to audio/video file")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@ -152,9 +152,7 @@ def _format_progress_line(
|
|||||||
)
|
)
|
||||||
elapsed = now - start_ts
|
elapsed = now - start_ts
|
||||||
line = (
|
line = (
|
||||||
f"[PROGRESS] {hhmmss(processed)} / "
|
f"[PROGRESS] {hhmmss(processed)} / {hhmmss(total_duration)} ({pct:5.1f}%)"
|
||||||
f"{hhmmss(total_duration)} "
|
|
||||||
f"({pct:5.1f}%)"
|
|
||||||
)
|
)
|
||||||
if processed > 0:
|
if processed > 0:
|
||||||
rate = processed / max(1e-6, elapsed)
|
rate = processed / max(1e-6, elapsed)
|
||||||
@ -206,7 +204,7 @@ def _write_diarized_outputs(
|
|||||||
logger.info("Wrote: %s", rttm_path)
|
logger.info("Wrote: %s", rttm_path)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
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")
|
fw = _try_import("faster_whisper")
|
||||||
if fw is None:
|
if fw is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
"faster-whisper is not installed " "in this environment.",
|
"faster-whisper is not installed in this environment.",
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
@ -241,7 +239,7 @@ def main() -> int:
|
|||||||
device, compute_type = _resolve_device_and_compute(args)
|
device, compute_type = _resolve_device_and_compute(args)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Loading model='%s', device='%s', " "compute_type='%s'",
|
"Loading model='%s', device='%s', compute_type='%s'",
|
||||||
args.model,
|
args.model,
|
||||||
device,
|
device,
|
||||||
compute_type,
|
compute_type,
|
||||||
|
|||||||
@ -47,7 +47,7 @@ def check_diarization_deps() -> bool:
|
|||||||
_torch = _try_import("torch")
|
_torch = _try_import("torch")
|
||||||
if _sf is None or _sb is None or _torch is None:
|
if _sf is None or _sb is None or _torch is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Diarization deps missing offline; " "speaker labels will be skipped.",
|
"Diarization deps missing offline; speaker labels will be skipped.",
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
return True
|
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("Preparing model '%s' into %s", model_name, model_dir)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Downloading model files " "(progress bar should appear below)...",
|
"Downloading model files (progress bar should appear below)...",
|
||||||
)
|
)
|
||||||
fw.WhisperModel(
|
fw.WhisperModel(
|
||||||
model_name,
|
model_name,
|
||||||
|
|||||||
@ -49,20 +49,44 @@ unfixable = []
|
|||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
# Test files - allow test-specific patterns (assert, magic values)
|
# Test files - allow test-specific patterns (assert, magic values)
|
||||||
"**/tests/**/*.py" = [
|
"**/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
|
"PLR2004", # Allow magic values in tests
|
||||||
"PT019", # Allow underscore-prefixed fixture params
|
"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
|
"SLF001", # Allow private member access in tests
|
||||||
]
|
]
|
||||||
"**/test_*.py" = [
|
"**/test_*.py" = [
|
||||||
"S101", # Allow assert in tests
|
"ANN", # Allow missing type annotations in tests
|
||||||
"S310", # Allow URL open in tests
|
"ARG", # Allow unused arguments (fixtures, mocks)
|
||||||
"S607", # Allow partial executable path in tests
|
"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
|
"PLC0415", # Allow late imports for test isolation
|
||||||
|
"PLR0913", # Allow many arguments (mock patches)
|
||||||
"PLR2004", # Allow magic values in tests
|
"PLR2004", # Allow magic values in tests
|
||||||
"PT019", # Allow underscore-prefixed fixture params
|
"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
|
"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
|
# Files using urlopen with validated URL schemes
|
||||||
"python_pkg/geo_data/_common.py" = ["S310"]
|
"python_pkg/geo_data/_common.py" = ["S310"]
|
||||||
"python_pkg/steam_backlog_enforcer/library_hider.py" = ["S310"]
|
"python_pkg/steam_backlog_enforcer/library_hider.py" = ["S310"]
|
||||||
@ -257,23 +281,26 @@ addopts = [
|
|||||||
"--strict-markers",
|
"--strict-markers",
|
||||||
"--strict-config",
|
"--strict-config",
|
||||||
"-ra",
|
"-ra",
|
||||||
|
"--cov=python_pkg",
|
||||||
|
"--cov-branch",
|
||||||
|
"--cov-report=term-missing",
|
||||||
]
|
]
|
||||||
filterwarnings = [
|
filterwarnings = [
|
||||||
"error",
|
"error",
|
||||||
"ignore::DeprecationWarning",
|
"ignore::DeprecationWarning",
|
||||||
|
"default::pytest.PytestUnraisableExceptionWarning",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# COVERAGE - Code coverage configuration
|
# COVERAGE - Code coverage configuration
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["."]
|
source = ["python_pkg"]
|
||||||
branch = true
|
branch = true
|
||||||
omit = [
|
omit = [
|
||||||
"*/__pycache__/*",
|
"*/__pycache__/*",
|
||||||
"*/tests/*",
|
"*/tests/*",
|
||||||
"*/.venv/*",
|
"*/.venv/*",
|
||||||
"Bash/*",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
|
|||||||
11
python_pkg/anki_decks/conftest.py
Normal file
11
python_pkg/anki_decks/conftest.py
Normal 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))
|
||||||
@ -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
|
||||||
@ -292,8 +292,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_forests = list(forests.iterrows())[: args.preview_count]
|
preview_forests = list(forests.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_forests)} preview images "
|
f"Exporting {len(preview_forests)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_forests:
|
for _, row in preview_forests:
|
||||||
forest_name = row["name"]
|
forest_name = row["name"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -373,8 +373,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
# Pre-compute color mapping for previews
|
# Pre-compute color mapping for previews
|
||||||
color_map = _build_color_map(gminy["name"].tolist())
|
color_map = _build_color_map(gminy["name"].tolist())
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_gminy)} preview images "
|
f"Exporting {len(preview_gminy)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_gminy:
|
for _, row in preview_gminy:
|
||||||
gmina_name = row["name"]
|
gmina_name = row["name"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -378,8 +378,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_islands = list(islands.iterrows())[: args.preview_count]
|
preview_islands = list(islands.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_islands)} preview images "
|
f"Exporting {len(preview_islands)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_islands:
|
for _, row in preview_islands:
|
||||||
island_name = row["name"]
|
island_name = row["name"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -331,8 +331,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_lakes = list(lakes.iterrows())[: args.preview_count]
|
preview_lakes = list(lakes.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_lakes)} preview images "
|
f"Exporting {len(preview_lakes)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_lakes:
|
for _, row in preview_lakes:
|
||||||
lake_name = row["name"]
|
lake_name = row["name"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -304,8 +304,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_parks = list(parks.iterrows())[: args.preview_count]
|
preview_parks = list(parks.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_parks)} preview images "
|
f"Exporting {len(preview_parks)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_parks:
|
for _, row in preview_parks:
|
||||||
park_name = row["name"]
|
park_name = row["name"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -360,8 +360,7 @@ def main() -> int:
|
|||||||
sys.stdout.write("\n")
|
sys.stdout.write("\n")
|
||||||
sys.stdout.write("Data source: Wikipedia\n")
|
sys.stdout.write("Data source: Wikipedia\n")
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
"URL: https://en.wikipedia.org/wiki/"
|
"URL: https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland\n"
|
||||||
"Vehicle_registration_plates_of_Poland\n"
|
|
||||||
)
|
)
|
||||||
sys.stdout.write(f"Cache location: {get_cache_path()}\n")
|
sys.stdout.write(f"Cache location: {get_cache_path()}\n")
|
||||||
sys.stdout.write(f"Cache expiry: {CACHE_EXPIRY_DAYS} days\n")
|
sys.stdout.write(f"Cache expiry: {CACHE_EXPIRY_DAYS} days\n")
|
||||||
|
|||||||
@ -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 == {}
|
||||||
@ -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
|
||||||
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@ -226,6 +227,16 @@ class TestMain:
|
|||||||
main(["--help"])
|
main(["--help"])
|
||||||
assert exc_info.value.code == 0
|
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__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
@ -345,8 +345,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_peaks = list(peaks.iterrows())[: args.preview_count]
|
preview_peaks = list(peaks.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_peaks)} preview images "
|
f"Exporting {len(preview_peaks)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_peaks:
|
for _, row in preview_peaks:
|
||||||
peak_name = row["name"]
|
peak_name = row["name"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -300,8 +300,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_ranges = list(ranges.iterrows())[: args.preview_count]
|
preview_ranges = list(ranges.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_ranges)} preview images "
|
f"Exporting {len(preview_ranges)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_ranges:
|
for _, row in preview_ranges:
|
||||||
range_name = row["name"]
|
range_name = row["name"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -316,8 +316,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_parks = list(parks.iterrows())[: args.preview_count]
|
preview_parks = list(parks.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_parks)} preview images "
|
f"Exporting {len(preview_parks)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_parks:
|
for _, row in preview_parks:
|
||||||
park_name = row["name"]
|
park_name = row["name"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -278,8 +278,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_powiaty = list(powiaty.iterrows())[: args.preview_count]
|
preview_powiaty = list(powiaty.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_powiaty)} preview images "
|
f"Exporting {len(preview_powiaty)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_powiaty:
|
for _, row in preview_powiaty:
|
||||||
powiat_name = row["nazwa"]
|
powiat_name = row["nazwa"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -325,8 +325,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_rivers = list(rivers.iterrows())[: args.preview_count]
|
preview_rivers = list(rivers.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_rivers)} preview images "
|
f"Exporting {len(preview_rivers)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_rivers:
|
for _, row in preview_rivers:
|
||||||
river_name = row["name"]
|
river_name = row["name"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -333,8 +333,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_sites = list(sites.iterrows())[: args.preview_count]
|
preview_sites = list(sites.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_sites)} preview images "
|
f"Exporting {len(preview_sites)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_sites:
|
for _, row in preview_sites:
|
||||||
site_name = row["name"]
|
site_name = row["name"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -286,8 +286,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_bridges = list(bridges.iterrows())[: args.preview_count]
|
preview_bridges = list(bridges.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_bridges)} preview images "
|
f"Exporting {len(preview_bridges)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_bridges:
|
for _, row in preview_bridges:
|
||||||
bridge_name = row["name"]
|
bridge_name = row["name"]
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import pytest
|
import pytest
|
||||||
@ -13,6 +14,7 @@ try:
|
|||||||
create_district_map,
|
create_district_map,
|
||||||
generate_anki_package,
|
generate_anki_package,
|
||||||
generate_district_image_bytes,
|
generate_district_image_bytes,
|
||||||
|
load_district_data,
|
||||||
main,
|
main,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -24,6 +26,7 @@ except ImportError:
|
|||||||
create_district_map,
|
create_district_map,
|
||||||
generate_anki_package,
|
generate_anki_package,
|
||||||
generate_district_image_bytes,
|
generate_district_image_bytes,
|
||||||
|
load_district_data,
|
||||||
main,
|
main,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -170,6 +173,41 @@ class TestMain:
|
|||||||
main(["--help"])
|
main(["--help"])
|
||||||
assert exc_info.value.code == 0
|
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__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -295,8 +295,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
preview_osiedla = list(osiedla.iterrows())[: args.preview_count]
|
preview_osiedla = list(osiedla.iterrows())[: args.preview_count]
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"Exporting {len(preview_osiedla)} preview images "
|
f"Exporting {len(preview_osiedla)} preview images to {preview_dir}...\n"
|
||||||
f"to {preview_dir}...\n"
|
|
||||||
)
|
)
|
||||||
for _, row in preview_osiedla:
|
for _, row in preview_osiedla:
|
||||||
osiedle_name = row["name"]
|
osiedle_name = row["name"]
|
||||||
|
|||||||
@ -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
|
||||||
@ -80,9 +80,9 @@ def get_unique_streets(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def load_street_data() -> (
|
def load_street_data() -> tuple[
|
||||||
tuple[list[tuple[str, gpd.GeoDataFrame, float]], gpd.GeoDataFrame]
|
list[tuple[str, gpd.GeoDataFrame, float]], gpd.GeoDataFrame
|
||||||
):
|
]:
|
||||||
"""Load Warsaw streets and boundary.
|
"""Load Warsaw streets and boundary.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
0
python_pkg/articles/tests/__init__.py
Normal file
0
python_pkg/articles/tests/__init__.py
Normal file
@ -30,7 +30,7 @@ def _req(
|
|||||||
def test_crud_roundtrip(tmp_path: Path) -> None:
|
def test_crud_roundtrip(tmp_path: Path) -> None:
|
||||||
"""Test full CRUD lifecycle for articles API."""
|
"""Test full CRUD lifecycle for articles API."""
|
||||||
# Build C server
|
# 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))
|
subprocess.run(["make", "-s", "server_c"], check=True, cwd=str(here))
|
||||||
|
|
||||||
# Find a free port
|
# 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:
|
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||||
_req(base + f"/api/articles/{art_id}")
|
_req(base + f"/api/articles/{art_id}")
|
||||||
assert exc_info.value.code == HTTPStatus.NOT_FOUND
|
assert exc_info.value.code == HTTPStatus.NOT_FOUND
|
||||||
|
exc_info.value.close()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
srv.terminate()
|
srv.terminate()
|
||||||
@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
# Budget for the entire website (single file) in bytes
|
# Budget for the entire website (single file) in bytes
|
||||||
BUDGET = 14 * 1024 # 14 KiB
|
BUDGET = 14 * 1024 # 14 KiB
|
||||||
|
|
||||||
HERE = Path(__file__).parent
|
HERE = Path(__file__).parent.parent
|
||||||
SITE_FILE = HERE / "index.html"
|
SITE_FILE = HERE / "index.html"
|
||||||
|
|
||||||
|
|
||||||
0
python_pkg/brightness_controller/tests/__init__.py
Normal file
0
python_pkg/brightness_controller/tests/__init__.py
Normal 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 ─────────────────────────────────────────────────────────────────
|
||||||
@ -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()
|
||||||
@ -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()
|
||||||
@ -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()
|
||||||
@ -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"
|
||||||
@ -78,26 +78,23 @@ BROTHER_STATUS_CODES: dict[int, tuple[str, str, str]] = {
|
|||||||
40309: (
|
40309: (
|
||||||
"critical",
|
"critical",
|
||||||
"Replace Toner",
|
"Replace Toner",
|
||||||
"The toner cartridge needs immediate replacement"
|
"The toner cartridge needs immediate replacement (TN-1050/TN-1030 compatible).",
|
||||||
" (TN-1050/TN-1030 compatible).",
|
|
||||||
),
|
),
|
||||||
40310: (
|
40310: (
|
||||||
"critical",
|
"critical",
|
||||||
"Toner End",
|
"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
|
# Drum
|
||||||
30201: (
|
30201: (
|
||||||
"warn",
|
"warn",
|
||||||
"Drum End Soon",
|
"Drum End Soon",
|
||||||
"The drum unit is nearing end of life."
|
"The drum unit is nearing end of life. Order replacement (DR-1050 compatible).",
|
||||||
" Order replacement (DR-1050 compatible).",
|
|
||||||
),
|
),
|
||||||
40201: (
|
40201: (
|
||||||
"warn",
|
"warn",
|
||||||
"Drum End Soon",
|
"Drum End Soon",
|
||||||
"The drum unit is nearing end of life."
|
"The drum unit is nearing end of life. Order replacement (DR-1050 compatible).",
|
||||||
" Order replacement (DR-1050 compatible).",
|
|
||||||
),
|
),
|
||||||
40019: (
|
40019: (
|
||||||
"critical",
|
"critical",
|
||||||
|
|||||||
@ -179,10 +179,7 @@ def _cups_restart_service() -> bool:
|
|||||||
proc.kill()
|
proc.kill()
|
||||||
proc.wait()
|
proc.wait()
|
||||||
sys.stdout.write("\n")
|
sys.stdout.write("\n")
|
||||||
_out(
|
_out(f" {RED}CUPS restart timed out (stuck backend process?).{RESET}")
|
||||||
f" {RED}CUPS restart timed out"
|
|
||||||
f" (stuck backend process?).{RESET}"
|
|
||||||
)
|
|
||||||
_out(
|
_out(
|
||||||
f" {DIM}Try: sudo kill -9 $(pgrep -f 'cups/backend/usb')"
|
f" {DIM}Try: sudo kill -9 $(pgrep -f 'cups/backend/usb')"
|
||||||
f" && sudo systemctl restart cups{RESET}"
|
f" && sudo systemctl restart cups{RESET}"
|
||||||
@ -193,9 +190,7 @@ def _cups_restart_service() -> bool:
|
|||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
sys.stdout.write("\n")
|
sys.stdout.write("\n")
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
_out(
|
_out(f" {RED}CUPS restart failed (exit code {proc.returncode}).{RESET}")
|
||||||
f" {RED}CUPS restart failed" f" (exit code {proc.returncode}).{RESET}"
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
sys.stdout.write("\n")
|
sys.stdout.write("\n")
|
||||||
|
|||||||
@ -233,9 +233,7 @@ def reset_consumable(name: str) -> None:
|
|||||||
key = f"{name}_replaced_at"
|
key = f"{name}_replaced_at"
|
||||||
state[key] = total
|
state[key] = total
|
||||||
_save_consumable_state(state)
|
_save_consumable_state(state)
|
||||||
_out(
|
_out(f"{GREEN}✓ {name.capitalize()} counter reset at page count {total}.{RESET}")
|
||||||
f"{GREEN}✓ {name.capitalize()} counter reset at page count" f" {total}.{RESET}"
|
|
||||||
)
|
|
||||||
_out(f" State saved to {CONSUMABLE_STATE_FILE}")
|
_out(f" State saved to {CONSUMABLE_STATE_FILE}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -87,10 +87,7 @@ def _display_page_count_estimate() -> None:
|
|||||||
else:
|
else:
|
||||||
drum_color = GREEN
|
drum_color = GREEN
|
||||||
drum_note = ""
|
drum_note = ""
|
||||||
_out(
|
_out(f" {BOLD}Drum:{RESET} {drum_color}{drum_bar} ~{drum_pct}%{drum_note}{RESET}")
|
||||||
f" {BOLD}Drum:{RESET} {drum_color}{drum_bar} ~{drum_pct}%"
|
|
||||||
f"{drum_note}{RESET}"
|
|
||||||
)
|
|
||||||
_out(
|
_out(
|
||||||
f" {DIM}Based on pages since last replacement"
|
f" {DIM}Based on pages since last replacement"
|
||||||
f" vs rated capacity (toner ~{TONER_RATED_PAGES},"
|
f" vs rated capacity (toner ~{TONER_RATED_PAGES},"
|
||||||
@ -158,7 +155,7 @@ _SEVERITY_COLORS: dict[str, str] = {
|
|||||||
_SEVERITY_SUMMARIES: dict[str, str] = {
|
_SEVERITY_SUMMARIES: dict[str, str] = {
|
||||||
"ok": f"{GREEN}{BOLD}✓ Printer is healthy. No replacements needed.{RESET}",
|
"ok": f"{GREEN}{BOLD}✓ Printer is healthy. No replacements needed.{RESET}",
|
||||||
"info": (
|
"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": (
|
"warn": (
|
||||||
f"{YELLOW}{BOLD}⚡ WARNING: Maintenance will be needed"
|
f"{YELLOW}{BOLD}⚡ WARNING: Maintenance will be needed"
|
||||||
@ -166,7 +163,7 @@ _SEVERITY_SUMMARIES: dict[str, str] = {
|
|||||||
f" now to avoid interruption.{RESET}"
|
f" now to avoid interruption.{RESET}"
|
||||||
),
|
),
|
||||||
"critical": (
|
"critical": (
|
||||||
f"{RED}{BOLD}⚠ ACTION REQUIRED:" f" Replacement or fix needed now!{RESET}"
|
f"{RED}{BOLD}⚠ ACTION REQUIRED: Replacement or fix needed now!{RESET}"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
0
python_pkg/brother_printer/tests/__init__.py
Normal file
0
python_pkg/brother_printer/tests/__init__.py
Normal file
211
python_pkg/brother_printer/tests/test_check_brother_printer.py
Normal file
211
python_pkg/brother_printer/tests/test_check_brother_printer.py
Normal 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
|
||||||
119
python_pkg/brother_printer/tests/test_constants.py
Normal file
119
python_pkg/brother_printer/tests/test_constants.py
Normal 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)
|
||||||
458
python_pkg/brother_printer/tests/test_cups_queue.py
Normal file
458
python_pkg/brother_printer/tests/test_cups_queue.py
Normal 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
|
||||||
278
python_pkg/brother_printer/tests/test_cups_queue_part2.py
Normal file
278
python_pkg/brother_printer/tests/test_cups_queue_part2.py
Normal 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")
|
||||||
76
python_pkg/brother_printer/tests/test_cups_queue_part3.py
Normal file
76
python_pkg/brother_printer/tests/test_cups_queue_part3.py
Normal 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()
|
||||||
454
python_pkg/brother_printer/tests/test_cups_service.py
Normal file
454
python_pkg/brother_printer/tests/test_cups_service.py
Normal 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
|
||||||
285
python_pkg/brother_printer/tests/test_cups_service_part2.py
Normal file
285
python_pkg/brother_printer/tests/test_cups_service_part2.py
Normal 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"] == ""
|
||||||
308
python_pkg/brother_printer/tests/test_cups_service_part3.py
Normal file
308
python_pkg/brother_printer/tests/test_cups_service_part3.py
Normal 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"
|
||||||
86
python_pkg/brother_printer/tests/test_cups_service_part4.py
Normal file
86
python_pkg/brother_printer/tests/test_cups_service_part4.py
Normal 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") == {}
|
||||||
93
python_pkg/brother_printer/tests/test_data_classes.py
Normal file
93
python_pkg/brother_printer/tests/test_data_classes.py
Normal 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
|
||||||
446
python_pkg/brother_printer/tests/test_display.py
Normal file
446
python_pkg/brother_printer/tests/test_display.py
Normal 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
|
||||||
90
python_pkg/brother_printer/tests/test_display_part2.py
Normal file
90
python_pkg/brother_printer/tests/test_display_part2.py
Normal 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)
|
||||||
29
python_pkg/brother_printer/tests/test_main_entry.py
Normal file
29
python_pkg/brother_printer/tests/test_main_entry.py
Normal 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()
|
||||||
189
python_pkg/brother_printer/tests/test_network_query.py
Normal file
189
python_pkg/brother_printer/tests/test_network_query.py
Normal 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"
|
||||||
498
python_pkg/brother_printer/tests/test_usb_query.py
Normal file
498
python_pkg/brother_printer/tests/test_usb_query.py
Normal 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"
|
||||||
@ -107,7 +107,7 @@ def _format_single_schedule(
|
|||||||
f"{screening.end_str()} {screening.movie}\n"
|
f"{screening.end_str()} {screening.movie}\n"
|
||||||
)
|
)
|
||||||
output.write(
|
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):
|
if i < len(schedule):
|
||||||
gap = schedule[i].start - screening.end
|
gap = schedule[i].start - screening.end
|
||||||
@ -143,9 +143,7 @@ def _format_schedules(
|
|||||||
output.write(f" OPTIMAL CINEMA SCHEDULES - {date}\n")
|
output.write(f" OPTIMAL CINEMA SCHEDULES - {date}\n")
|
||||||
else:
|
else:
|
||||||
output.write(" OPTIMAL CINEMA SCHEDULES\n")
|
output.write(" OPTIMAL CINEMA SCHEDULES\n")
|
||||||
output.write(
|
output.write(f" {num_movies} movies, {num_schedules} possible combination(s)\n")
|
||||||
f" {num_movies} movies, " f"{num_schedules} possible combination(s)\n"
|
|
||||||
)
|
|
||||||
output.write(f"{sep}\n\n")
|
output.write(f"{sep}\n\n")
|
||||||
|
|
||||||
display_count = min(num_schedules, max_display)
|
display_count = min(num_schedules, max_display)
|
||||||
@ -158,9 +156,7 @@ def _format_schedules(
|
|||||||
|
|
||||||
if num_schedules > display_count:
|
if num_schedules > display_count:
|
||||||
output.write(f"{thin_sep}\n")
|
output.write(f"{thin_sep}\n")
|
||||||
output.write(
|
output.write(f" ... and {num_schedules - display_count} more combinations\n")
|
||||||
f" ... and {num_schedules - display_count} " "more combinations\n"
|
|
||||||
)
|
|
||||||
output.write(" (use -n to show more, e.g., -n 10)\n")
|
output.write(" (use -n to show more, e.g., -n 10)\n")
|
||||||
output.write("\n")
|
output.write("\n")
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,7 @@ DEFAULT_EXCLUDED_GENRES = {"horror"}
|
|||||||
def _build_parser() -> argparse.ArgumentParser:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
"""Build the argument parser for the cinema planner."""
|
"""Build the argument parser for the cinema planner."""
|
||||||
parser = argparse.ArgumentParser(
|
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,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog="""
|
epilog="""
|
||||||
Supports Cinema City HTML/PDF schedules (auto-detected).
|
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"Movies considered: {len(all_movie_names)}\n")
|
||||||
f.write(f"Buffer time: {args.buffer} minutes\n")
|
f.write(f"Buffer time: {args.buffer} minutes\n")
|
||||||
if excluded_genres:
|
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)
|
f.write(schedule_output)
|
||||||
logger.info("Schedule saved to: %s", output_file)
|
logger.info("Schedule saved to: %s", output_file)
|
||||||
|
|
||||||
|
|||||||
0
python_pkg/cinema_planner/tests/__init__.py
Normal file
0
python_pkg/cinema_planner/tests/__init__.py
Normal file
480
python_pkg/cinema_planner/tests/test_cinema_parsing.py
Normal file
480
python_pkg/cinema_planner/tests/test_cinema_parsing.py
Normal 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
Loading…
Reference in New Issue
Block a user