mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 13:23:01 +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
4cf523bf6d
commit
996617d4a0
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_forests = list(forests.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_forests)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_forests)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_forests:
|
||||
forest_name = row["name"]
|
||||
|
||||
@ -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
|
||||
color_map = _build_color_map(gminy["name"].tolist())
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_gminy)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_gminy)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_gminy:
|
||||
gmina_name = row["name"]
|
||||
|
||||
@ -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_islands = list(islands.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_islands)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_islands)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_islands:
|
||||
island_name = row["name"]
|
||||
|
||||
@ -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_lakes = list(lakes.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_lakes)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_lakes)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_lakes:
|
||||
lake_name = row["name"]
|
||||
|
||||
@ -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_parks = list(parks.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_parks)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_parks)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_parks:
|
||||
park_name = row["name"]
|
||||
|
||||
@ -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("Data source: Wikipedia\n")
|
||||
sys.stdout.write(
|
||||
"URL: https://en.wikipedia.org/wiki/"
|
||||
"Vehicle_registration_plates_of_Poland\n"
|
||||
"URL: https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland\n"
|
||||
)
|
||||
sys.stdout.write(f"Cache location: {get_cache_path()}\n")
|
||||
sys.stdout.write(f"Cache expiry: {CACHE_EXPIRY_DAYS} days\n")
|
||||
|
||||
@ -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 pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@ -226,6 +227,16 @@ class TestMain:
|
||||
main(["--help"])
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
def test_main_error_returns_1(self, tmp_path: Path) -> None:
|
||||
"""Test that main returns 1 on error."""
|
||||
with patch(
|
||||
"python_pkg.anki_decks.polish_license_plates"
|
||||
".polish_license_plates_anki.generate_anki_package",
|
||||
side_effect=OSError("disk full"),
|
||||
):
|
||||
result = main(["--output", str(tmp_path / "out.apkg")])
|
||||
assert result == 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@ -345,8 +345,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_peaks = list(peaks.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_peaks)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_peaks)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_peaks:
|
||||
peak_name = row["name"]
|
||||
|
||||
@ -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_ranges = list(ranges.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_ranges)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_ranges)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_ranges:
|
||||
range_name = row["name"]
|
||||
|
||||
@ -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_parks = list(parks.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_parks)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_parks)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_parks:
|
||||
park_name = row["name"]
|
||||
|
||||
@ -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_powiaty = list(powiaty.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_powiaty)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_powiaty)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_powiaty:
|
||||
powiat_name = row["nazwa"]
|
||||
|
||||
@ -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_rivers = list(rivers.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_rivers)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_rivers)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_rivers:
|
||||
river_name = row["name"]
|
||||
|
||||
@ -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_sites = list(sites.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_sites)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_sites)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_sites:
|
||||
site_name = row["name"]
|
||||
|
||||
@ -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_bridges = list(bridges.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_bridges)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_bridges)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_bridges:
|
||||
bridge_name = row["name"]
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import pytest
|
||||
@ -13,6 +14,7 @@ try:
|
||||
create_district_map,
|
||||
generate_anki_package,
|
||||
generate_district_image_bytes,
|
||||
load_district_data,
|
||||
main,
|
||||
)
|
||||
except ImportError:
|
||||
@ -24,6 +26,7 @@ except ImportError:
|
||||
create_district_map,
|
||||
generate_anki_package,
|
||||
generate_district_image_bytes,
|
||||
load_district_data,
|
||||
main,
|
||||
)
|
||||
|
||||
@ -170,6 +173,41 @@ class TestMain:
|
||||
main(["--help"])
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
def test_main_error_returns_1(self, tmp_path: Path) -> None:
|
||||
"""Test that main returns 1 on error."""
|
||||
with patch(
|
||||
"python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki"
|
||||
".generate_anki_package",
|
||||
side_effect=OSError("disk full"),
|
||||
):
|
||||
result = main(["--output", str(tmp_path / "out.apkg")])
|
||||
assert result == 1
|
||||
|
||||
|
||||
class TestLoadDistrictData:
|
||||
"""Tests for load_district_data."""
|
||||
|
||||
def test_missing_geojson_raises_file_not_found(self, tmp_path: Path) -> None:
|
||||
"""Test FileNotFoundError when GeoJSON file is missing."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki"
|
||||
".GEOJSON_PATH",
|
||||
tmp_path / "nonexistent.geojson",
|
||||
),
|
||||
pytest.raises(FileNotFoundError, match="GeoJSON file not found"),
|
||||
):
|
||||
load_district_data()
|
||||
|
||||
|
||||
class TestCreateDistrictMapErrors:
|
||||
"""Tests for create_district_map error paths."""
|
||||
|
||||
def test_unknown_district_raises_value_error(self) -> None:
|
||||
"""Test ValueError when district name is not found."""
|
||||
with pytest.raises(ValueError, match="not found in data"):
|
||||
create_district_map("NonexistentDistrict123")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@ -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_osiedla = list(osiedla.iterrows())[: args.preview_count]
|
||||
sys.stdout.write(
|
||||
f"Exporting {len(preview_osiedla)} preview images "
|
||||
f"to {preview_dir}...\n"
|
||||
f"Exporting {len(preview_osiedla)} preview images to {preview_dir}...\n"
|
||||
)
|
||||
for _, row in preview_osiedla:
|
||||
osiedle_name = row["name"]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
def load_street_data() -> (
|
||||
tuple[list[tuple[str, gpd.GeoDataFrame, float]], gpd.GeoDataFrame]
|
||||
):
|
||||
def load_street_data() -> tuple[
|
||||
list[tuple[str, gpd.GeoDataFrame, float]], gpd.GeoDataFrame
|
||||
]:
|
||||
"""Load Warsaw streets and boundary.
|
||||
|
||||
Returns:
|
||||
|
||||
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:
|
||||
"""Test full CRUD lifecycle for articles API."""
|
||||
# Build C server
|
||||
here = Path(__file__).resolve().parent
|
||||
here = Path(__file__).resolve().parent.parent
|
||||
subprocess.run(["make", "-s", "server_c"], check=True, cwd=str(here))
|
||||
|
||||
# Find a free port
|
||||
@ -100,6 +100,7 @@ def test_crud_roundtrip(tmp_path: Path) -> None:
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
_req(base + f"/api/articles/{art_id}")
|
||||
assert exc_info.value.code == HTTPStatus.NOT_FOUND
|
||||
exc_info.value.close()
|
||||
|
||||
finally:
|
||||
srv.terminate()
|
||||
@ -5,7 +5,7 @@ from pathlib import Path
|
||||
# Budget for the entire website (single file) in bytes
|
||||
BUDGET = 14 * 1024 # 14 KiB
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
HERE = Path(__file__).parent.parent
|
||||
SITE_FILE = HERE / "index.html"
|
||||
|
||||
|
||||
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"
|
||||
@ -107,7 +107,7 @@ def _format_single_schedule(
|
||||
f"{screening.end_str()} {screening.movie}\n"
|
||||
)
|
||||
output.write(
|
||||
f" Duration: {hours}h {mins}m " f"(movie starts ~{actual_start_str})\n"
|
||||
f" Duration: {hours}h {mins}m (movie starts ~{actual_start_str})\n"
|
||||
)
|
||||
if i < len(schedule):
|
||||
gap = schedule[i].start - screening.end
|
||||
@ -143,9 +143,7 @@ def _format_schedules(
|
||||
output.write(f" OPTIMAL CINEMA SCHEDULES - {date}\n")
|
||||
else:
|
||||
output.write(" OPTIMAL CINEMA SCHEDULES\n")
|
||||
output.write(
|
||||
f" {num_movies} movies, " f"{num_schedules} possible combination(s)\n"
|
||||
)
|
||||
output.write(f" {num_movies} movies, {num_schedules} possible combination(s)\n")
|
||||
output.write(f"{sep}\n\n")
|
||||
|
||||
display_count = min(num_schedules, max_display)
|
||||
@ -158,9 +156,7 @@ def _format_schedules(
|
||||
|
||||
if num_schedules > display_count:
|
||||
output.write(f"{thin_sep}\n")
|
||||
output.write(
|
||||
f" ... and {num_schedules - display_count} " "more combinations\n"
|
||||
)
|
||||
output.write(f" ... and {num_schedules - display_count} more combinations\n")
|
||||
output.write(" (use -n to show more, e.g., -n 10)\n")
|
||||
output.write("\n")
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ DEFAULT_EXCLUDED_GENRES = {"horror"}
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the argument parser for the cinema planner."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=("Plan your cinema day to watch " "as many movies as possible."),
|
||||
description=("Plan your cinema day to watch as many movies as possible."),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Supports Cinema City HTML/PDF schedules (auto-detected).
|
||||
@ -270,7 +270,7 @@ def _output_schedules(
|
||||
f.write(f"Movies considered: {len(all_movie_names)}\n")
|
||||
f.write(f"Buffer time: {args.buffer} minutes\n")
|
||||
if excluded_genres:
|
||||
f.write("Excluded genres: " f"{', '.join(sorted(excluded_genres))}\n")
|
||||
f.write(f"Excluded genres: {', '.join(sorted(excluded_genres))}\n")
|
||||
f.write(schedule_output)
|
||||
logger.info("Schedule saved to: %s", output_file)
|
||||
|
||||
|
||||
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"
|
||||
462
python_pkg/cinema_planner/tests/test_cinema_planner.py
Normal file
462
python_pkg/cinema_planner/tests/test_cinema_planner.py
Normal file
@ -0,0 +1,462 @@
|
||||
"""Tests for cinema_planner main module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.cinema_planner._cinema_parsing import Movie
|
||||
from python_pkg.cinema_planner._cinema_scheduling import Screening
|
||||
from python_pkg.cinema_planner.cinema_planner import (
|
||||
_apply_must_watch_filter,
|
||||
_build_parser,
|
||||
_filter_movies,
|
||||
_load_movies_from_file,
|
||||
_load_movies_from_stdin,
|
||||
_load_movies_interactive,
|
||||
_output_schedules,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildParser:
|
||||
"""Tests for _build_parser."""
|
||||
|
||||
def test_parser_created(self) -> None:
|
||||
parser = _build_parser()
|
||||
assert isinstance(parser, argparse.ArgumentParser)
|
||||
|
||||
def test_parser_defaults(self) -> None:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args([])
|
||||
assert args.buffer == 0
|
||||
assert args.interactive is False
|
||||
assert args.list is False
|
||||
assert args.max_schedules == 5
|
||||
assert args.input_file is None
|
||||
assert args.select is None
|
||||
assert args.exclude is None
|
||||
assert args.exclude_genre is None
|
||||
assert args.all_genres is False
|
||||
assert args.output is None
|
||||
assert args.must_watch is None
|
||||
|
||||
def test_parser_with_file(self) -> None:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["test.html"])
|
||||
assert args.input_file == "test.html"
|
||||
|
||||
def test_parser_interactive(self) -> None:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["-i"])
|
||||
assert args.interactive is True
|
||||
|
||||
def test_parser_all_options(self) -> None:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(
|
||||
[
|
||||
"test.html",
|
||||
"-b",
|
||||
"10",
|
||||
"-l",
|
||||
"-s",
|
||||
"Movie",
|
||||
"-x",
|
||||
"Bad",
|
||||
"-g",
|
||||
"Horror",
|
||||
"--all-genres",
|
||||
"-o",
|
||||
"out.txt",
|
||||
"-n",
|
||||
"3",
|
||||
"-m",
|
||||
"Must",
|
||||
]
|
||||
)
|
||||
assert args.buffer == 10
|
||||
assert args.list is True
|
||||
assert args.select == "Movie"
|
||||
assert args.exclude == "Bad"
|
||||
assert args.exclude_genre == "Horror"
|
||||
assert args.all_genres is True
|
||||
assert args.output == "out.txt"
|
||||
assert args.max_schedules == 3
|
||||
assert args.must_watch == "Must"
|
||||
|
||||
|
||||
class TestLoadMoviesInteractive:
|
||||
"""Tests for _load_movies_interactive."""
|
||||
|
||||
@patch("builtins.input", side_effect=["Movie A, 10:00, 90min", ""])
|
||||
def test_single_movie(self, _mock: MagicMock) -> None:
|
||||
result = _load_movies_interactive()
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "Movie A"
|
||||
|
||||
@patch(
|
||||
"builtins.input",
|
||||
side_effect=[
|
||||
"Movie A, 10:00, 90min",
|
||||
"Movie B, 14:00, 120min",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_multiple_movies(self, _mock: MagicMock) -> None:
|
||||
result = _load_movies_interactive()
|
||||
assert len(result) == 2
|
||||
|
||||
@patch("builtins.input", side_effect=EOFError)
|
||||
def test_eof(self, _mock: MagicMock) -> None:
|
||||
result = _load_movies_interactive()
|
||||
assert result == []
|
||||
|
||||
@patch("builtins.input", side_effect=["bad line", ""])
|
||||
def test_invalid_input(self, _mock: MagicMock) -> None:
|
||||
result = _load_movies_interactive()
|
||||
assert result == []
|
||||
|
||||
@patch(
|
||||
"builtins.input",
|
||||
side_effect=["bad line", "Movie A, 10:00, 90min", ""],
|
||||
)
|
||||
def test_mixed_valid_invalid(self, _mock: MagicMock) -> None:
|
||||
result = _load_movies_interactive()
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestLoadMoviesFromFile:
|
||||
"""Tests for _load_movies_from_file."""
|
||||
|
||||
@patch(
|
||||
"python_pkg.cinema_planner.cinema_planner.parse_cinema_city_html",
|
||||
)
|
||||
def test_html_file(self, mock_parse: MagicMock) -> None:
|
||||
mock_parse.return_value = ([Movie("A", [600], 120)], "2025-01-25")
|
||||
movies, date = _load_movies_from_file(Path("test.html"))
|
||||
assert len(movies) == 1
|
||||
assert date == "2025-01-25"
|
||||
|
||||
@patch(
|
||||
"python_pkg.cinema_planner.cinema_planner.parse_cinema_city_html",
|
||||
)
|
||||
def test_htm_file(self, mock_parse: MagicMock) -> None:
|
||||
mock_parse.return_value = ([Movie("A", [600], 120)], None)
|
||||
movies, date = _load_movies_from_file(Path("test.htm"))
|
||||
mock_parse.assert_called_once()
|
||||
|
||||
@patch(
|
||||
"python_pkg.cinema_planner.cinema_planner.parse_cinema_city_pdf",
|
||||
)
|
||||
def test_pdf_file(self, mock_parse: MagicMock) -> None:
|
||||
mock_parse.return_value = [Movie("A", [600], 120)]
|
||||
movies, date = _load_movies_from_file(Path("test.pdf"))
|
||||
assert len(movies) == 1
|
||||
assert date is None
|
||||
|
||||
def test_text_file(self) -> None:
|
||||
content = "Movie A, 10:00, 90min\n# comment\nMovie B, 14:00, 120min\n"
|
||||
with patch.object(Path, "open", mock_open(read_data=content)):
|
||||
with patch.object(Path, "suffix", new=".txt"):
|
||||
movies, date = _load_movies_from_file(Path("test.txt"))
|
||||
assert len(movies) == 2
|
||||
assert date is None
|
||||
|
||||
def test_text_file_with_bad_line(self) -> None:
|
||||
content = "Movie A, 10:00, 90min\nbad line\n"
|
||||
with patch.object(Path, "open", mock_open(read_data=content)):
|
||||
with patch.object(Path, "suffix", new=".txt"):
|
||||
movies, date = _load_movies_from_file(Path("test.txt"))
|
||||
assert len(movies) == 1
|
||||
|
||||
|
||||
class TestLoadMoviesFromStdin:
|
||||
"""Tests for _load_movies_from_stdin."""
|
||||
|
||||
def test_basic(self) -> None:
|
||||
with patch("sys.stdin", StringIO("Movie A, 10:00, 90min\n")):
|
||||
result = _load_movies_from_stdin()
|
||||
assert len(result) == 1
|
||||
|
||||
def test_invalid_line(self) -> None:
|
||||
with patch("sys.stdin", StringIO("bad line\n")):
|
||||
result = _load_movies_from_stdin()
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestFilterMovies:
|
||||
"""Tests for _filter_movies."""
|
||||
|
||||
def _make_args(self, **kwargs: Any) -> argparse.Namespace:
|
||||
defaults = {
|
||||
"select": None,
|
||||
"exclude": None,
|
||||
"exclude_genre": None,
|
||||
"all_genres": False,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
def test_no_filters(self) -> None:
|
||||
movies = [Movie("A", [600], 120)]
|
||||
result, excluded = _filter_movies(movies, self._make_args())
|
||||
# Default horror exclusion but no genre matches
|
||||
assert len(result) == 1
|
||||
|
||||
def test_select_filter(self) -> None:
|
||||
movies = [
|
||||
Movie("Inception", [600], 120),
|
||||
Movie("Matrix", [600], 120),
|
||||
]
|
||||
result, _ = _filter_movies(
|
||||
movies,
|
||||
self._make_args(select="inception"),
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "Inception"
|
||||
|
||||
def test_exclude_filter(self) -> None:
|
||||
movies = [
|
||||
Movie("Inception", [600], 120),
|
||||
Movie("Matrix", [600], 120),
|
||||
]
|
||||
result, _ = _filter_movies(
|
||||
movies,
|
||||
self._make_args(exclude="matrix"),
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "Inception"
|
||||
|
||||
def test_genre_exclusion_default(self) -> None:
|
||||
movies = [
|
||||
Movie("Horror Movie", [600], 120, ["Horror"]),
|
||||
Movie("Comedy Movie", [600], 120, ["Comedy"]),
|
||||
]
|
||||
result, excluded = _filter_movies(movies, self._make_args())
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "Comedy Movie"
|
||||
assert "horror" in excluded
|
||||
|
||||
def test_all_genres_flag(self) -> None:
|
||||
movies = [
|
||||
Movie("Horror Movie", [600], 120, ["Horror"]),
|
||||
Movie("Comedy Movie", [600], 120, ["Comedy"]),
|
||||
]
|
||||
result, excluded = _filter_movies(
|
||||
movies,
|
||||
self._make_args(all_genres=True),
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert len(excluded) == 0
|
||||
|
||||
def test_custom_genre_exclusion(self) -> None:
|
||||
movies = [
|
||||
Movie("Action Movie", [600], 120, ["Action"]),
|
||||
Movie("Drama Movie", [600], 120, ["Drama"]),
|
||||
]
|
||||
result, excluded = _filter_movies(
|
||||
movies,
|
||||
self._make_args(all_genres=True, exclude_genre="action"),
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "Drama Movie"
|
||||
|
||||
def test_no_genre_filtered(self) -> None:
|
||||
movies = [Movie("Movie", [600], 120, ["Comedy"])]
|
||||
result, excluded = _filter_movies(movies, self._make_args())
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestApplyMustWatchFilter:
|
||||
"""Tests for _apply_must_watch_filter."""
|
||||
|
||||
def test_found(self) -> None:
|
||||
schedules = [
|
||||
[Screening("Movie A", 600, 720)],
|
||||
[Screening("Movie B", 600, 720)],
|
||||
]
|
||||
result = _apply_must_watch_filter(schedules, "Movie A")
|
||||
assert len(result) == 1
|
||||
assert result[0][0].movie == "Movie A"
|
||||
|
||||
def test_not_found(self) -> None:
|
||||
schedules = [
|
||||
[Screening("Movie A", 600, 720)],
|
||||
[Screening("Movie B", 600, 720)],
|
||||
]
|
||||
result = _apply_must_watch_filter(schedules, "Movie C")
|
||||
assert len(result) == 2 # Returns original
|
||||
|
||||
def test_partial_match(self) -> None:
|
||||
schedules = [[Screening("The Matrix Reloaded", 600, 720)]]
|
||||
result = _apply_must_watch_filter(schedules, "matrix")
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestOutputSchedules:
|
||||
"""Tests for _output_schedules."""
|
||||
|
||||
def _make_args(self, **kwargs: Any) -> argparse.Namespace:
|
||||
defaults = {
|
||||
"buffer": 0,
|
||||
"max_schedules": 5,
|
||||
"output": None,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
@patch("sys.stdout", new_callable=StringIO)
|
||||
def test_basic_output(self, mock_stdout: MagicMock) -> None:
|
||||
schedules = [[Screening("A", 600, 720)]]
|
||||
_output_schedules(
|
||||
schedules,
|
||||
["A"],
|
||||
None,
|
||||
self._make_args(),
|
||||
set(),
|
||||
)
|
||||
assert "OPTIMAL" in mock_stdout.getvalue()
|
||||
|
||||
@patch("sys.stdout", new_callable=StringIO)
|
||||
@patch("builtins.open", mock_open())
|
||||
def test_output_to_file(self, mock_stdout: MagicMock) -> None:
|
||||
schedules = [[Screening("A", 600, 720)]]
|
||||
_output_schedules(
|
||||
schedules,
|
||||
["A"],
|
||||
None,
|
||||
self._make_args(output="out.txt"),
|
||||
set(),
|
||||
)
|
||||
|
||||
@patch("sys.stdout", new_callable=StringIO)
|
||||
@patch("builtins.open", mock_open())
|
||||
def test_output_with_date(self, mock_stdout: MagicMock) -> None:
|
||||
schedules = [[Screening("A", 600, 720)]]
|
||||
_output_schedules(
|
||||
schedules,
|
||||
["A"],
|
||||
"2025-01-25",
|
||||
self._make_args(),
|
||||
set(),
|
||||
)
|
||||
|
||||
@patch("sys.stdout", new_callable=StringIO)
|
||||
@patch("builtins.open", mock_open())
|
||||
def test_output_with_excluded_genres(self, mock_stdout: MagicMock) -> None:
|
||||
schedules = [[Screening("A", 600, 720)]]
|
||||
_output_schedules(
|
||||
schedules,
|
||||
["A"],
|
||||
"2025-01-25",
|
||||
self._make_args(),
|
||||
{"horror"},
|
||||
)
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for main function."""
|
||||
|
||||
@patch("sys.argv", ["cinema_planner", "-i"])
|
||||
@patch(
|
||||
"python_pkg.cinema_planner.cinema_planner._load_movies_interactive",
|
||||
)
|
||||
@patch("sys.stdout", new_callable=StringIO)
|
||||
def test_interactive_mode(
|
||||
self,
|
||||
mock_stdout: MagicMock,
|
||||
mock_load: MagicMock,
|
||||
) -> None:
|
||||
mock_load.return_value = [Movie("A", [600], 120)]
|
||||
main()
|
||||
|
||||
@patch("sys.argv", ["cinema_planner", "test.html"])
|
||||
@patch(
|
||||
"python_pkg.cinema_planner.cinema_planner._load_movies_from_file",
|
||||
)
|
||||
@patch("sys.stdout", new_callable=StringIO)
|
||||
def test_file_mode(
|
||||
self,
|
||||
mock_stdout: MagicMock,
|
||||
mock_load: MagicMock,
|
||||
) -> None:
|
||||
mock_load.return_value = ([Movie("A", [600], 120)], "2025-01-25")
|
||||
with patch("builtins.open", mock_open()):
|
||||
main()
|
||||
|
||||
@patch("sys.argv", ["cinema_planner"])
|
||||
@patch(
|
||||
"python_pkg.cinema_planner.cinema_planner._load_movies_from_stdin",
|
||||
)
|
||||
@patch("sys.stdout", new_callable=StringIO)
|
||||
def test_stdin_mode(
|
||||
self,
|
||||
mock_stdout: MagicMock,
|
||||
mock_load: MagicMock,
|
||||
) -> None:
|
||||
mock_load.return_value = [Movie("A", [600], 120)]
|
||||
main()
|
||||
|
||||
@patch("sys.argv", ["cinema_planner", "-i"])
|
||||
@patch(
|
||||
"python_pkg.cinema_planner.cinema_planner._load_movies_interactive",
|
||||
)
|
||||
def test_no_movies_exits(self, mock_load: MagicMock) -> None:
|
||||
mock_load.return_value = []
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
@patch("sys.argv", ["cinema_planner", "-i", "-l"])
|
||||
@patch(
|
||||
"python_pkg.cinema_planner.cinema_planner._load_movies_interactive",
|
||||
)
|
||||
@patch("sys.stdout", new_callable=StringIO)
|
||||
def test_list_mode(
|
||||
self,
|
||||
mock_stdout: MagicMock,
|
||||
mock_load: MagicMock,
|
||||
) -> None:
|
||||
mock_load.return_value = [Movie("A", [600], 120)]
|
||||
main()
|
||||
assert "Parsed" in mock_stdout.getvalue()
|
||||
|
||||
@patch("sys.argv", ["cinema_planner", "-i", "-m", "Movie A"])
|
||||
@patch(
|
||||
"python_pkg.cinema_planner.cinema_planner._load_movies_interactive",
|
||||
)
|
||||
@patch("sys.stdout", new_callable=StringIO)
|
||||
def test_must_watch(
|
||||
self,
|
||||
mock_stdout: MagicMock,
|
||||
mock_load: MagicMock,
|
||||
) -> None:
|
||||
mock_load.return_value = [
|
||||
Movie("Movie A", [600], 120),
|
||||
Movie("Movie B", [900], 120),
|
||||
]
|
||||
main()
|
||||
|
||||
@patch(
|
||||
"sys.argv",
|
||||
["cinema_planner", "-i", "-s", "Movie", "-x", "Bad", "-g", "Horror"],
|
||||
)
|
||||
@patch(
|
||||
"python_pkg.cinema_planner.cinema_planner._load_movies_interactive",
|
||||
)
|
||||
@patch("sys.stdout", new_callable=StringIO)
|
||||
def test_filters(
|
||||
self,
|
||||
mock_stdout: MagicMock,
|
||||
mock_load: MagicMock,
|
||||
) -> None:
|
||||
mock_load.return_value = [
|
||||
Movie("Movie Good", [600], 120),
|
||||
Movie("Bad Movie", [600], 120),
|
||||
Movie("Other", [600], 120),
|
||||
]
|
||||
main()
|
||||
338
python_pkg/cinema_planner/tests/test_cinema_scheduling.py
Normal file
338
python_pkg/cinema_planner/tests/test_cinema_scheduling.py
Normal file
@ -0,0 +1,338 @@
|
||||
"""Tests for _cinema_scheduling module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
|
||||
from python_pkg.cinema_planner._cinema_parsing import Movie
|
||||
from python_pkg.cinema_planner._cinema_scheduling import (
|
||||
Screening,
|
||||
_format_all_movies,
|
||||
_format_schedules,
|
||||
_format_single_schedule,
|
||||
find_best_schedule,
|
||||
)
|
||||
|
||||
|
||||
class TestScreening:
|
||||
"""Tests for Screening dataclass."""
|
||||
|
||||
def test_start_str(self) -> None:
|
||||
s = Screening("Movie", 600, 720)
|
||||
assert s.start_str() == "10:00"
|
||||
|
||||
def test_end_str(self) -> None:
|
||||
s = Screening("Movie", 600, 720)
|
||||
assert s.end_str() == "12:00"
|
||||
|
||||
def test_start_str_zero_padded(self) -> None:
|
||||
s = Screening("Movie", 65, 180)
|
||||
assert s.start_str() == "01:05"
|
||||
|
||||
def test_overlaps_true(self) -> None:
|
||||
s1 = Screening("A", 600, 720)
|
||||
s2 = Screening("B", 700, 820)
|
||||
assert s1.overlaps(s2)
|
||||
|
||||
def test_overlaps_false(self) -> None:
|
||||
s1 = Screening("A", 600, 720)
|
||||
s2 = Screening("B", 900, 1020)
|
||||
assert not s1.overlaps(s2)
|
||||
|
||||
def test_overlaps_with_buffer(self) -> None:
|
||||
s1 = Screening("A", 600, 720)
|
||||
s2 = Screening("B", 735, 855)
|
||||
assert not s1.overlaps(s2, buffer=0)
|
||||
# buffer=31 => 720+31=751 > 735+15=750 => overlap
|
||||
assert s1.overlaps(s2, buffer=31)
|
||||
|
||||
def test_overlaps_ads_grace(self) -> None:
|
||||
# ADS_DURATION is 15. end + buffer <= start + ADS
|
||||
# 720 + 0 <= 720 + 15 => True => no overlap
|
||||
s1 = Screening("A", 600, 720)
|
||||
s2 = Screening("B", 720, 840)
|
||||
assert not s1.overlaps(s2)
|
||||
|
||||
def test_overlaps_symmetric(self) -> None:
|
||||
s1 = Screening("A", 600, 720)
|
||||
s2 = Screening("B", 700, 820)
|
||||
assert s1.overlaps(s2)
|
||||
assert s2.overlaps(s1)
|
||||
|
||||
def test_no_overlap_reversed_order(self) -> None:
|
||||
s1 = Screening("A", 900, 1020)
|
||||
s2 = Screening("B", 600, 720)
|
||||
assert not s1.overlaps(s2)
|
||||
|
||||
|
||||
class TestFindBestSchedule:
|
||||
"""Tests for find_best_schedule."""
|
||||
|
||||
def test_single_movie(self) -> None:
|
||||
movies = [Movie("A", [600], 120)]
|
||||
result = find_best_schedule(movies, 0)
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 1
|
||||
assert result[0][0].movie == "A"
|
||||
|
||||
def test_two_non_overlapping(self) -> None:
|
||||
movies = [
|
||||
Movie("A", [600], 120),
|
||||
Movie("B", [900], 120),
|
||||
]
|
||||
result = find_best_schedule(movies, 0)
|
||||
assert len(result) >= 1
|
||||
assert len(result[0]) == 2
|
||||
|
||||
def test_two_overlapping(self) -> None:
|
||||
movies = [
|
||||
Movie("A", [600], 120),
|
||||
Movie("B", [610], 120),
|
||||
]
|
||||
result = find_best_schedule(movies, 0)
|
||||
# Best schedule has 1 movie (they overlap)
|
||||
assert len(result[0]) == 1
|
||||
|
||||
def test_multiple_screenings(self) -> None:
|
||||
movies = [
|
||||
Movie("A", [600, 900], 120),
|
||||
Movie("B", [750], 120),
|
||||
]
|
||||
result = find_best_schedule(movies, 0)
|
||||
# Should find schedule with both movies A@600 + B@750
|
||||
best = result[0]
|
||||
assert len(best) == 2
|
||||
|
||||
def test_buffer_time(self) -> None:
|
||||
movies = [
|
||||
Movie("A", [600], 120),
|
||||
Movie("B", [735], 120), # 15 min gap (exactly ADS_DURATION)
|
||||
]
|
||||
# With buffer=0, no overlap
|
||||
result_no_buffer = find_best_schedule(movies, 0)
|
||||
assert len(result_no_buffer[0]) == 2
|
||||
|
||||
# With large buffer, they do overlap
|
||||
result_buffer = find_best_schedule(movies, 31)
|
||||
assert len(result_buffer[0]) == 1
|
||||
|
||||
def test_empty_movies(self) -> None:
|
||||
result = find_best_schedule([], 0)
|
||||
# Empty schedule with 0 movies => best_count stays 0
|
||||
assert result == []
|
||||
|
||||
def test_multiple_best_schedules(self) -> None:
|
||||
movies = [
|
||||
Movie("A", [600], 60),
|
||||
Movie("B", [600], 60),
|
||||
]
|
||||
result = find_best_schedule(movies, 0)
|
||||
assert len(result) == 2 # A or B, both are equally good
|
||||
|
||||
def test_sorted_by_start_time(self) -> None:
|
||||
movies = [
|
||||
Movie("B", [900], 120),
|
||||
Movie("A", [600], 120),
|
||||
]
|
||||
result = find_best_schedule(movies, 0)
|
||||
assert result[0][0].movie == "A"
|
||||
assert result[0][1].movie == "B"
|
||||
|
||||
def test_pruning(self) -> None:
|
||||
# Create scenario where pruning is triggered
|
||||
movies = [
|
||||
Movie("A", [600], 60),
|
||||
Movie("B", [700], 60),
|
||||
Movie("C", [800], 60),
|
||||
Movie("D", [610], 60), # Overlaps with A
|
||||
]
|
||||
result = find_best_schedule(movies, 0)
|
||||
# Best has 3 movies (A, B, C)
|
||||
assert len(result[0]) == 3
|
||||
|
||||
|
||||
class TestFormatSingleSchedule:
|
||||
"""Tests for _format_single_schedule."""
|
||||
|
||||
def test_single_screening(self) -> None:
|
||||
output = StringIO()
|
||||
schedule = [Screening("Movie A", 600, 720)]
|
||||
_format_single_schedule(schedule, output)
|
||||
text = output.getvalue()
|
||||
assert "Movie A" in text
|
||||
assert "10:00" in text
|
||||
assert "12:00" in text
|
||||
|
||||
def test_multiple_screenings_with_gap(self) -> None:
|
||||
output = StringIO()
|
||||
schedule = [
|
||||
Screening("A", 600, 720),
|
||||
Screening("B", 780, 900),
|
||||
]
|
||||
_format_single_schedule(schedule, output)
|
||||
text = output.getvalue()
|
||||
assert "60 min break" in text
|
||||
|
||||
def test_no_gap(self) -> None:
|
||||
output = StringIO()
|
||||
schedule = [
|
||||
Screening("A", 600, 720),
|
||||
Screening("B", 720, 840),
|
||||
]
|
||||
_format_single_schedule(schedule, output)
|
||||
text = output.getvalue()
|
||||
assert "break" not in text
|
||||
|
||||
def test_duration_display(self) -> None:
|
||||
output = StringIO()
|
||||
schedule = [Screening("Movie A", 600, 706)]
|
||||
_format_single_schedule(schedule, output)
|
||||
text = output.getvalue()
|
||||
assert "1h 46m" in text
|
||||
|
||||
def test_actual_start_display(self) -> None:
|
||||
output = StringIO()
|
||||
schedule = [Screening("Movie A", 600, 720)]
|
||||
_format_single_schedule(schedule, output)
|
||||
text = output.getvalue()
|
||||
# actual start = 600 + 15 = 615 => 10:15
|
||||
assert "10:15" in text
|
||||
|
||||
|
||||
class TestFormatSchedules:
|
||||
"""Tests for _format_schedules."""
|
||||
|
||||
def test_empty_schedules(self) -> None:
|
||||
output = StringIO()
|
||||
_format_schedules([], ["A"], output=output)
|
||||
assert "No movies can be scheduled!" in output.getvalue()
|
||||
|
||||
def test_empty_first_schedule(self) -> None:
|
||||
output = StringIO()
|
||||
_format_schedules([[]], ["A"], output=output)
|
||||
assert "No movies can be scheduled!" in output.getvalue()
|
||||
|
||||
def test_single_schedule(self) -> None:
|
||||
output = StringIO()
|
||||
schedule = [[Screening("Movie A", 600, 720)]]
|
||||
_format_schedules(schedule, ["Movie A"], output=output)
|
||||
text = output.getvalue()
|
||||
assert "OPTIMAL CINEMA SCHEDULES" in text
|
||||
assert "1 movies" in text
|
||||
|
||||
def test_with_date(self) -> None:
|
||||
output = StringIO()
|
||||
schedule = [[Screening("Movie A", 600, 720)]]
|
||||
_format_schedules(schedule, ["Movie A"], "2025-01-25", output=output)
|
||||
text = output.getvalue()
|
||||
assert "2025-01-25" in text
|
||||
|
||||
def test_no_date(self) -> None:
|
||||
output = StringIO()
|
||||
schedule = [[Screening("Movie A", 600, 720)]]
|
||||
_format_schedules(schedule, ["Movie A"], output=output)
|
||||
text = output.getvalue()
|
||||
assert "OPTIMAL CINEMA SCHEDULES\n" in text
|
||||
|
||||
def test_multiple_schedules(self) -> None:
|
||||
output = StringIO()
|
||||
schedules = [
|
||||
[Screening("A", 600, 720)],
|
||||
[Screening("B", 600, 720)],
|
||||
]
|
||||
_format_schedules(schedules, ["A", "B"], output=output)
|
||||
text = output.getvalue()
|
||||
assert "OPTION 1" in text
|
||||
assert "OPTION 2" in text
|
||||
|
||||
def test_max_display_truncation(self) -> None:
|
||||
output = StringIO()
|
||||
schedules = [
|
||||
[Screening("A", 600, 720)],
|
||||
[Screening("B", 600, 720)],
|
||||
[Screening("C", 600, 720)],
|
||||
]
|
||||
_format_schedules(schedules, ["A", "B", "C"], max_display=2, output=output)
|
||||
text = output.getvalue()
|
||||
assert "1 more combinations" in text
|
||||
assert "use -n to show more" in text
|
||||
|
||||
def test_skipped_movies(self) -> None:
|
||||
output = StringIO()
|
||||
schedules = [[Screening("A", 600, 720)]]
|
||||
_format_schedules(schedules, ["A", "B", "C"], output=output)
|
||||
text = output.getvalue()
|
||||
assert "Skipped movies (2)" in text
|
||||
assert "- B" in text
|
||||
assert "- C" in text
|
||||
|
||||
def test_no_skipped_with_multiple_schedules(self) -> None:
|
||||
output = StringIO()
|
||||
schedules = [
|
||||
[Screening("A", 600, 720)],
|
||||
[Screening("B", 600, 720)],
|
||||
]
|
||||
_format_schedules(schedules, ["A", "B", "C"], output=output)
|
||||
text = output.getvalue()
|
||||
# Skipped only printed when num_schedules == 1
|
||||
assert "Skipped" not in text
|
||||
|
||||
def test_default_output_stdout(self) -> None:
|
||||
schedule = [[Screening("Movie A", 600, 720)]]
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch.object(sys, "stdout", new_callable=StringIO) as mock_stdout:
|
||||
_format_schedules(schedule, ["Movie A"])
|
||||
text = mock_stdout.getvalue()
|
||||
assert "OPTIMAL CINEMA SCHEDULES" in text
|
||||
|
||||
|
||||
class TestFormatAllMovies:
|
||||
"""Tests for _format_all_movies."""
|
||||
|
||||
def test_basic(self) -> None:
|
||||
output = StringIO()
|
||||
movies = [Movie("Movie A", [600, 840], 120)]
|
||||
_format_all_movies(movies, output=output)
|
||||
text = output.getvalue()
|
||||
assert "Movie A" in text
|
||||
assert "120 min" in text
|
||||
|
||||
def test_with_date(self) -> None:
|
||||
output = StringIO()
|
||||
movies = [Movie("Movie A", [600], 90)]
|
||||
_format_all_movies(movies, "2025-01-25", output=output)
|
||||
text = output.getvalue()
|
||||
assert "2025-01-25" in text
|
||||
|
||||
def test_no_date(self) -> None:
|
||||
output = StringIO()
|
||||
movies = [Movie("Movie A", [600], 90)]
|
||||
_format_all_movies(movies, output=output)
|
||||
text = output.getvalue()
|
||||
assert "Parsed 1 movies:" in text
|
||||
|
||||
def test_with_genres(self) -> None:
|
||||
output = StringIO()
|
||||
movies = [Movie("Movie A", [600], 90, ["Action", "Drama"])]
|
||||
_format_all_movies(movies, output=output)
|
||||
text = output.getvalue()
|
||||
assert "[Action, Drama]" in text
|
||||
|
||||
def test_without_genres(self) -> None:
|
||||
output = StringIO()
|
||||
movies = [Movie("Movie A", [600], 90)]
|
||||
_format_all_movies(movies, output=output)
|
||||
text = output.getvalue()
|
||||
assert "[" not in text.split("Movie A")[1].split("\n")[0]
|
||||
|
||||
def test_default_output_stdout(self) -> None:
|
||||
movies = [Movie("Movie A", [600], 90)]
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch.object(sys, "stdout", new_callable=StringIO) as mock_stdout:
|
||||
_format_all_movies(movies)
|
||||
text = mock_stdout.getvalue()
|
||||
assert "Movie A" in text
|
||||
@ -206,7 +206,9 @@ class TestRunAnalysisSubprocess:
|
||||
|
||||
with (
|
||||
patch("python_pkg.lichess_bot.main.Path") as mock_path,
|
||||
patch("subprocess.Popen", return_value=mock_proc),
|
||||
patch(
|
||||
"python_pkg.lichess_bot.main.subprocess.Popen", return_value=mock_proc
|
||||
),
|
||||
):
|
||||
mock_script = MagicMock()
|
||||
mock_script.is_file.return_value = True
|
||||
|
||||
0
python_pkg/moviepy_showcase/tests/__init__.py
Normal file
0
python_pkg/moviepy_showcase/tests/__init__.py
Normal file
123
python_pkg/moviepy_showcase/tests/conftest.py
Normal file
123
python_pkg/moviepy_showcase/tests/conftest.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""Mock moviepy modules for all moviepy_showcase tests.
|
||||
|
||||
This module-level setup installs mock moviepy packages into sys.modules
|
||||
so source modules can be imported without moviepy installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
_H, _W = 1080, 1920
|
||||
|
||||
|
||||
def create_mock_clip(**overrides: Any) -> MagicMock:
|
||||
"""Return a MagicMock that behaves enough like a moviepy clip."""
|
||||
clip = MagicMock()
|
||||
clip.duration = overrides.get("duration", 2.0)
|
||||
clip.size = overrides.get("size", (_W, _H))
|
||||
clip.fps = overrides.get("fps", 30)
|
||||
chain = [
|
||||
"with_fps",
|
||||
"with_duration",
|
||||
"with_position",
|
||||
"with_opacity",
|
||||
"with_mask",
|
||||
"with_audio",
|
||||
"with_effects",
|
||||
"with_background_color",
|
||||
"with_speed_scaled",
|
||||
"with_section_cut_out",
|
||||
"with_effects_on_subclip",
|
||||
"with_layer_index",
|
||||
"with_volume_scaled",
|
||||
"with_start",
|
||||
"subclipped",
|
||||
"cropped",
|
||||
"resized",
|
||||
"rotated",
|
||||
"image_transform",
|
||||
"transform",
|
||||
"time_transform",
|
||||
"to_ImageClip",
|
||||
"to_mask",
|
||||
"to_RGB",
|
||||
]
|
||||
for name in chain:
|
||||
getattr(clip, name).return_value = clip
|
||||
return clip
|
||||
|
||||
|
||||
# ── Build mock module tree ────────────────────────────────────────
|
||||
mock_moviepy = MagicMock()
|
||||
|
||||
_clip_classes = [
|
||||
"VideoClip",
|
||||
"ColorClip",
|
||||
"TextClip",
|
||||
"ImageClip",
|
||||
"CompositeVideoClip",
|
||||
"VideoFileClip",
|
||||
"BitmapClip",
|
||||
"DataVideoClip",
|
||||
"ImageSequenceClip",
|
||||
"AudioClip",
|
||||
"AudioArrayClip",
|
||||
"CompositeAudioClip",
|
||||
]
|
||||
for _cls in _clip_classes:
|
||||
getattr(mock_moviepy, _cls).side_effect = lambda *a, **kw: create_mock_clip()
|
||||
|
||||
mock_moviepy.concatenate_videoclips.side_effect = lambda *a, **kw: create_mock_clip()
|
||||
mock_moviepy.concatenate_audioclips.side_effect = lambda *a, **kw: create_mock_clip()
|
||||
mock_moviepy.video.compositing.CompositeVideoClip.clips_array.side_effect = (
|
||||
lambda *a, **kw: create_mock_clip()
|
||||
)
|
||||
|
||||
# Drawing tools must return real numpy arrays (used in numpy ops)
|
||||
mock_moviepy.video.tools.drawing.circle.return_value = np.zeros(
|
||||
(_H, _W), dtype=np.float64
|
||||
)
|
||||
mock_moviepy.video.tools.drawing.color_gradient.return_value = np.zeros(
|
||||
(_H, _W), dtype=np.float64
|
||||
)
|
||||
mock_moviepy.video.tools.drawing.color_split.return_value = np.zeros(
|
||||
(_H, _W), dtype=np.float64
|
||||
)
|
||||
|
||||
# ── Install into sys.modules ─────────────────────────────────────
|
||||
_module_paths = [
|
||||
"moviepy",
|
||||
"moviepy.video",
|
||||
"moviepy.video.fx",
|
||||
"moviepy.video.compositing",
|
||||
"moviepy.video.compositing.CompositeVideoClip",
|
||||
"moviepy.video.tools",
|
||||
"moviepy.video.tools.drawing",
|
||||
"moviepy.audio",
|
||||
"moviepy.audio.fx",
|
||||
]
|
||||
|
||||
|
||||
def _install_moviepy_mocks() -> None:
|
||||
"""(Re)install this conftest's moviepy mocks into sys.modules."""
|
||||
for _mod in _module_paths:
|
||||
parts = _mod.split(".")
|
||||
obj: Any = mock_moviepy
|
||||
for part in parts[1:]:
|
||||
obj = getattr(obj, part)
|
||||
sys.modules[_mod] = obj
|
||||
|
||||
|
||||
_install_moviepy_mocks()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reinstall_moviepy_mocks() -> None:
|
||||
"""Ensure our moviepy mocks are active even if another conftest overwrote."""
|
||||
_install_moviepy_mocks()
|
||||
75
python_pkg/moviepy_showcase/tests/test_audio_output.py
Normal file
75
python_pkg/moviepy_showcase/tests/test_audio_output.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Tests for python_pkg.moviepy_showcase._moviepy_audio_output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_pkg.moviepy_showcase._moviepy_audio_output import (
|
||||
_make_sine,
|
||||
part4_audio,
|
||||
part5_composition,
|
||||
part6_drawing_tools,
|
||||
part7_output,
|
||||
)
|
||||
|
||||
|
||||
# ── _make_sine inner maker branches ──────────────────────────────
|
||||
def test_make_sine_returns_clip() -> None:
|
||||
clip = _make_sine(440.0, 2.0)
|
||||
assert clip is not None
|
||||
|
||||
|
||||
def test_make_sine_maker_scalar() -> None:
|
||||
"""maker() with scalar t → t_arr.ndim == 0 → returns 1-D."""
|
||||
import moviepy as mp
|
||||
|
||||
mp.AudioClip.side_effect = lambda *a, **kw: MagicMock()
|
||||
_make_sine(440.0, 1.0)
|
||||
maker = mp.AudioClip.call_args[0][0]
|
||||
|
||||
result = maker(0.0)
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.ndim == 1
|
||||
assert result.shape == (2,)
|
||||
|
||||
|
||||
def test_make_sine_maker_array() -> None:
|
||||
"""maker() with array t → t_arr.ndim > 0 → returns 2-D."""
|
||||
import moviepy as mp
|
||||
|
||||
mp.AudioClip.side_effect = lambda *a, **kw: MagicMock()
|
||||
_make_sine(440.0, 1.0)
|
||||
maker = mp.AudioClip.call_args[0][0]
|
||||
|
||||
t = np.linspace(0, 1, 100)
|
||||
result = maker(t)
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.ndim == 2
|
||||
assert result.shape == (100, 2)
|
||||
|
||||
|
||||
# ── part functions ───────────────────────────────────────────────
|
||||
def test_part4_audio() -> None:
|
||||
result = part4_audio()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_part5_composition() -> None:
|
||||
result = part5_composition()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_part6_drawing_tools() -> None:
|
||||
result = part6_drawing_tools()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_part7_output() -> None:
|
||||
result = part7_output()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
83
python_pkg/moviepy_showcase/tests/test_clip_types.py
Normal file
83
python_pkg/moviepy_showcase/tests/test_clip_types.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""Tests for python_pkg.moviepy_showcase._moviepy_clip_types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_pkg.moviepy_showcase._moviepy_clip_types import (
|
||||
part1_clip_types,
|
||||
part2_clip_methods,
|
||||
)
|
||||
from python_pkg.moviepy_showcase.moviepy_showcase import H, W
|
||||
from python_pkg.moviepy_showcase.tests.conftest import create_mock_clip
|
||||
|
||||
|
||||
# ── part1_clip_types ─────────────────────────────────────────────
|
||||
def test_part1_clip_types_returns_scenes() -> None:
|
||||
result = part1_clip_types()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_part1_data_to_frame() -> None:
|
||||
"""Extract and test the inner data_to_frame function."""
|
||||
import moviepy as mp
|
||||
|
||||
mp.DataVideoClip.side_effect = lambda *a, **kw: create_mock_clip()
|
||||
result = part1_clip_types()
|
||||
assert len(result) > 0
|
||||
|
||||
# DataVideoClip is called with (data_list, data_to_frame, fps=FPS)
|
||||
for call in mp.DataVideoClip.call_args_list:
|
||||
if len(call[0]) >= 2 and callable(call[0][1]):
|
||||
data_to_frame = call[0][1]
|
||||
frame = data_to_frame(30)
|
||||
assert frame.shape == (H, W, 3)
|
||||
assert frame.dtype == np.uint8
|
||||
# Test with 0 (edge case: bar_w = 0)
|
||||
frame0 = data_to_frame(0)
|
||||
assert frame0.shape == (H, W, 3)
|
||||
break
|
||||
|
||||
|
||||
# ── part2_clip_methods ───────────────────────────────────────────
|
||||
def test_part2_clip_methods_returns_scenes() -> None:
|
||||
result = part2_clip_methods()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_part2_flip_lr() -> None:
|
||||
"""Extract and test the inner flip_lr function."""
|
||||
base_mock = create_mock_clip()
|
||||
with patch(
|
||||
"python_pkg.moviepy_showcase._moviepy_clip_types._base_clip",
|
||||
return_value=base_mock,
|
||||
):
|
||||
part2_clip_methods()
|
||||
|
||||
# flip_lr was passed to image_transform
|
||||
flip_lr = base_mock.image_transform.call_args[0][0]
|
||||
img = np.arange(24, dtype=np.uint8).reshape(2, 4, 3)
|
||||
flipped = flip_lr(img)
|
||||
np.testing.assert_array_equal(flipped, img[:, ::-1])
|
||||
|
||||
|
||||
def test_part2_shift_right() -> None:
|
||||
"""Extract and test the inner shift_right function."""
|
||||
base_mock = create_mock_clip()
|
||||
with patch(
|
||||
"python_pkg.moviepy_showcase._moviepy_clip_types._base_clip",
|
||||
return_value=base_mock,
|
||||
):
|
||||
part2_clip_methods()
|
||||
|
||||
# shift_right was passed to transform
|
||||
shift_right = base_mock.transform.call_args[0][0]
|
||||
dummy_frame = np.ones((4, 6, 3), dtype=np.uint8)
|
||||
gf = MagicMock(return_value=dummy_frame)
|
||||
result = shift_right(gf, 1.0)
|
||||
gf.assert_called_once_with(1.0)
|
||||
assert result.shape == dummy_frame.shape
|
||||
158
python_pkg/moviepy_showcase/tests/test_moviepy_showcase.py
Normal file
158
python_pkg/moviepy_showcase/tests/test_moviepy_showcase.py
Normal file
@ -0,0 +1,158 @@
|
||||
"""Tests for python_pkg.moviepy_showcase.moviepy_showcase."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_pkg.moviepy_showcase.moviepy_showcase import (
|
||||
H,
|
||||
W,
|
||||
_base_clip,
|
||||
_build,
|
||||
_checkerboard,
|
||||
_gradient,
|
||||
_label,
|
||||
_render_part,
|
||||
_resize_to_canvas,
|
||||
_section_header,
|
||||
_titled,
|
||||
main,
|
||||
)
|
||||
from python_pkg.moviepy_showcase.tests.conftest import create_mock_clip
|
||||
|
||||
|
||||
# ── _gradient ─────────────────────────────────────────────────────
|
||||
def test_gradient_at_zero() -> None:
|
||||
frame = _gradient(0.0)
|
||||
assert frame.shape == (H, W, 3)
|
||||
assert frame.dtype == np.uint8
|
||||
|
||||
|
||||
def test_gradient_nonzero() -> None:
|
||||
frame = _gradient(1.5)
|
||||
assert frame.shape == (H, W, 3)
|
||||
|
||||
|
||||
# ── _checkerboard ────────────────────────────────────────────────
|
||||
def test_checkerboard_at_zero() -> None:
|
||||
frame = _checkerboard(0.0)
|
||||
assert frame.shape == (H, W, 3)
|
||||
assert frame.dtype == np.uint8
|
||||
|
||||
|
||||
def test_checkerboard_nonzero() -> None:
|
||||
frame = _checkerboard(2.3)
|
||||
assert frame.shape == (H, W, 3)
|
||||
|
||||
|
||||
# ── _base_clip ───────────────────────────────────────────────────
|
||||
def test_base_clip_default() -> None:
|
||||
clip = _base_clip()
|
||||
assert clip is not None
|
||||
|
||||
|
||||
def test_base_clip_custom_duration() -> None:
|
||||
clip = _base_clip(5.0)
|
||||
assert clip is not None
|
||||
|
||||
|
||||
# ── _label ───────────────────────────────────────────────────────
|
||||
def test_label_defaults() -> None:
|
||||
lbl = _label("hello")
|
||||
assert lbl is not None
|
||||
|
||||
|
||||
def test_label_custom_params() -> None:
|
||||
lbl = _label("hello", size=48, color="red", pos=("left", "top"), dur=3.0)
|
||||
assert lbl is not None
|
||||
|
||||
|
||||
# ── _titled ──────────────────────────────────────────────────────
|
||||
def test_titled() -> None:
|
||||
clip = create_mock_clip()
|
||||
result = _titled(clip, "test title")
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ── _section_header ──────────────────────────────────────────────
|
||||
def test_section_header_with_subtitle() -> None:
|
||||
result = _section_header("Title", "Subtitle text")
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_section_header_without_subtitle() -> None:
|
||||
result = _section_header("Title")
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ── _resize_to_canvas ───────────────────────────────────────────
|
||||
def test_resize_to_canvas() -> None:
|
||||
clip = create_mock_clip(size=(960, 540))
|
||||
result = _resize_to_canvas(clip)
|
||||
assert result is not None
|
||||
clip.resized.assert_called_once()
|
||||
|
||||
|
||||
# ── _render_part ─────────────────────────────────────────────────
|
||||
def test_render_part() -> None:
|
||||
s1 = create_mock_clip()
|
||||
s2 = create_mock_clip()
|
||||
_render_part([s1, s2], "/tmp/test_part.mp4", "test")
|
||||
s1.close.assert_called_once()
|
||||
s2.close.assert_called_once()
|
||||
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────
|
||||
def test_main_success() -> None:
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.moviepy_showcase.moviepy_showcase.tempfile.mkdtemp",
|
||||
return_value="/tmp/mock_dir",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.moviepy_showcase.moviepy_showcase._build",
|
||||
) as mock_build,
|
||||
patch(
|
||||
"python_pkg.moviepy_showcase.moviepy_showcase.shutil.rmtree",
|
||||
) as mock_rmtree,
|
||||
):
|
||||
main()
|
||||
mock_build.assert_called_once_with("/tmp/mock_dir")
|
||||
mock_rmtree.assert_called_once_with("/tmp/mock_dir", ignore_errors=True)
|
||||
|
||||
|
||||
def test_main_build_raises() -> None:
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.moviepy_showcase.moviepy_showcase.tempfile.mkdtemp",
|
||||
return_value="/tmp/mock_dir",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.moviepy_showcase.moviepy_showcase._build",
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.moviepy_showcase.moviepy_showcase.shutil.rmtree",
|
||||
) as mock_rmtree,
|
||||
):
|
||||
with contextlib.suppress(RuntimeError):
|
||||
main()
|
||||
mock_rmtree.assert_called_once_with("/tmp/mock_dir", ignore_errors=True)
|
||||
|
||||
|
||||
# ── _build ───────────────────────────────────────────────────────
|
||||
def test_build() -> None:
|
||||
mock_stat: Any = MagicMock()
|
||||
mock_stat.st_size = 10 * 1024 * 1024
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.moviepy_showcase.moviepy_showcase._render_part",
|
||||
),
|
||||
patch.object(Path, "stat", return_value=mock_stat),
|
||||
):
|
||||
_build("/tmp/test_build")
|
||||
136
python_pkg/moviepy_showcase/tests/test_video_effects.py
Normal file
136
python_pkg/moviepy_showcase/tests/test_video_effects.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Tests for python_pkg.moviepy_showcase._moviepy_video_effects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from python_pkg.moviepy_showcase._moviepy_video_effects import (
|
||||
_fx,
|
||||
_part3_effects_1_to_17,
|
||||
_part3_effects_18_to_34,
|
||||
part3_video_effects,
|
||||
)
|
||||
from python_pkg.moviepy_showcase.moviepy_showcase import H, W
|
||||
from python_pkg.moviepy_showcase.tests.conftest import create_mock_clip
|
||||
|
||||
|
||||
# ── _fx branches ─────────────────────────────────────────────────
|
||||
def test_fx_normal_path() -> None:
|
||||
"""Effect succeeds, duration > 0, size matches canvas."""
|
||||
clip = create_mock_clip(duration=2.0, size=(W, H))
|
||||
with patch(
|
||||
"python_pkg.moviepy_showcase._moviepy_video_effects._base_clip",
|
||||
return_value=clip,
|
||||
):
|
||||
result = _fx(MagicMock(), "label")
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_fx_duration_none() -> None:
|
||||
"""After with_effects, duration is None → sets duration."""
|
||||
clip = create_mock_clip(size=(W, H))
|
||||
clip.duration = None
|
||||
clip.with_effects.return_value = clip
|
||||
clip.with_duration.return_value = create_mock_clip(size=(W, H))
|
||||
with patch(
|
||||
"python_pkg.moviepy_showcase._moviepy_video_effects._base_clip",
|
||||
return_value=clip,
|
||||
):
|
||||
result = _fx(MagicMock(), "label")
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_fx_duration_zero() -> None:
|
||||
"""After with_effects, duration <= 0 → sets duration."""
|
||||
clip = create_mock_clip(size=(W, H))
|
||||
clip.duration = 0
|
||||
clip.with_effects.return_value = clip
|
||||
clip.with_duration.return_value = create_mock_clip(size=(W, H))
|
||||
with patch(
|
||||
"python_pkg.moviepy_showcase._moviepy_video_effects._base_clip",
|
||||
return_value=clip,
|
||||
):
|
||||
result = _fx(MagicMock(), "label")
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_fx_duration_negative() -> None:
|
||||
"""After with_effects, duration < 0 → sets duration."""
|
||||
clip = create_mock_clip(size=(W, H))
|
||||
clip.duration = -1.0
|
||||
clip.with_effects.return_value = clip
|
||||
clip.with_duration.return_value = create_mock_clip(size=(W, H))
|
||||
with patch(
|
||||
"python_pkg.moviepy_showcase._moviepy_video_effects._base_clip",
|
||||
return_value=clip,
|
||||
):
|
||||
result = _fx(MagicMock(), "label")
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_fx_raises_valueerror() -> None:
|
||||
"""with_effects raises ValueError → falls back to base clip."""
|
||||
clip = create_mock_clip(size=(W, H))
|
||||
clip.with_effects.side_effect = ValueError("test")
|
||||
with patch(
|
||||
"python_pkg.moviepy_showcase._moviepy_video_effects._base_clip",
|
||||
return_value=clip,
|
||||
):
|
||||
result = _fx(MagicMock(), "label")
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_fx_raises_oserror() -> None:
|
||||
"""with_effects raises OSError → falls back to base clip."""
|
||||
clip = create_mock_clip(size=(W, H))
|
||||
clip.with_effects.side_effect = OSError("test")
|
||||
with patch(
|
||||
"python_pkg.moviepy_showcase._moviepy_video_effects._base_clip",
|
||||
return_value=clip,
|
||||
):
|
||||
result = _fx(MagicMock(), "label")
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_fx_raises_attributeerror() -> None:
|
||||
"""with_effects raises AttributeError → falls back to base clip."""
|
||||
clip = create_mock_clip(size=(W, H))
|
||||
clip.with_effects.side_effect = AttributeError("test")
|
||||
with patch(
|
||||
"python_pkg.moviepy_showcase._moviepy_video_effects._base_clip",
|
||||
return_value=clip,
|
||||
):
|
||||
result = _fx(MagicMock(), "label")
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_fx_size_mismatch() -> None:
|
||||
"""After effect, size != (W, H) → resize_to_canvas is called."""
|
||||
clip = create_mock_clip(size=(100, 100))
|
||||
clip.with_effects.return_value = clip
|
||||
with patch(
|
||||
"python_pkg.moviepy_showcase._moviepy_video_effects._base_clip",
|
||||
return_value=clip,
|
||||
):
|
||||
result = _fx(MagicMock(), "label")
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ── part functions ───────────────────────────────────────────────
|
||||
def test_part3_effects_1_to_17() -> None:
|
||||
result = _part3_effects_1_to_17()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_part3_effects_18_to_34() -> None:
|
||||
result = _part3_effects_18_to_34()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_part3_video_effects() -> None:
|
||||
result = part3_video_effects()
|
||||
assert isinstance(result, list)
|
||||
# Should include header + effects from both halves
|
||||
assert len(result) > 1
|
||||
0
python_pkg/music_gen/tests/__init__.py
Normal file
0
python_pkg/music_gen/tests/__init__.py
Normal file
394
python_pkg/music_gen/tests/test_music_generation.py
Normal file
394
python_pkg/music_gen/tests/test_music_generation.py
Normal file
@ -0,0 +1,394 @@
|
||||
"""Tests for python_pkg.music_gen._music_generation module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from python_pkg.music_gen._music_generation import (
|
||||
SEGMENT_DURATION,
|
||||
VRAM_THRESHOLD_LARGE,
|
||||
VRAM_THRESHOLD_MEDIUM,
|
||||
_calculate_segment_duration,
|
||||
_generate_long_audio,
|
||||
crossfade_audio,
|
||||
generate_segment,
|
||||
get_device,
|
||||
get_vram_gb,
|
||||
load_model,
|
||||
select_model_size,
|
||||
)
|
||||
|
||||
|
||||
class TestGetDevice:
|
||||
"""Tests for get_device()."""
|
||||
|
||||
def test_nvidia_gpu_with_cuda(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_torch.cuda.is_available.return_value = True
|
||||
mock_torch.cuda.get_device_name.return_value = "RTX 3080"
|
||||
props = MagicMock()
|
||||
props.total_memory = 12 * 1024**3
|
||||
mock_torch.cuda.get_device_properties.return_value = props
|
||||
mock_torch.backends.mps.is_available.return_value = False
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
|
||||
with (
|
||||
patch.dict("sys.modules", {"torch": mock_torch}),
|
||||
patch("shutil.which", return_value="/usr/bin/nvidia-smi"),
|
||||
patch("subprocess.run", return_value=mock_result),
|
||||
):
|
||||
result = get_device()
|
||||
|
||||
assert result == "cuda"
|
||||
|
||||
def test_nvidia_gpu_without_cuda_raises(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_torch.cuda.is_available.return_value = False
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
|
||||
with (
|
||||
patch.dict("sys.modules", {"torch": mock_torch}),
|
||||
patch("shutil.which", return_value="/usr/bin/nvidia-smi"),
|
||||
patch("subprocess.run", return_value=mock_result),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="NVIDIA GPU detected"):
|
||||
get_device()
|
||||
|
||||
def test_nvidia_smi_not_found(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_torch.cuda.is_available.return_value = False
|
||||
mock_torch.backends.mps.is_available.return_value = False
|
||||
# hasattr check: torch.backends has 'mps' attr
|
||||
mock_backends = MagicMock()
|
||||
mock_backends.mps.is_available.return_value = False
|
||||
mock_torch.backends = mock_backends
|
||||
|
||||
with (
|
||||
patch.dict("sys.modules", {"torch": mock_torch}),
|
||||
patch("shutil.which", return_value=None),
|
||||
):
|
||||
result = get_device()
|
||||
|
||||
assert result == "cpu"
|
||||
|
||||
def test_nvidia_smi_returns_nonzero(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_torch.cuda.is_available.return_value = False
|
||||
mock_backends = MagicMock()
|
||||
mock_backends.mps.is_available.return_value = False
|
||||
mock_torch.backends = mock_backends
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 1
|
||||
|
||||
with (
|
||||
patch.dict("sys.modules", {"torch": mock_torch}),
|
||||
patch("shutil.which", return_value="/usr/bin/nvidia-smi"),
|
||||
patch("subprocess.run", return_value=mock_result),
|
||||
):
|
||||
result = get_device()
|
||||
|
||||
assert result == "cpu"
|
||||
|
||||
def test_mps_device(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_torch.cuda.is_available.return_value = False
|
||||
mock_backends = MagicMock()
|
||||
mock_backends.mps.is_available.return_value = True
|
||||
mock_torch.backends = mock_backends
|
||||
|
||||
with (
|
||||
patch.dict("sys.modules", {"torch": mock_torch}),
|
||||
patch("shutil.which", return_value=None),
|
||||
):
|
||||
result = get_device()
|
||||
|
||||
assert result == "mps"
|
||||
|
||||
def test_file_not_found_error(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_torch.cuda.is_available.return_value = False
|
||||
mock_backends = MagicMock()
|
||||
mock_backends.mps.is_available.return_value = False
|
||||
mock_torch.backends = mock_backends
|
||||
|
||||
with (
|
||||
patch.dict("sys.modules", {"torch": mock_torch}),
|
||||
patch("shutil.which", side_effect=FileNotFoundError),
|
||||
):
|
||||
result = get_device()
|
||||
|
||||
assert result == "cpu"
|
||||
|
||||
|
||||
class TestGetVramGb:
|
||||
"""Tests for get_vram_gb()."""
|
||||
|
||||
def test_cuda_available(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_torch.cuda.is_available.return_value = True
|
||||
props = MagicMock()
|
||||
props.total_memory = 8 * 1024**3
|
||||
mock_torch.cuda.get_device_properties.return_value = props
|
||||
|
||||
with patch.dict("sys.modules", {"torch": mock_torch}):
|
||||
result = get_vram_gb()
|
||||
|
||||
assert result == pytest.approx(8.0)
|
||||
|
||||
def test_no_cuda(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_torch.cuda.is_available.return_value = False
|
||||
|
||||
with patch.dict("sys.modules", {"torch": mock_torch}):
|
||||
result = get_vram_gb()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestSelectModelSize:
|
||||
"""Tests for select_model_size()."""
|
||||
|
||||
def test_user_choice_provided(self) -> None:
|
||||
assert select_model_size("small") == "small"
|
||||
|
||||
def test_no_gpu_returns_medium(self) -> None:
|
||||
with patch(
|
||||
"python_pkg.music_gen._music_generation.get_vram_gb",
|
||||
return_value=None,
|
||||
):
|
||||
assert select_model_size() == "medium"
|
||||
|
||||
def test_large_vram(self) -> None:
|
||||
with patch(
|
||||
"python_pkg.music_gen._music_generation.get_vram_gb",
|
||||
return_value=VRAM_THRESHOLD_LARGE,
|
||||
):
|
||||
assert select_model_size() == "large"
|
||||
|
||||
def test_medium_vram(self) -> None:
|
||||
with patch(
|
||||
"python_pkg.music_gen._music_generation.get_vram_gb",
|
||||
return_value=VRAM_THRESHOLD_MEDIUM,
|
||||
):
|
||||
assert select_model_size() == "medium"
|
||||
|
||||
def test_small_vram(self) -> None:
|
||||
with patch(
|
||||
"python_pkg.music_gen._music_generation.get_vram_gb",
|
||||
return_value=4.0,
|
||||
):
|
||||
assert select_model_size() == "small"
|
||||
|
||||
|
||||
class TestLoadModel:
|
||||
"""Tests for load_model()."""
|
||||
|
||||
def test_load_model(self) -> None:
|
||||
mock_processor = MagicMock()
|
||||
mock_model = MagicMock()
|
||||
mock_model.to.return_value = mock_model
|
||||
|
||||
mock_auto_processor = MagicMock()
|
||||
mock_auto_processor.from_pretrained.return_value = mock_processor
|
||||
mock_musicgen = MagicMock()
|
||||
mock_musicgen.from_pretrained.return_value = mock_model
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_generation.get_device",
|
||||
return_value="cpu",
|
||||
),
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{"transformers": MagicMock()},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_generation.AutoProcessor",
|
||||
mock_auto_processor,
|
||||
create=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_generation.MusicgenForConditionalGeneration",
|
||||
mock_musicgen,
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
# We need to mock the imports inside load_model
|
||||
pass
|
||||
|
||||
# Alternative approach - mock at the transformers import level
|
||||
mock_transformers = MagicMock()
|
||||
mock_transformers.AutoProcessor.from_pretrained.return_value = mock_processor
|
||||
mock_from_pretrained = (
|
||||
mock_transformers.MusicgenForConditionalGeneration.from_pretrained
|
||||
)
|
||||
mock_from_pretrained.return_value = mock_model
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_generation.get_device",
|
||||
return_value="cpu",
|
||||
),
|
||||
patch.dict("sys.modules", {"transformers": mock_transformers}),
|
||||
):
|
||||
model, processor = load_model("small")
|
||||
|
||||
assert model == mock_model
|
||||
assert processor == mock_processor
|
||||
mock_model.to.assert_called_once_with("cpu")
|
||||
|
||||
|
||||
class TestCrossfadeAudio:
|
||||
"""Tests for crossfade_audio()."""
|
||||
|
||||
def test_zero_crossfade_samples(self) -> None:
|
||||
a1 = np.array([1.0, 2.0, 3.0])
|
||||
a2 = np.array([4.0, 5.0, 6.0])
|
||||
result = crossfade_audio(a1, a2, 0)
|
||||
np.testing.assert_array_equal(result, np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]))
|
||||
|
||||
def test_negative_crossfade_samples(self) -> None:
|
||||
a1 = np.array([1.0, 2.0])
|
||||
a2 = np.array([3.0, 4.0])
|
||||
result = crossfade_audio(a1, a2, -1)
|
||||
np.testing.assert_array_equal(result, np.array([1.0, 2.0, 3.0, 4.0]))
|
||||
|
||||
def test_crossfade_larger_than_audio1(self) -> None:
|
||||
a1 = np.array([1.0, 2.0])
|
||||
a2 = np.array([3.0, 4.0, 5.0])
|
||||
result = crossfade_audio(a1, a2, 5)
|
||||
np.testing.assert_array_equal(result, np.array([1.0, 2.0, 3.0, 4.0, 5.0]))
|
||||
|
||||
def test_normal_crossfade(self) -> None:
|
||||
a1 = np.array([1.0, 1.0, 1.0, 1.0], dtype=np.float64)
|
||||
a2 = np.array([2.0, 2.0, 2.0, 2.0], dtype=np.float64)
|
||||
result = crossfade_audio(a1, a2, 2)
|
||||
assert len(result) == 6
|
||||
# First 2 samples from a1 (non-crossfaded)
|
||||
assert result[0] == 1.0
|
||||
assert result[1] == 1.0
|
||||
# Last 2 samples from a2 (non-crossfaded)
|
||||
assert result[4] == 2.0
|
||||
assert result[5] == 2.0
|
||||
|
||||
|
||||
class TestGenerateSegment:
|
||||
"""Tests for generate_segment()."""
|
||||
|
||||
def test_generate_segment(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_torch.no_grad.return_value.__enter__ = MagicMock()
|
||||
mock_torch.no_grad.return_value.__exit__ = MagicMock()
|
||||
|
||||
mock_processor = MagicMock()
|
||||
mock_processor.return_value = {"input_ids": MagicMock()}
|
||||
|
||||
mock_model = MagicMock()
|
||||
audio_tensor = MagicMock()
|
||||
audio_tensor.cpu.return_value.numpy.return_value = np.array([0.1, 0.2])
|
||||
# audio_values[0, 0] needs to work with tuple indexing
|
||||
audio_values = MagicMock()
|
||||
audio_values.__getitem__ = MagicMock(return_value=audio_tensor)
|
||||
mock_model.generate.return_value = audio_values
|
||||
|
||||
with patch.dict("sys.modules", {"torch": mock_torch}):
|
||||
result = generate_segment("test", mock_model, mock_processor, 10, "cpu")
|
||||
|
||||
np.testing.assert_array_equal(result, np.array([0.1, 0.2]))
|
||||
|
||||
|
||||
class TestCalculateSegmentDuration:
|
||||
"""Tests for _calculate_segment_duration()."""
|
||||
|
||||
def test_non_last_segment(self) -> None:
|
||||
result = _calculate_segment_duration(0, 3, 0, 32000, 60)
|
||||
assert result == SEGMENT_DURATION
|
||||
|
||||
def test_last_segment_remaining_large(self) -> None:
|
||||
# Last segment with a lot of remaining time
|
||||
result = _calculate_segment_duration(2, 3, 32000 * 40, 32000, 60)
|
||||
# remaining = 60 - 40 = 20
|
||||
# min_duration = max(5, 20 + 2) = 22
|
||||
# min(25, 22) = 22
|
||||
assert result == 22
|
||||
|
||||
def test_last_segment_remaining_small(self) -> None:
|
||||
# Last segment with very little remaining
|
||||
result = _calculate_segment_duration(2, 3, 32000 * 58, 32000, 60)
|
||||
# remaining = 60 - 58 = 2
|
||||
# min_duration = max(5, 2 + 2) = 5
|
||||
# min(25, 5) = 5
|
||||
assert result == 5
|
||||
|
||||
|
||||
class TestGenerateLongAudio:
|
||||
"""Tests for _generate_long_audio()."""
|
||||
|
||||
def test_generate_long_audio(self) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_param = MagicMock()
|
||||
mock_param.device = "cpu"
|
||||
mock_model.parameters.return_value = iter([mock_param])
|
||||
mock_model.config.audio_encoder.sampling_rate = 100
|
||||
|
||||
mock_processor = MagicMock()
|
||||
|
||||
segment = np.ones(100 * SEGMENT_DURATION, dtype=np.float32)
|
||||
|
||||
with patch(
|
||||
"python_pkg.music_gen._music_generation.generate_segment",
|
||||
return_value=segment,
|
||||
):
|
||||
result = _generate_long_audio("test", mock_model, mock_processor, 60)
|
||||
|
||||
assert isinstance(result, np.ndarray)
|
||||
|
||||
def test_generate_long_audio_no_trim(self) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_param = MagicMock()
|
||||
mock_param.device = "cpu"
|
||||
mock_model.parameters.return_value = iter([mock_param])
|
||||
mock_model.config.audio_encoder.sampling_rate = 10
|
||||
|
||||
mock_processor = MagicMock()
|
||||
|
||||
# Return a small segment so total < target, no trimming occurs
|
||||
segment = np.ones(10 * 5, dtype=np.float32)
|
||||
|
||||
with patch(
|
||||
"python_pkg.music_gen._music_generation.generate_segment",
|
||||
return_value=segment,
|
||||
):
|
||||
result = _generate_long_audio("test", mock_model, mock_processor, 200)
|
||||
|
||||
# Result should not exceed 200 * 10 = 2000 samples
|
||||
assert isinstance(result, np.ndarray)
|
||||
|
||||
def test_generate_long_audio_trims(self) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_param = MagicMock()
|
||||
mock_param.device = "cpu"
|
||||
mock_model.parameters.return_value = iter([mock_param])
|
||||
mock_model.config.audio_encoder.sampling_rate = 10
|
||||
|
||||
mock_processor = MagicMock()
|
||||
|
||||
# Return large segment each time so result exceeds target
|
||||
segment = np.ones(10 * SEGMENT_DURATION, dtype=np.float32)
|
||||
|
||||
with patch(
|
||||
"python_pkg.music_gen._music_generation.generate_segment",
|
||||
return_value=segment,
|
||||
):
|
||||
result = _generate_long_audio("test", mock_model, mock_processor, 30)
|
||||
|
||||
# Should be trimmed to exactly 30 * 10 = 300 samples
|
||||
assert len(result) == 300
|
||||
157
python_pkg/music_gen/tests/test_music_generation_part2.py
Normal file
157
python_pkg/music_gen/tests/test_music_generation_part2.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""Tests for generate_music in python_pkg.music_gen._music_generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_pkg.music_gen._music_generation import (
|
||||
SEGMENT_DURATION,
|
||||
generate_music,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestGenerateMusic:
|
||||
"""Tests for generate_music()."""
|
||||
|
||||
def test_short_duration_with_output_dir(self, tmp_path: Path) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_param = MagicMock()
|
||||
mock_param.device = "cpu"
|
||||
mock_model.parameters.return_value = iter([mock_param])
|
||||
mock_model.config.audio_encoder.sampling_rate = 100
|
||||
|
||||
mock_processor = MagicMock()
|
||||
audio = np.ones(100 * 10, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_generation.generate_segment",
|
||||
return_value=audio,
|
||||
),
|
||||
patch("scipy.io.wavfile.write") as mock_write,
|
||||
):
|
||||
result = generate_music(
|
||||
"test prompt",
|
||||
mock_model,
|
||||
mock_processor,
|
||||
duration_seconds=10,
|
||||
output_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert result.parent == tmp_path
|
||||
assert result.suffix == ".wav"
|
||||
assert "test_prompt" in result.name
|
||||
mock_write.assert_called_once()
|
||||
|
||||
def test_long_duration_uses_long_audio(self, tmp_path: Path) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_model.config.audio_encoder.sampling_rate = 100
|
||||
|
||||
mock_processor = MagicMock()
|
||||
audio = np.ones(100 * 60, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_generation._generate_long_audio",
|
||||
return_value=audio,
|
||||
),
|
||||
patch("scipy.io.wavfile.write"),
|
||||
):
|
||||
result = generate_music(
|
||||
"long prompt",
|
||||
mock_model,
|
||||
mock_processor,
|
||||
duration_seconds=SEGMENT_DURATION + 1,
|
||||
output_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert result.suffix == ".wav"
|
||||
|
||||
def test_default_output_dir(self) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_param = MagicMock()
|
||||
mock_param.device = "cpu"
|
||||
mock_model.parameters.return_value = iter([mock_param])
|
||||
mock_model.config.audio_encoder.sampling_rate = 100
|
||||
|
||||
mock_processor = MagicMock()
|
||||
audio = np.ones(100 * 5, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_generation.generate_segment",
|
||||
return_value=audio,
|
||||
),
|
||||
patch("scipy.io.wavfile.write"),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
):
|
||||
result = generate_music(
|
||||
"test",
|
||||
mock_model,
|
||||
mock_processor,
|
||||
duration_seconds=5,
|
||||
)
|
||||
|
||||
assert "output" in str(result.parent)
|
||||
|
||||
def test_prompt_sanitization_special_chars(self, tmp_path: Path) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_param = MagicMock()
|
||||
mock_param.device = "cpu"
|
||||
mock_model.parameters.return_value = iter([mock_param])
|
||||
mock_model.config.audio_encoder.sampling_rate = 100
|
||||
|
||||
mock_processor = MagicMock()
|
||||
audio = np.ones(100 * 5, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_generation.generate_segment",
|
||||
return_value=audio,
|
||||
),
|
||||
patch("scipy.io.wavfile.write"),
|
||||
):
|
||||
result = generate_music(
|
||||
"hello!@#$%^&*() world",
|
||||
mock_model,
|
||||
mock_processor,
|
||||
duration_seconds=5,
|
||||
output_dir=tmp_path,
|
||||
)
|
||||
|
||||
# Special chars stripped, spaces become underscores
|
||||
assert "hello_world" in result.name
|
||||
|
||||
def test_exact_segment_duration(self, tmp_path: Path) -> None:
|
||||
"""Duration == SEGMENT_DURATION should use short path."""
|
||||
mock_model = MagicMock()
|
||||
mock_param = MagicMock()
|
||||
mock_param.device = "cpu"
|
||||
mock_model.parameters.return_value = iter([mock_param])
|
||||
mock_model.config.audio_encoder.sampling_rate = 100
|
||||
|
||||
mock_processor = MagicMock()
|
||||
audio = np.ones(100 * SEGMENT_DURATION, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_generation.generate_segment",
|
||||
return_value=audio,
|
||||
) as mock_seg,
|
||||
patch("scipy.io.wavfile.write"),
|
||||
):
|
||||
generate_music(
|
||||
"test",
|
||||
mock_model,
|
||||
mock_processor,
|
||||
duration_seconds=SEGMENT_DURATION,
|
||||
output_dir=tmp_path,
|
||||
)
|
||||
|
||||
mock_seg.assert_called_once()
|
||||
245
python_pkg/music_gen/tests/test_music_generator.py
Normal file
245
python_pkg/music_gen/tests/test_music_generator.py
Normal file
@ -0,0 +1,245 @@
|
||||
"""Tests for python_pkg.music_gen.music_generator module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from python_pkg.music_gen.music_generator import (
|
||||
check_dependencies,
|
||||
interactive_mode,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
|
||||
class TestCheckDependencies:
|
||||
"""Tests for check_dependencies()."""
|
||||
|
||||
def test_all_present(self) -> None:
|
||||
with patch("importlib.util.find_spec", return_value=MagicMock()):
|
||||
assert check_dependencies() is True
|
||||
|
||||
def test_torch_missing(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
def mock_find_spec(name: str) -> Any:
|
||||
if name == "torch":
|
||||
return None
|
||||
return MagicMock()
|
||||
|
||||
with patch("importlib.util.find_spec", side_effect=mock_find_spec):
|
||||
assert check_dependencies() is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "torch" in captured.out
|
||||
|
||||
def test_transformers_missing(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
def mock_find_spec(name: str) -> Any:
|
||||
if name == "transformers":
|
||||
return None
|
||||
return MagicMock()
|
||||
|
||||
with patch("importlib.util.find_spec", side_effect=mock_find_spec):
|
||||
assert check_dependencies() is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "transformers" in captured.out
|
||||
|
||||
def test_scipy_missing(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
def mock_find_spec(name: str) -> Any:
|
||||
if name == "scipy":
|
||||
return None
|
||||
return MagicMock()
|
||||
|
||||
with patch("importlib.util.find_spec", side_effect=mock_find_spec):
|
||||
assert check_dependencies() is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "scipy" in captured.out
|
||||
|
||||
def test_bark_missing_with_include_bark(
|
||||
self,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
def mock_find_spec(name: str) -> Any:
|
||||
if name == "bark":
|
||||
return None
|
||||
return MagicMock()
|
||||
|
||||
with patch("importlib.util.find_spec", side_effect=mock_find_spec):
|
||||
assert check_dependencies(include_bark=True) is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "bark" in captured.out.lower()
|
||||
|
||||
def test_bark_not_checked_without_flag(self) -> None:
|
||||
with patch("importlib.util.find_spec", return_value=MagicMock()):
|
||||
assert check_dependencies(include_bark=False) is True
|
||||
|
||||
def test_all_present_with_bark(self) -> None:
|
||||
with patch("importlib.util.find_spec", return_value=MagicMock()):
|
||||
assert check_dependencies(include_bark=True) is True
|
||||
|
||||
|
||||
class TestInteractiveMode:
|
||||
"""Tests for interactive_mode()."""
|
||||
|
||||
def test_quit_command(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", return_value=":q"):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Exiting" in captured.out
|
||||
|
||||
def test_quit_word(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", return_value="quit"):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Exiting" in captured.out
|
||||
|
||||
def test_exit_word(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", return_value="exit"):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
def test_help_command(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", side_effect=[":h", ":q"]):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Example prompts" in captured.out
|
||||
|
||||
def test_help_word(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", side_effect=["help", ":q"]):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Example prompts" in captured.out
|
||||
|
||||
def test_set_duration(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", side_effect=[":d 15", ":q"]):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Duration set to 15s" in captured.out
|
||||
|
||||
def test_set_duration_clamped(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", side_effect=[":d 100", ":q"]):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Duration set to 30s" in captured.out
|
||||
|
||||
def test_set_duration_invalid(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", side_effect=[":d abc", ":q"]):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Invalid duration" in captured.out
|
||||
|
||||
def test_empty_prompt(self) -> None:
|
||||
with patch("builtins.input", side_effect=["", ":q"]):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
def test_number_prompt_valid(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with (
|
||||
patch("builtins.input", side_effect=["1", ":q"]),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_music",
|
||||
) as mock_gen,
|
||||
):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
mock_gen.assert_called_once()
|
||||
|
||||
def test_number_prompt_invalid(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", side_effect=["99", ":q"]):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Invalid number" in captured.out
|
||||
|
||||
def test_normal_prompt(self) -> None:
|
||||
with (
|
||||
patch("builtins.input", side_effect=["jazz music", ":q"]),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_music",
|
||||
) as mock_gen,
|
||||
):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
mock_gen.assert_called_once()
|
||||
|
||||
def test_generation_error(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with (
|
||||
patch("builtins.input", side_effect=["jazz music", ":q"]),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_music",
|
||||
side_effect=RuntimeError("CUDA OOM"),
|
||||
),
|
||||
):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Error generating music" in captured.out
|
||||
|
||||
def test_eof_error(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", side_effect=EOFError):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Exiting" in captured.out
|
||||
|
||||
def test_keyboard_interrupt(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", side_effect=KeyboardInterrupt):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Exiting" in captured.out
|
||||
|
||||
def test_quit_long(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", return_value=":quit"):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Exiting" in captured.out
|
||||
|
||||
def test_help_long(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", side_effect=[":help", ":q"]):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Example prompts" in captured.out
|
||||
|
||||
def test_duration_clamp_minimum(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch("builtins.input", side_effect=[":d 0", ":q"]):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Duration set to 1s" in captured.out
|
||||
|
||||
def test_generation_value_error(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with (
|
||||
patch("builtins.input", side_effect=["jazz", ":q"]),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_music",
|
||||
side_effect=ValueError("bad value"),
|
||||
),
|
||||
):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Error generating music" in captured.out
|
||||
|
||||
def test_generation_os_error(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with (
|
||||
patch("builtins.input", side_effect=["jazz", ":q"]),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_music",
|
||||
side_effect=OSError("disk full"),
|
||||
),
|
||||
):
|
||||
interactive_mode(MagicMock(), MagicMock())
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Error generating music" in captured.out
|
||||
308
python_pkg/music_gen/tests/test_music_generator_part2.py
Normal file
308
python_pkg/music_gen/tests/test_music_generator_part2.py
Normal file
@ -0,0 +1,308 @@
|
||||
"""Tests for main() in python_pkg.music_gen.music_generator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.music_gen.music_generator import main
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for main()."""
|
||||
|
||||
def test_no_prompt_no_interactive_exits(self) -> None:
|
||||
with (
|
||||
patch("sys.argv", ["music_generator"]),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
|
||||
def test_song_mode(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"sys.argv",
|
||||
["music_generator", "--song", "la la la", "--music", "pop"],
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_song",
|
||||
) as mock_song,
|
||||
):
|
||||
main()
|
||||
|
||||
mock_song.assert_called_once_with(
|
||||
"la la la",
|
||||
"pop",
|
||||
voice="v2/en_speaker_6",
|
||||
output_dir=None,
|
||||
)
|
||||
|
||||
def test_speech_mode(self) -> None:
|
||||
with (
|
||||
patch("sys.argv", ["music_generator", "--speech", "Hello world"]),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_speech",
|
||||
) as mock_speech,
|
||||
):
|
||||
main()
|
||||
|
||||
mock_speech.assert_called_once_with(
|
||||
"Hello world",
|
||||
voice="v2/en_speaker_6",
|
||||
output_dir=None,
|
||||
)
|
||||
|
||||
def test_music_mode_with_prompt(self) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_processor = MagicMock()
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["music_generator", "jazz piano"]),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.select_model_size",
|
||||
return_value="small",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.load_model",
|
||||
return_value=(mock_model, mock_processor),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_music",
|
||||
) as mock_gen,
|
||||
):
|
||||
main()
|
||||
|
||||
mock_gen.assert_called_once_with(
|
||||
"jazz piano",
|
||||
mock_model,
|
||||
mock_processor,
|
||||
duration_seconds=10,
|
||||
output_dir=None,
|
||||
)
|
||||
|
||||
def test_interactive_mode(self) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_processor = MagicMock()
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["music_generator", "--interactive"]),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.select_model_size",
|
||||
return_value="small",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.load_model",
|
||||
return_value=(mock_model, mock_processor),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.interactive_mode",
|
||||
) as mock_inter,
|
||||
):
|
||||
main()
|
||||
|
||||
mock_inter.assert_called_once_with(mock_model, mock_processor)
|
||||
|
||||
def test_dependencies_fail_exits(self) -> None:
|
||||
with (
|
||||
patch("sys.argv", ["music_generator", "test prompt"]),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=False,
|
||||
),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
|
||||
def test_song_dependencies_fail_exits(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"sys.argv",
|
||||
["music_generator", "--song", "la la"],
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=False,
|
||||
),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
|
||||
def test_speech_dependencies_fail_exits(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"sys.argv",
|
||||
["music_generator", "--speech", "hello"],
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=False,
|
||||
),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
|
||||
def test_with_model_flag(self) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_processor = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"sys.argv",
|
||||
["music_generator", "--model", "large", "epic orchestra"],
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.select_model_size",
|
||||
return_value="large",
|
||||
) as mock_select,
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.load_model",
|
||||
return_value=(mock_model, mock_processor),
|
||||
),
|
||||
patch("python_pkg.music_gen.music_generator.generate_music"),
|
||||
):
|
||||
main()
|
||||
|
||||
mock_select.assert_called_once_with("large")
|
||||
|
||||
def test_with_duration_flag(self) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_processor = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"sys.argv",
|
||||
["music_generator", "--duration", "30", "bass drop"],
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.select_model_size",
|
||||
return_value="medium",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.load_model",
|
||||
return_value=(mock_model, mock_processor),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_music",
|
||||
) as mock_gen,
|
||||
):
|
||||
main()
|
||||
|
||||
mock_gen.assert_called_once_with(
|
||||
"bass drop",
|
||||
mock_model,
|
||||
mock_processor,
|
||||
duration_seconds=30,
|
||||
output_dir=None,
|
||||
)
|
||||
|
||||
def test_with_output_flag(self) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_processor = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"sys.argv",
|
||||
["music_generator", "--output", "/tmp/out", "test"],
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.select_model_size",
|
||||
return_value="medium",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.load_model",
|
||||
return_value=(mock_model, mock_processor),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_music",
|
||||
) as mock_gen,
|
||||
):
|
||||
main()
|
||||
|
||||
_, kwargs = mock_gen.call_args
|
||||
assert kwargs["output_dir"] is not None
|
||||
|
||||
def test_speech_with_voice_flag(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"sys.argv",
|
||||
[
|
||||
"music_generator",
|
||||
"--speech",
|
||||
"--voice",
|
||||
"v2/en_speaker_3",
|
||||
"Hello",
|
||||
],
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_speech",
|
||||
) as mock_speech,
|
||||
):
|
||||
main()
|
||||
|
||||
mock_speech.assert_called_once_with(
|
||||
"Hello",
|
||||
voice="v2/en_speaker_3",
|
||||
output_dir=None,
|
||||
)
|
||||
|
||||
def test_song_with_voice_flag(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"sys.argv",
|
||||
[
|
||||
"music_generator",
|
||||
"--song",
|
||||
"--voice",
|
||||
"v2/en_speaker_0",
|
||||
"sing",
|
||||
],
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.check_dependencies",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen.music_generator.generate_song",
|
||||
) as mock_song,
|
||||
):
|
||||
main()
|
||||
|
||||
mock_song.assert_called_once_with(
|
||||
"sing",
|
||||
"upbeat pop instrumental backing track",
|
||||
voice="v2/en_speaker_0",
|
||||
output_dir=None,
|
||||
)
|
||||
492
python_pkg/music_gen/tests/test_music_speech.py
Normal file
492
python_pkg/music_gen/tests/test_music_speech.py
Normal file
@ -0,0 +1,492 @@
|
||||
"""Tests for python_pkg.music_gen._music_speech module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from python_pkg.music_gen._music_speech import (
|
||||
BARK_MAX_CHARS,
|
||||
_generate_instrumental_for_song,
|
||||
_generate_vocals_for_song,
|
||||
_mix_audio,
|
||||
_resample_audio,
|
||||
_split_into_sentences,
|
||||
generate_speech,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestSplitIntoSentences:
|
||||
"""Tests for _split_into_sentences()."""
|
||||
|
||||
def test_single_sentence(self) -> None:
|
||||
result = _split_into_sentences("Hello world.")
|
||||
assert result == ["Hello world."]
|
||||
|
||||
def test_multiple_sentences(self) -> None:
|
||||
result = _split_into_sentences("First sentence. Second sentence. Third.")
|
||||
assert len(result) >= 1
|
||||
# All sentences should be present
|
||||
combined = " ".join(result)
|
||||
assert "First sentence." in combined
|
||||
assert "Second sentence." in combined
|
||||
|
||||
def test_short_sentences_grouped(self) -> None:
|
||||
result = _split_into_sentences("Hi. Ok. Yes.")
|
||||
# Short sentences should be grouped together (< BARK_MAX_CHARS)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_long_text_splits(self) -> None:
|
||||
# Create text that exceeds BARK_MAX_CHARS when combined
|
||||
long_sentence = "A" * (BARK_MAX_CHARS - 10) + "."
|
||||
text = f"{long_sentence} {long_sentence}"
|
||||
result = _split_into_sentences(text)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_empty_result_returns_original(self) -> None:
|
||||
# A single word with no sentence boundaries
|
||||
result = _split_into_sentences("hello")
|
||||
assert result == ["hello"]
|
||||
|
||||
def test_whitespace_stripped(self) -> None:
|
||||
result = _split_into_sentences(" Hello world. ")
|
||||
assert result[0] == "Hello world."
|
||||
|
||||
def test_current_empty_in_else_branch(self) -> None:
|
||||
# First sentence exceeds BARK_MAX_CHARS so current is empty when else hit
|
||||
long_sent = "A" * (BARK_MAX_CHARS + 10) + "."
|
||||
short_sent = "Short."
|
||||
text = f"{long_sent} {short_sent}"
|
||||
result = _split_into_sentences(text)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_all_sentences_too_long(self) -> None:
|
||||
# Each individual sentence is huge -- current is never empty at else
|
||||
s1 = "A" * (BARK_MAX_CHARS + 10) + "."
|
||||
s2 = "B" * (BARK_MAX_CHARS + 10) + "."
|
||||
text = f"{s1} {s2}"
|
||||
result = _split_into_sentences(text)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_empty_string_input(self) -> None:
|
||||
# Empty string → sentences=[''], current stays '' after loop
|
||||
result = _split_into_sentences("")
|
||||
assert result == [""]
|
||||
|
||||
|
||||
class TestResampleAudio:
|
||||
"""Tests for _resample_audio()."""
|
||||
|
||||
def test_same_rate_returns_unchanged(self) -> None:
|
||||
audio = np.array([1.0, 2.0, 3.0], dtype=np.float32)
|
||||
result = _resample_audio(audio, 44100, 44100)
|
||||
np.testing.assert_array_equal(result, audio)
|
||||
|
||||
def test_resample_different_rate(self) -> None:
|
||||
audio = np.ones(100, dtype=np.float32)
|
||||
result = _resample_audio(audio, 44100, 22050)
|
||||
# Should be shorter since target rate is lower
|
||||
expected_length = int(len(audio) / 44100 * 22050)
|
||||
assert len(result) == expected_length
|
||||
assert result.dtype == np.float32
|
||||
|
||||
|
||||
class TestMixAudio:
|
||||
"""Tests for _mix_audio()."""
|
||||
|
||||
def test_vocals_shorter_than_instrumental(self) -> None:
|
||||
instrumental = np.ones(100, dtype=np.float32)
|
||||
vocals = np.ones(50, dtype=np.float32)
|
||||
result = _mix_audio(instrumental, vocals)
|
||||
assert len(result) == 100
|
||||
|
||||
def test_vocals_longer_than_instrumental(self) -> None:
|
||||
instrumental = np.ones(50, dtype=np.float32)
|
||||
vocals = np.ones(100, dtype=np.float32)
|
||||
result = _mix_audio(instrumental, vocals)
|
||||
assert len(result) == 50
|
||||
|
||||
def test_same_length(self) -> None:
|
||||
instrumental = np.ones(100, dtype=np.float32)
|
||||
vocals = np.ones(100, dtype=np.float32)
|
||||
result = _mix_audio(instrumental, vocals)
|
||||
assert len(result) == 100
|
||||
|
||||
def test_normalization_when_clipping(self) -> None:
|
||||
instrumental = np.ones(10, dtype=np.float32) * 2.0
|
||||
vocals = np.ones(10, dtype=np.float32) * 2.0
|
||||
result = _mix_audio(
|
||||
instrumental, vocals, vocal_volume=1.0, instrumental_volume=1.0
|
||||
)
|
||||
# Should be normalized so max <= 1.0
|
||||
assert np.max(np.abs(result)) <= 1.0 + 1e-6
|
||||
|
||||
def test_no_normalization_needed(self) -> None:
|
||||
instrumental = np.ones(10, dtype=np.float32) * 0.1
|
||||
vocals = np.ones(10, dtype=np.float32) * 0.1
|
||||
result = _mix_audio(
|
||||
instrumental, vocals, vocal_volume=0.5, instrumental_volume=0.5
|
||||
)
|
||||
assert result.dtype == np.float32
|
||||
|
||||
def test_output_type(self) -> None:
|
||||
instrumental = np.ones(10, dtype=np.float32) * 0.5
|
||||
vocals = np.ones(10, dtype=np.float32) * 0.5
|
||||
result = _mix_audio(instrumental, vocals)
|
||||
assert result.dtype == np.float32
|
||||
|
||||
|
||||
class TestGenerateSpeech:
|
||||
"""Tests for generate_speech()."""
|
||||
|
||||
def test_single_sentence(self, tmp_path: Path) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_bark = MagicMock()
|
||||
mock_bark.SAMPLE_RATE = 24000
|
||||
mock_bark.generate_audio.return_value = np.zeros(24000, dtype=np.float32)
|
||||
|
||||
np.zeros(24000, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"torch": mock_torch,
|
||||
"functools": __import__("functools"),
|
||||
"numpy": np,
|
||||
"scipy": MagicMock(),
|
||||
"scipy.io": MagicMock(),
|
||||
"scipy.io.wavfile": MagicMock(),
|
||||
"bark": mock_bark,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._split_into_sentences",
|
||||
return_value=["Hello world."],
|
||||
),
|
||||
patch("scipy.io.wavfile.write"),
|
||||
):
|
||||
result = generate_speech("Hello world.", output_dir=tmp_path)
|
||||
|
||||
assert result.parent == tmp_path
|
||||
assert result.suffix == ".wav"
|
||||
assert "speech" in result.name
|
||||
|
||||
def test_multiple_sentences(self, tmp_path: Path) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_bark = MagicMock()
|
||||
mock_bark.SAMPLE_RATE = 24000
|
||||
mock_bark.generate_audio.return_value = np.zeros(24000, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"torch": mock_torch,
|
||||
"functools": __import__("functools"),
|
||||
"numpy": np,
|
||||
"scipy": MagicMock(),
|
||||
"scipy.io": MagicMock(),
|
||||
"scipy.io.wavfile": MagicMock(),
|
||||
"bark": mock_bark,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._split_into_sentences",
|
||||
return_value=["First sentence.", "Second sentence."],
|
||||
),
|
||||
patch("scipy.io.wavfile.write"),
|
||||
):
|
||||
result = generate_speech(
|
||||
"First sentence. Second sentence.",
|
||||
output_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert result.suffix == ".wav"
|
||||
|
||||
def test_default_output_dir(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_bark = MagicMock()
|
||||
mock_bark.SAMPLE_RATE = 24000
|
||||
mock_bark.generate_audio.return_value = np.zeros(24000, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"torch": mock_torch,
|
||||
"functools": __import__("functools"),
|
||||
"numpy": np,
|
||||
"scipy": MagicMock(),
|
||||
"scipy.io": MagicMock(),
|
||||
"scipy.io.wavfile": MagicMock(),
|
||||
"bark": mock_bark,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._split_into_sentences",
|
||||
return_value=["Hello."],
|
||||
),
|
||||
patch("scipy.io.wavfile.write"),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
):
|
||||
result = generate_speech("Hello.")
|
||||
|
||||
assert "output" in str(result.parent)
|
||||
|
||||
def test_patched_load_called(self, tmp_path: Path) -> None:
|
||||
"""Ensure the patched_load inner function is actually invoked."""
|
||||
import sys
|
||||
|
||||
mock_torch = MagicMock()
|
||||
original_load = MagicMock(return_value="loaded")
|
||||
mock_torch.load = original_load
|
||||
|
||||
mock_bark = MagicMock()
|
||||
mock_bark.SAMPLE_RATE = 24000
|
||||
mock_bark.generate_audio.return_value = np.zeros(24000, dtype=np.float32)
|
||||
|
||||
# Make preload_models call torch.load so patched_load runs
|
||||
def call_torch_load() -> None:
|
||||
sys.modules["torch"].load("model.pt")
|
||||
|
||||
mock_bark.preload_models.side_effect = call_torch_load
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"torch": mock_torch,
|
||||
"functools": __import__("functools"),
|
||||
"numpy": np,
|
||||
"scipy": MagicMock(),
|
||||
"scipy.io": MagicMock(),
|
||||
"scipy.io.wavfile": MagicMock(),
|
||||
"bark": mock_bark,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._split_into_sentences",
|
||||
return_value=["Hello."],
|
||||
),
|
||||
patch("scipy.io.wavfile.write"),
|
||||
):
|
||||
generate_speech("Hello.", output_dir=tmp_path)
|
||||
|
||||
# The original_load should have been called via patched_load
|
||||
original_load.assert_called_once_with("model.pt", weights_only=False)
|
||||
|
||||
def test_torch_load_restored_after_exception(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
original_load = mock_torch.load
|
||||
|
||||
mock_bark = MagicMock()
|
||||
mock_bark.preload_models.side_effect = RuntimeError("test error")
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"torch": mock_torch,
|
||||
"functools": __import__("functools"),
|
||||
"numpy": np,
|
||||
"scipy": MagicMock(),
|
||||
"scipy.io": MagicMock(),
|
||||
"scipy.io.wavfile": MagicMock(),
|
||||
"bark": mock_bark,
|
||||
},
|
||||
),
|
||||
pytest.raises(RuntimeError, match="test error"),
|
||||
):
|
||||
generate_speech("Hello.")
|
||||
|
||||
# torch.load should be restored
|
||||
assert mock_torch.load == original_load
|
||||
|
||||
|
||||
class TestGenerateVocalsForSong:
|
||||
"""Tests for _generate_vocals_for_song()."""
|
||||
|
||||
def test_single_sentence(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_bark = MagicMock()
|
||||
mock_bark.SAMPLE_RATE = 24000
|
||||
audio_array = np.zeros(24000, dtype=np.float32)
|
||||
mock_bark.generate_audio.return_value = audio_array
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"torch": mock_torch,
|
||||
"functools": __import__("functools"),
|
||||
"numpy": np,
|
||||
"bark": mock_bark,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._split_into_sentences",
|
||||
return_value=["Hello."],
|
||||
),
|
||||
):
|
||||
vocals, sr = _generate_vocals_for_song("Hello.", "v2/en_speaker_6")
|
||||
|
||||
assert sr == 24000
|
||||
np.testing.assert_array_equal(vocals, audio_array)
|
||||
|
||||
def test_multiple_sentences(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
mock_bark = MagicMock()
|
||||
mock_bark.SAMPLE_RATE = 24000
|
||||
audio_array = np.ones(12000, dtype=np.float32)
|
||||
mock_bark.generate_audio.return_value = audio_array
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"torch": mock_torch,
|
||||
"functools": __import__("functools"),
|
||||
"numpy": np,
|
||||
"bark": mock_bark,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._split_into_sentences",
|
||||
return_value=["First.", "Second."],
|
||||
),
|
||||
):
|
||||
vocals, sr = _generate_vocals_for_song(
|
||||
"First. Second.",
|
||||
"v2/en_speaker_6",
|
||||
)
|
||||
|
||||
assert sr == 24000
|
||||
assert len(vocals) == 24000 # Two 12000-sample arrays concatenated
|
||||
|
||||
def test_torch_load_restored(self) -> None:
|
||||
mock_torch = MagicMock()
|
||||
original_load = mock_torch.load
|
||||
|
||||
mock_bark = MagicMock()
|
||||
mock_bark.preload_models.side_effect = RuntimeError("fail")
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"torch": mock_torch,
|
||||
"functools": __import__("functools"),
|
||||
"numpy": np,
|
||||
"bark": mock_bark,
|
||||
},
|
||||
),
|
||||
pytest.raises(RuntimeError, match="fail"),
|
||||
):
|
||||
_generate_vocals_for_song("Hello.", "v2/en_speaker_6")
|
||||
|
||||
assert mock_torch.load == original_load
|
||||
|
||||
def test_patched_load_is_invoked(self) -> None:
|
||||
"""Ensure patched_load inner function runs in _generate_vocals_for_song."""
|
||||
import sys
|
||||
|
||||
mock_torch = MagicMock()
|
||||
original_load = MagicMock(return_value="loaded_model")
|
||||
mock_torch.load = original_load
|
||||
|
||||
mock_bark = MagicMock()
|
||||
mock_bark.SAMPLE_RATE = 24000
|
||||
audio_array = np.zeros(24000, dtype=np.float32)
|
||||
mock_bark.generate_audio.return_value = audio_array
|
||||
|
||||
def call_torch_load() -> None:
|
||||
sys.modules["torch"].load("weights.pt")
|
||||
|
||||
mock_bark.preload_models.side_effect = call_torch_load
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"torch": mock_torch,
|
||||
"functools": __import__("functools"),
|
||||
"numpy": np,
|
||||
"bark": mock_bark,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._split_into_sentences",
|
||||
return_value=["Hello."],
|
||||
),
|
||||
):
|
||||
vocals, sr = _generate_vocals_for_song("Hello.", "v2/en_speaker_6")
|
||||
|
||||
assert sr == 24000
|
||||
# The original_load should have been called via patched_load
|
||||
original_load.assert_called_once_with("weights.pt", weights_only=False)
|
||||
|
||||
|
||||
class TestGenerateInstrumentalForSong:
|
||||
"""Tests for _generate_instrumental_for_song()."""
|
||||
|
||||
def test_short_duration(self) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_param = MagicMock()
|
||||
mock_param.device = "cpu"
|
||||
mock_model.parameters.return_value = iter([mock_param])
|
||||
mock_model.config.audio_encoder.sampling_rate = 100
|
||||
|
||||
audio = np.zeros(100 * 10, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech.select_model_size",
|
||||
return_value="small",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech.load_model",
|
||||
return_value=(mock_model, MagicMock()),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech.generate_segment",
|
||||
return_value=audio,
|
||||
),
|
||||
):
|
||||
instrumental, sr = _generate_instrumental_for_song("test", 10)
|
||||
|
||||
assert sr == 100
|
||||
np.testing.assert_array_equal(instrumental, audio)
|
||||
|
||||
def test_long_duration(self) -> None:
|
||||
mock_model = MagicMock()
|
||||
mock_param = MagicMock()
|
||||
mock_param.device = "cpu"
|
||||
mock_model.parameters.return_value = iter([mock_param])
|
||||
mock_model.config.audio_encoder.sampling_rate = 100
|
||||
|
||||
audio = np.zeros(100 * 60, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech.select_model_size",
|
||||
return_value="small",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech.load_model",
|
||||
return_value=(mock_model, MagicMock()),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._generate_long_audio",
|
||||
return_value=audio,
|
||||
),
|
||||
):
|
||||
instrumental, sr = _generate_instrumental_for_song("test", 60)
|
||||
|
||||
assert sr == 100
|
||||
150
python_pkg/music_gen/tests/test_music_speech_part2.py
Normal file
150
python_pkg/music_gen/tests/test_music_speech_part2.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""Tests for generate_song in python_pkg.music_gen._music_speech."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_pkg.music_gen._music_speech import generate_song
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestGenerateSong:
|
||||
"""Tests for generate_song()."""
|
||||
|
||||
def test_with_output_dir(self, tmp_path: Path) -> None:
|
||||
vocals = np.ones(24000, dtype=np.float32)
|
||||
instrumental = np.ones(3200, dtype=np.float32)
|
||||
resampled = np.ones(3200, dtype=np.float32)
|
||||
mixed = np.ones(3200, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._generate_vocals_for_song",
|
||||
return_value=(vocals, 24000),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._generate_instrumental_for_song",
|
||||
return_value=(instrumental, 32000),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._resample_audio",
|
||||
return_value=resampled,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._mix_audio",
|
||||
return_value=mixed,
|
||||
),
|
||||
patch("scipy.io.wavfile.write") as mock_write,
|
||||
):
|
||||
result = generate_song(
|
||||
"la la la",
|
||||
"upbeat pop",
|
||||
output_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert result.parent == tmp_path
|
||||
assert result.suffix == ".wav"
|
||||
assert "song" in result.name
|
||||
mock_write.assert_called_once()
|
||||
|
||||
def test_default_output_dir(self) -> None:
|
||||
vocals = np.ones(24000, dtype=np.float32)
|
||||
instrumental = np.ones(3200, dtype=np.float32)
|
||||
resampled = np.ones(3200, dtype=np.float32)
|
||||
mixed = np.ones(3200, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._generate_vocals_for_song",
|
||||
return_value=(vocals, 24000),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._generate_instrumental_for_song",
|
||||
return_value=(instrumental, 32000),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._resample_audio",
|
||||
return_value=resampled,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._mix_audio",
|
||||
return_value=mixed,
|
||||
),
|
||||
patch("scipy.io.wavfile.write"),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
):
|
||||
result = generate_song("la la la", "pop")
|
||||
|
||||
assert "output" in str(result.parent)
|
||||
|
||||
def test_lyrics_sanitization(self, tmp_path: Path) -> None:
|
||||
vocals = np.ones(24000, dtype=np.float32)
|
||||
instrumental = np.ones(3200, dtype=np.float32)
|
||||
resampled = np.ones(3200, dtype=np.float32)
|
||||
mixed = np.ones(3200, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._generate_vocals_for_song",
|
||||
return_value=(vocals, 24000),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._generate_instrumental_for_song",
|
||||
return_value=(instrumental, 32000),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._resample_audio",
|
||||
return_value=resampled,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._mix_audio",
|
||||
return_value=mixed,
|
||||
),
|
||||
patch("scipy.io.wavfile.write"),
|
||||
):
|
||||
result = generate_song(
|
||||
"hello!@#$ world",
|
||||
"rock",
|
||||
output_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert "hello_world" in result.name
|
||||
|
||||
def test_custom_voice(self, tmp_path: Path) -> None:
|
||||
vocals = np.ones(24000, dtype=np.float32)
|
||||
instrumental = np.ones(3200, dtype=np.float32)
|
||||
resampled = np.ones(3200, dtype=np.float32)
|
||||
mixed = np.ones(3200, dtype=np.float32)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._generate_vocals_for_song",
|
||||
return_value=(vocals, 24000),
|
||||
) as mock_vocals,
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._generate_instrumental_for_song",
|
||||
return_value=(instrumental, 32000),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._resample_audio",
|
||||
return_value=resampled,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.music_gen._music_speech._mix_audio",
|
||||
return_value=mixed,
|
||||
),
|
||||
patch("scipy.io.wavfile.write"),
|
||||
):
|
||||
generate_song(
|
||||
"test",
|
||||
"jazz",
|
||||
voice="v2/en_speaker_3",
|
||||
output_dir=tmp_path,
|
||||
)
|
||||
|
||||
mock_vocals.assert_called_once_with("test", "v2/en_speaker_3")
|
||||
@ -11,8 +11,7 @@ REGULAR_MODIFIERS: list[Modifier] = [
|
||||
{
|
||||
"name": "Pair Bonus",
|
||||
"description": (
|
||||
"Any pocket pair: everyone else pays you 1 chip, "
|
||||
"even if you lose the hand."
|
||||
"Any pocket pair: everyone else pays you 1 chip, even if you lose the hand."
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -82,7 +81,7 @@ REGULAR_MODIFIERS: list[Modifier] = [
|
||||
{
|
||||
"name": "Deck Shuffle",
|
||||
"description": (
|
||||
"After dealing hole cards, shuffle deck " "and redeal all community cards."
|
||||
"After dealing hole cards, shuffle deck and redeal all community cards."
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -101,7 +100,7 @@ REGULAR_MODIFIERS: list[Modifier] = [
|
||||
{
|
||||
"name": "Escalation",
|
||||
"description": (
|
||||
"Each raise must be at least 2x the previous raise " "(not just matching)."
|
||||
"Each raise must be at least 2x the previous raise (not just matching)."
|
||||
),
|
||||
},
|
||||
# Position and Action Modifiers
|
||||
@ -236,8 +235,7 @@ REGULAR_MODIFIERS: list[Modifier] = [
|
||||
{
|
||||
"name": "Prediction Pool",
|
||||
"description": (
|
||||
"Everyone puts 1 chip in pool. "
|
||||
"Guess the river card exactly = win the pool."
|
||||
"Everyone puts 1 chip in pool. Guess the river card exactly = win the pool."
|
||||
),
|
||||
},
|
||||
# Partnership Modifiers
|
||||
@ -374,7 +372,7 @@ ENDGAME_MODIFIERS: list[Modifier] = [
|
||||
{
|
||||
"name": "Confession Booth",
|
||||
"description": (
|
||||
"Each player must truthfully state " "their biggest bluff this session."
|
||||
"Each player must truthfully state their biggest bluff this session."
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -399,7 +397,7 @@ ENDGAME_MODIFIERS: list[Modifier] = [
|
||||
{
|
||||
"name": "Emergency Fund",
|
||||
"description": (
|
||||
"All players with less than 5 chips " "get emergency funding from the pot."
|
||||
"All players with less than 5 chips get emergency funding from the pot."
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -413,7 +411,7 @@ ENDGAME_MODIFIERS: list[Modifier] = [
|
||||
{
|
||||
"name": "Nuclear Option",
|
||||
"description": (
|
||||
"Dealer burns the top 3 cards. " "Play with whatever's left in the deck."
|
||||
"Dealer burns the top 3 cards. Play with whatever's left in the deck."
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -438,7 +436,7 @@ ENDGAME_MODIFIERS: list[Modifier] = [
|
||||
{
|
||||
"name": "Photo Finish",
|
||||
"description": (
|
||||
"Take a photo of the winning hand - " "it goes in the poker hall of fame."
|
||||
"Take a photo of the winning hand - it goes in the poker hall of fame."
|
||||
),
|
||||
},
|
||||
# Chaos Theory
|
||||
|
||||
0
python_pkg/poker_modifier_app/tests/__init__.py
Normal file
0
python_pkg/poker_modifier_app/tests/__init__.py
Normal file
310
python_pkg/poker_modifier_app/tests/test_poker_gui_part2.py
Normal file
310
python_pkg/poker_modifier_app/tests/test_poker_gui_part2.py
Normal file
@ -0,0 +1,310 @@
|
||||
"""Tests for _poker_gui.py - GUI setup mixin methods."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _install_tk_mocks() -> dict[str, MagicMock]:
|
||||
"""Install mock tkinter modules and return them."""
|
||||
mock_tk = MagicMock()
|
||||
mock_ttk = MagicMock()
|
||||
mock_tk.ttk = mock_ttk
|
||||
|
||||
# Constants used in the source
|
||||
mock_tk.BOTH = "both"
|
||||
mock_tk.X = "x"
|
||||
mock_tk.LEFT = "left"
|
||||
mock_tk.RIGHT = "right"
|
||||
mock_tk.HORIZONTAL = "horizontal"
|
||||
mock_tk.CENTER = "center"
|
||||
mock_tk.RIDGE = "ridge"
|
||||
mock_tk.RAISED = "raised"
|
||||
mock_tk.SUNKEN = "sunken"
|
||||
|
||||
# Make constructors return fresh mocks each time
|
||||
mock_tk.Tk.return_value = MagicMock(name="root")
|
||||
mock_tk.Frame.side_effect = lambda *a, **kw: MagicMock(name="Frame")
|
||||
mock_tk.Label.side_effect = lambda *a, **kw: MagicMock(name="Label")
|
||||
mock_tk.LabelFrame.side_effect = lambda *a, **kw: MagicMock(name="LabelFrame")
|
||||
mock_tk.Scale.side_effect = lambda *a, **kw: MagicMock(name="Scale")
|
||||
mock_tk.IntVar.side_effect = lambda *a, **kw: MagicMock(name="IntVar")
|
||||
mock_tk.BooleanVar.side_effect = lambda *a, **kw: MagicMock(name="BooleanVar")
|
||||
mock_tk.Checkbutton.side_effect = lambda *a, **kw: MagicMock(name="Checkbutton")
|
||||
mock_tk.Button.side_effect = lambda *a, **kw: MagicMock(name="Button")
|
||||
|
||||
return {"tk": mock_tk, "ttk": mock_ttk}
|
||||
|
||||
|
||||
def _make_mixin() -> Any:
|
||||
"""Create a PokerGuiMixin instance with mocked tkinter."""
|
||||
tk_mocks = _install_tk_mocks()
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"tkinter": tk_mocks["tk"],
|
||||
"tkinter.ttk": tk_mocks["ttk"],
|
||||
},
|
||||
):
|
||||
# Force reimport so the module picks up mocked tkinter
|
||||
mod_name = "python_pkg.poker_modifier_app._poker_gui"
|
||||
if mod_name in sys.modules:
|
||||
del sys.modules[mod_name]
|
||||
|
||||
from python_pkg.poker_modifier_app._poker_gui import PokerGuiMixin
|
||||
|
||||
mixin = PokerGuiMixin()
|
||||
return mixin, tk_mocks["tk"], tk_mocks["ttk"]
|
||||
|
||||
|
||||
class TestSetupGui:
|
||||
"""Tests for setup_gui orchestration."""
|
||||
|
||||
def test_setup_gui_calls_all_subparts(self) -> None:
|
||||
mixin, _tk, _ttk = _make_mixin()
|
||||
with (
|
||||
patch.object(mixin, "_setup_main_window") as m_win,
|
||||
patch.object(mixin, "_create_main_frame") as m_frame,
|
||||
patch.object(mixin, "_create_title") as m_title,
|
||||
patch.object(mixin, "_create_settings_frame") as m_settings,
|
||||
patch.object(mixin, "_create_result_display") as m_result,
|
||||
patch.object(mixin, "_create_buttons") as m_buttons,
|
||||
patch.object(mixin, "_create_statistics_frame") as m_stats,
|
||||
):
|
||||
main_frame_mock = MagicMock()
|
||||
m_frame.return_value = main_frame_mock
|
||||
mixin.setup_gui()
|
||||
|
||||
m_win.assert_called_once()
|
||||
m_frame.assert_called_once()
|
||||
m_title.assert_called_once_with(main_frame_mock)
|
||||
m_settings.assert_called_once_with(main_frame_mock)
|
||||
m_result.assert_called_once_with(main_frame_mock)
|
||||
m_buttons.assert_called_once_with(main_frame_mock)
|
||||
m_stats.assert_called_once_with(main_frame_mock)
|
||||
|
||||
|
||||
class TestSetupMainWindow:
|
||||
"""Tests for _setup_main_window."""
|
||||
|
||||
def test_creates_root_and_configures(self) -> None:
|
||||
mixin, mock_tk, mock_ttk = _make_mixin()
|
||||
mixin._setup_main_window()
|
||||
|
||||
mock_tk.Tk.assert_called_once()
|
||||
root = mixin.root
|
||||
root.title.assert_called_once_with("🃏 Texas Hold'em Modifier")
|
||||
root.geometry.assert_called_once_with("650x750")
|
||||
root.configure.assert_called_once_with(bg="#0f4c3a")
|
||||
root.resizable.assert_called_once_with(True, True)
|
||||
mock_ttk.Style.assert_called_once()
|
||||
mock_ttk.Style.return_value.theme_use.assert_called_once_with("clam")
|
||||
|
||||
|
||||
class TestCreateMainFrame:
|
||||
"""Tests for _create_main_frame."""
|
||||
|
||||
def test_creates_frame_and_packs(self) -> None:
|
||||
mixin, mock_tk, _ttk = _make_mixin()
|
||||
mixin.root = MagicMock()
|
||||
result = mixin._create_main_frame()
|
||||
|
||||
mock_tk.Frame.assert_called_once_with(
|
||||
mixin.root, bg="#0f4c3a", padx=20, pady=20
|
||||
)
|
||||
result.pack.assert_called_once_with(fill="both", expand=True)
|
||||
|
||||
|
||||
class TestCreateTitle:
|
||||
"""Tests for _create_title."""
|
||||
|
||||
def test_creates_title_label(self) -> None:
|
||||
mixin, mock_tk, _ttk = _make_mixin()
|
||||
parent = MagicMock()
|
||||
mixin._create_title(parent)
|
||||
|
||||
mock_tk.Label.assert_called_once_with(
|
||||
parent,
|
||||
text="🃏 Texas Hold'em Modifier",
|
||||
font=("Arial", 24, "bold"),
|
||||
fg="#ffd700",
|
||||
bg="#0f4c3a",
|
||||
)
|
||||
|
||||
|
||||
class TestCreateSettingsFrame:
|
||||
"""Tests for _create_settings_frame."""
|
||||
|
||||
def test_creates_settings_and_sub_controls(self) -> None:
|
||||
mixin, mock_tk, _ttk = _make_mixin()
|
||||
parent = MagicMock()
|
||||
with (
|
||||
patch.object(mixin, "_create_probability_controls") as m_prob,
|
||||
patch.object(mixin, "_create_debug_controls") as m_debug,
|
||||
patch.object(mixin, "_create_length_controls") as m_length,
|
||||
):
|
||||
mixin._create_settings_frame(parent)
|
||||
|
||||
mock_tk.LabelFrame.assert_called_once()
|
||||
lf_kwargs = mock_tk.LabelFrame.call_args
|
||||
assert lf_kwargs[1]["text"] == "Settings"
|
||||
|
||||
m_prob.assert_called_once()
|
||||
m_debug.assert_called_once()
|
||||
m_length.assert_called_once()
|
||||
|
||||
|
||||
class TestCreateProbabilityControls:
|
||||
"""Tests for _create_probability_controls."""
|
||||
|
||||
def test_creates_prob_slider_and_label(self) -> None:
|
||||
mixin, mock_tk, _ttk = _make_mixin()
|
||||
# Provide required attributes used as command callbacks
|
||||
mixin.update_prob_display = MagicMock()
|
||||
parent = MagicMock()
|
||||
mixin._create_probability_controls(parent)
|
||||
|
||||
# Frame created
|
||||
assert mock_tk.Frame.call_count >= 1
|
||||
# Label for "Modifier Probability:"
|
||||
label_calls = mock_tk.Label.call_args_list
|
||||
assert any(c[1].get("text") == "Modifier Probability:" for c in label_calls)
|
||||
# IntVar with default 30
|
||||
mock_tk.IntVar.assert_called_once_with(value=30)
|
||||
assert hasattr(mixin, "prob_var")
|
||||
# Scale created
|
||||
mock_tk.Scale.assert_called_once()
|
||||
assert hasattr(mixin, "prob_scale")
|
||||
# Prob label created
|
||||
prob_labels = [c for c in label_calls if c[1].get("text") == "30%"]
|
||||
assert len(prob_labels) == 1
|
||||
assert hasattr(mixin, "prob_label")
|
||||
|
||||
|
||||
class TestCreateDebugControls:
|
||||
"""Tests for _create_debug_controls."""
|
||||
|
||||
def test_creates_debug_checkbox_and_button(self) -> None:
|
||||
mixin, mock_tk, _ttk = _make_mixin()
|
||||
mixin.toggle_debug_mode = MagicMock()
|
||||
mixin.toggle_force_endgame = MagicMock()
|
||||
parent = MagicMock()
|
||||
mixin._create_debug_controls(parent)
|
||||
|
||||
mock_tk.BooleanVar.assert_called_once_with(value=False)
|
||||
assert hasattr(mixin, "debug_var")
|
||||
mock_tk.Checkbutton.assert_called_once()
|
||||
cb_kwargs = mock_tk.Checkbutton.call_args[1]
|
||||
assert cb_kwargs["text"] == "Debug Mode"
|
||||
|
||||
mock_tk.Button.assert_called_once()
|
||||
btn_kwargs = mock_tk.Button.call_args[1]
|
||||
assert btn_kwargs["text"] == "Force Endgame"
|
||||
assert hasattr(mixin, "force_endgame_button")
|
||||
|
||||
|
||||
class TestCreateLengthControls:
|
||||
"""Tests for _create_length_controls."""
|
||||
|
||||
def test_creates_length_slider_and_label(self) -> None:
|
||||
mixin, mock_tk, _ttk = _make_mixin()
|
||||
mixin.update_length_display = MagicMock()
|
||||
parent = MagicMock()
|
||||
mixin._create_length_controls(parent)
|
||||
|
||||
assert mock_tk.Frame.call_count >= 1
|
||||
label_calls = mock_tk.Label.call_args_list
|
||||
assert any(c[1].get("text") == "Total Game Rounds:" for c in label_calls)
|
||||
mock_tk.IntVar.assert_called_once_with(value=20)
|
||||
assert hasattr(mixin, "length_var")
|
||||
mock_tk.Scale.assert_called_once()
|
||||
assert hasattr(mixin, "length_scale")
|
||||
length_labels = [c for c in label_calls if c[1].get("text") == "20"]
|
||||
assert len(length_labels) == 1
|
||||
assert hasattr(mixin, "length_label")
|
||||
|
||||
|
||||
class TestCreateResultDisplay:
|
||||
"""Tests for _create_result_display."""
|
||||
|
||||
def test_creates_result_frame_and_label(self) -> None:
|
||||
mixin, mock_tk, _ttk = _make_mixin()
|
||||
parent = MagicMock()
|
||||
mixin._create_result_display(parent)
|
||||
|
||||
# Result frame
|
||||
frame_calls = mock_tk.Frame.call_args_list
|
||||
assert any(c[1].get("height") == 150 for c in frame_calls)
|
||||
assert hasattr(mixin, "result_frame")
|
||||
mixin.result_frame.pack_propagate.assert_called_once_with(False)
|
||||
|
||||
# Result label
|
||||
label_calls = mock_tk.Label.call_args_list
|
||||
assert any(
|
||||
c[1].get("text") == "Click 'Start Round' to begin!" for c in label_calls
|
||||
)
|
||||
assert hasattr(mixin, "result_label")
|
||||
|
||||
|
||||
class TestCreateButtons:
|
||||
"""Tests for _create_buttons."""
|
||||
|
||||
def test_creates_start_and_reset_buttons(self) -> None:
|
||||
mixin, mock_tk, _ttk = _make_mixin()
|
||||
mixin.start_round = MagicMock()
|
||||
mixin.reset_game = MagicMock()
|
||||
parent = MagicMock()
|
||||
mixin._create_buttons(parent)
|
||||
|
||||
assert mock_tk.Frame.call_count >= 1
|
||||
btn_calls = mock_tk.Button.call_args_list
|
||||
assert len(btn_calls) == 2
|
||||
|
||||
start_kwargs = btn_calls[0][1]
|
||||
assert start_kwargs["text"] == "Start Round"
|
||||
assert start_kwargs["cursor"] == "hand2"
|
||||
assert hasattr(mixin, "start_button")
|
||||
|
||||
reset_kwargs = btn_calls[1][1]
|
||||
assert reset_kwargs["text"] == "Reset Game"
|
||||
assert reset_kwargs["cursor"] == "hand2"
|
||||
assert hasattr(mixin, "reset_button")
|
||||
|
||||
mixin.start_button.pack.assert_called_once()
|
||||
mixin.reset_button.pack.assert_called_once()
|
||||
|
||||
|
||||
class TestCreateStatisticsFrame:
|
||||
"""Tests for _create_statistics_frame."""
|
||||
|
||||
def test_creates_stats_labels(self) -> None:
|
||||
mixin, mock_tk, _ttk = _make_mixin()
|
||||
parent = MagicMock()
|
||||
mixin._create_statistics_frame(parent)
|
||||
|
||||
# 3 LabelFrames: rounds, modifiers, phase
|
||||
lf_calls = mock_tk.LabelFrame.call_args_list
|
||||
assert len(lf_calls) == 3
|
||||
lf_texts = [c[1]["text"] for c in lf_calls]
|
||||
assert "Rounds Played" in lf_texts
|
||||
assert "Modifiers Applied" in lf_texts
|
||||
assert "Game Phase" in lf_texts
|
||||
|
||||
assert hasattr(mixin, "rounds_label")
|
||||
assert hasattr(mixin, "mods_label")
|
||||
assert hasattr(mixin, "phase_label")
|
||||
|
||||
def test_stats_initial_values(self) -> None:
|
||||
mixin, mock_tk, _ttk = _make_mixin()
|
||||
parent = MagicMock()
|
||||
mixin._create_statistics_frame(parent)
|
||||
|
||||
label_calls = mock_tk.Label.call_args_list
|
||||
# Two "0" labels (rounds and mods) and one "Early" label
|
||||
zero_labels = [c for c in label_calls if c[1].get("text") == "0"]
|
||||
assert len(zero_labels) == 2
|
||||
early_labels = [c for c in label_calls if c[1].get("text") == "Early"]
|
||||
assert len(early_labels) == 1
|
||||
437
python_pkg/poker_modifier_app/tests/test_poker_modifier_app.py
Normal file
437
python_pkg/poker_modifier_app/tests/test_poker_modifier_app.py
Normal file
@ -0,0 +1,437 @@
|
||||
"""Tests for poker_modifier_app package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from python_pkg.poker_modifier_app._poker_modifiers import (
|
||||
ENDGAME_MODIFIERS,
|
||||
REGULAR_MODIFIERS,
|
||||
Modifier,
|
||||
)
|
||||
|
||||
|
||||
def _make_app() -> Any:
|
||||
"""Create a PokerModifierApp with setup_gui mocked out."""
|
||||
with patch(
|
||||
"python_pkg.poker_modifier_app.poker_modifier_app.PokerGuiMixin.setup_gui"
|
||||
):
|
||||
from python_pkg.poker_modifier_app.poker_modifier_app import PokerModifierApp
|
||||
|
||||
app = PokerModifierApp()
|
||||
# Provide mock GUI widgets used by logic methods
|
||||
app.root = MagicMock()
|
||||
app.prob_label = MagicMock()
|
||||
app.length_label = MagicMock()
|
||||
app.debug_var = MagicMock()
|
||||
app.force_endgame_button = MagicMock()
|
||||
app.start_button = MagicMock()
|
||||
app.rounds_label = MagicMock()
|
||||
app.phase_label = MagicMock()
|
||||
app.prob_var = MagicMock()
|
||||
app.mods_label = MagicMock()
|
||||
app.result_frame = MagicMock()
|
||||
app.result_label = MagicMock()
|
||||
return app
|
||||
|
||||
|
||||
class TestModifierData:
|
||||
"""Tests for _poker_modifiers module."""
|
||||
|
||||
def test_regular_modifiers_is_list(self) -> None:
|
||||
assert isinstance(REGULAR_MODIFIERS, list)
|
||||
assert len(REGULAR_MODIFIERS) > 0
|
||||
|
||||
def test_endgame_modifiers_is_list(self) -> None:
|
||||
assert isinstance(ENDGAME_MODIFIERS, list)
|
||||
assert len(ENDGAME_MODIFIERS) > 0
|
||||
|
||||
def test_modifier_structure(self) -> None:
|
||||
for mod in REGULAR_MODIFIERS + ENDGAME_MODIFIERS:
|
||||
assert "name" in mod
|
||||
assert "description" in mod
|
||||
|
||||
def test_modifier_type_alias(self) -> None:
|
||||
sample: Modifier = {"name": "test", "description": "test"}
|
||||
assert isinstance(sample, dict)
|
||||
|
||||
|
||||
class TestPokerModifierAppInit:
|
||||
"""Tests for PokerModifierApp initialization."""
|
||||
|
||||
def test_init_sets_defaults(self) -> None:
|
||||
app = _make_app()
|
||||
assert app.rounds_played == 0
|
||||
assert app.modifiers_applied == 0
|
||||
assert app.total_game_rounds == 20
|
||||
assert app.endgame_threshold == 0.8
|
||||
assert app.debug_mode is False
|
||||
assert app.force_endgame is False
|
||||
|
||||
def test_init_filters_endgame_from_regular(self) -> None:
|
||||
app = _make_app()
|
||||
endgame_names = {mod["name"] for mod in ENDGAME_MODIFIERS}
|
||||
regular_names = {mod["name"] for mod in app.modifiers}
|
||||
assert not regular_names.intersection(endgame_names)
|
||||
|
||||
def test_init_copies_modifier_lists(self) -> None:
|
||||
app = _make_app()
|
||||
assert app.modifiers is not REGULAR_MODIFIERS
|
||||
assert app.endgame_modifiers is not ENDGAME_MODIFIERS
|
||||
|
||||
|
||||
class TestUpdateDisplays:
|
||||
"""Tests for display update methods."""
|
||||
|
||||
def test_update_prob_display(self) -> None:
|
||||
app = _make_app()
|
||||
app.update_prob_display("50")
|
||||
app.prob_label.config.assert_called_once_with(text="50%")
|
||||
|
||||
def test_update_length_display(self) -> None:
|
||||
app = _make_app()
|
||||
app.update_length_display("30")
|
||||
app.length_label.config.assert_called_once_with(text="30")
|
||||
assert app.total_game_rounds == 30
|
||||
|
||||
|
||||
class TestToggleDebugMode:
|
||||
"""Tests for toggle_debug_mode."""
|
||||
|
||||
def test_enable_debug_mode(self) -> None:
|
||||
app = _make_app()
|
||||
app.debug_var.get.return_value = True
|
||||
app.toggle_debug_mode()
|
||||
assert app.debug_mode is True
|
||||
app.force_endgame_button.pack.assert_called_once()
|
||||
|
||||
def test_disable_debug_mode(self) -> None:
|
||||
app = _make_app()
|
||||
app.debug_var.get.return_value = False
|
||||
app.toggle_debug_mode()
|
||||
assert app.debug_mode is False
|
||||
assert app.force_endgame is False
|
||||
app.force_endgame_button.pack_forget.assert_called_once()
|
||||
|
||||
|
||||
class TestToggleForceEndgame:
|
||||
"""Tests for toggle_force_endgame."""
|
||||
|
||||
def test_toggle_on(self) -> None:
|
||||
app = _make_app()
|
||||
app.force_endgame = False
|
||||
app.toggle_force_endgame()
|
||||
assert app.force_endgame is True
|
||||
app.force_endgame_button.config.assert_called_once_with(
|
||||
text="Stop Force Endgame", bg="#4CAF50"
|
||||
)
|
||||
|
||||
def test_toggle_off(self) -> None:
|
||||
app = _make_app()
|
||||
app.force_endgame = True
|
||||
app.toggle_force_endgame()
|
||||
assert app.force_endgame is False
|
||||
app.force_endgame_button.config.assert_called_once_with(
|
||||
text="Force Endgame", bg="#ff6b6b"
|
||||
)
|
||||
|
||||
|
||||
class TestIsEndgame:
|
||||
"""Tests for is_endgame."""
|
||||
|
||||
def test_debug_force_endgame(self) -> None:
|
||||
app = _make_app()
|
||||
app.debug_mode = True
|
||||
app.force_endgame = True
|
||||
assert app.is_endgame() is True
|
||||
|
||||
def test_debug_no_force(self) -> None:
|
||||
app = _make_app()
|
||||
app.debug_mode = True
|
||||
app.force_endgame = False
|
||||
app.total_game_rounds = 20
|
||||
app.rounds_played = 0
|
||||
assert app.is_endgame() is False
|
||||
|
||||
def test_rounds_at_threshold(self) -> None:
|
||||
app = _make_app()
|
||||
app.total_game_rounds = 20
|
||||
app.endgame_threshold = 0.8
|
||||
app.rounds_played = 16 # exactly at 80%
|
||||
assert app.is_endgame() is True
|
||||
|
||||
def test_rounds_below_threshold(self) -> None:
|
||||
app = _make_app()
|
||||
app.total_game_rounds = 20
|
||||
app.endgame_threshold = 0.8
|
||||
app.rounds_played = 15
|
||||
assert app.is_endgame() is False
|
||||
|
||||
|
||||
class TestUpdatePhaseIndicator:
|
||||
"""Tests for update_phase_indicator - 4 branches."""
|
||||
|
||||
def test_endgame_phase(self) -> None:
|
||||
app = _make_app()
|
||||
app.debug_mode = True
|
||||
app.force_endgame = True
|
||||
app.update_phase_indicator()
|
||||
app.phase_label.config.assert_called_once_with(text="Endgame", fg="#ff6b6b")
|
||||
|
||||
def test_late_phase(self) -> None:
|
||||
app = _make_app()
|
||||
app.total_game_rounds = 20
|
||||
app.rounds_played = 12 # 60%
|
||||
app.update_phase_indicator()
|
||||
app.phase_label.config.assert_called_once_with(text="Late", fg="#ffa500")
|
||||
|
||||
def test_mid_phase(self) -> None:
|
||||
app = _make_app()
|
||||
app.total_game_rounds = 20
|
||||
app.rounds_played = 6 # 30%
|
||||
app.update_phase_indicator()
|
||||
app.phase_label.config.assert_called_once_with(text="Mid", fg="#ffeb3b")
|
||||
|
||||
def test_early_phase(self) -> None:
|
||||
app = _make_app()
|
||||
app.total_game_rounds = 20
|
||||
app.rounds_played = 1
|
||||
app.update_phase_indicator()
|
||||
app.phase_label.config.assert_called_once_with(text="Early", fg="#4CAF50")
|
||||
|
||||
|
||||
class TestStartRound:
|
||||
"""Tests for start_round."""
|
||||
|
||||
def test_start_round_with_modifier(self) -> None:
|
||||
app = _make_app()
|
||||
app.prob_var.get.return_value = 100
|
||||
with (
|
||||
patch.object(app, "apply_random_modifier") as mock_apply,
|
||||
patch.object(app, "update_phase_indicator"),
|
||||
patch("python_pkg.poker_modifier_app.poker_modifier_app._rng") as mock_rng,
|
||||
):
|
||||
mock_rng.random.return_value = 0.0 # 0 < 100
|
||||
app.start_round()
|
||||
mock_apply.assert_called_once()
|
||||
assert app.rounds_played == 1
|
||||
|
||||
def test_start_round_no_modifier(self) -> None:
|
||||
app = _make_app()
|
||||
app.prob_var.get.return_value = 0
|
||||
with (
|
||||
patch.object(app, "show_no_modifier") as mock_show,
|
||||
patch.object(app, "update_phase_indicator"),
|
||||
patch("python_pkg.poker_modifier_app.poker_modifier_app._rng") as mock_rng,
|
||||
):
|
||||
mock_rng.random.return_value = 0.5 # 50 >= 0
|
||||
app.start_round()
|
||||
mock_show.assert_called_once()
|
||||
|
||||
def test_start_round_button_animation(self) -> None:
|
||||
app = _make_app()
|
||||
app.prob_var.get.return_value = 0
|
||||
with (
|
||||
patch.object(app, "show_no_modifier"),
|
||||
patch.object(app, "update_phase_indicator"),
|
||||
patch("python_pkg.poker_modifier_app.poker_modifier_app._rng") as mock_rng,
|
||||
):
|
||||
mock_rng.random.return_value = 0.99
|
||||
app.start_round()
|
||||
app.start_button.config.assert_called()
|
||||
app.root.after.assert_called_once()
|
||||
|
||||
|
||||
class TestApplyRandomModifier:
|
||||
"""Tests for apply_random_modifier."""
|
||||
|
||||
def test_apply_normal_modifier(self) -> None:
|
||||
app = _make_app()
|
||||
app.modifiers = [{"name": "TestMod", "description": "Test desc"}]
|
||||
with patch("python_pkg.poker_modifier_app.poker_modifier_app._rng") as mock_rng:
|
||||
mock_rng.choice.return_value = {
|
||||
"name": "TestMod",
|
||||
"description": "Test desc",
|
||||
}
|
||||
app.apply_random_modifier()
|
||||
assert app.modifiers_applied == 1
|
||||
app.result_label.config.assert_called_once()
|
||||
call_kwargs = app.result_label.config.call_args[1]
|
||||
assert "TestMod" in call_kwargs["text"]
|
||||
assert call_kwargs["bg"] == "#2d4a2d"
|
||||
|
||||
def test_apply_endgame_modifier_rounds_left(self) -> None:
|
||||
app = _make_app()
|
||||
app.debug_mode = True
|
||||
app.force_endgame = True
|
||||
app.total_game_rounds = 20
|
||||
app.rounds_played = 17
|
||||
with patch("python_pkg.poker_modifier_app.poker_modifier_app._rng") as mock_rng:
|
||||
mock_rng.choice.return_value = {
|
||||
"name": "Final Boss",
|
||||
"description": "Last hand",
|
||||
}
|
||||
app.apply_random_modifier()
|
||||
call_kwargs = app.result_label.config.call_args[1]
|
||||
assert "ENDGAME" in call_kwargs["text"]
|
||||
assert "3 rounds left" in call_kwargs["text"]
|
||||
assert call_kwargs["bg"] == "#4a2d2d"
|
||||
|
||||
def test_apply_endgame_modifier_final_round(self) -> None:
|
||||
app = _make_app()
|
||||
app.debug_mode = True
|
||||
app.force_endgame = True
|
||||
app.total_game_rounds = 20
|
||||
app.rounds_played = 20
|
||||
with patch("python_pkg.poker_modifier_app.poker_modifier_app._rng") as mock_rng:
|
||||
mock_rng.choice.return_value = {
|
||||
"name": "Final Boss",
|
||||
"description": "Last hand",
|
||||
}
|
||||
app.apply_random_modifier()
|
||||
call_kwargs = app.result_label.config.call_args[1]
|
||||
assert "FINAL ROUND!" in call_kwargs["text"]
|
||||
|
||||
def test_apply_steel_cards_modifier(self) -> None:
|
||||
app = _make_app()
|
||||
app.modifiers = [
|
||||
{
|
||||
"name": "Steel Cards",
|
||||
"description": "Steel {steel_rank} cards!",
|
||||
}
|
||||
]
|
||||
with patch("python_pkg.poker_modifier_app.poker_modifier_app._rng") as mock_rng:
|
||||
mock_rng.choice.side_effect = [
|
||||
{"name": "Steel Cards", "description": "Steel {steel_rank} cards!"},
|
||||
"Ace",
|
||||
]
|
||||
app.apply_random_modifier()
|
||||
call_kwargs = app.result_label.config.call_args[1]
|
||||
assert "Ace" in call_kwargs["text"]
|
||||
|
||||
def test_apply_endgame_modifier_past_total(self) -> None:
|
||||
"""Rounds played exceeds total (rounds_left <= 0)."""
|
||||
app = _make_app()
|
||||
app.debug_mode = True
|
||||
app.force_endgame = True
|
||||
app.total_game_rounds = 20
|
||||
app.rounds_played = 25
|
||||
with patch("python_pkg.poker_modifier_app.poker_modifier_app._rng") as mock_rng:
|
||||
mock_rng.choice.return_value = {
|
||||
"name": "Final Boss",
|
||||
"description": "Last hand",
|
||||
}
|
||||
app.apply_random_modifier()
|
||||
call_kwargs = app.result_label.config.call_args[1]
|
||||
assert "FINAL ROUND!" in call_kwargs["text"]
|
||||
|
||||
|
||||
class TestShowNoModifier:
|
||||
"""Tests for show_no_modifier."""
|
||||
|
||||
def test_show_no_modifier(self) -> None:
|
||||
app = _make_app()
|
||||
app.show_no_modifier()
|
||||
app.result_frame.config.assert_called_once()
|
||||
app.result_label.config.assert_called_once()
|
||||
call_kwargs = app.result_label.config.call_args[1]
|
||||
assert "No modifier" in call_kwargs["text"]
|
||||
|
||||
|
||||
class TestResetGame:
|
||||
"""Tests for reset_game."""
|
||||
|
||||
def test_reset_game(self) -> None:
|
||||
app = _make_app()
|
||||
app.rounds_played = 10
|
||||
app.modifiers_applied = 5
|
||||
app.force_endgame = True
|
||||
app.debug_mode = False
|
||||
app.reset_game()
|
||||
assert app.rounds_played == 0
|
||||
assert app.modifiers_applied == 0
|
||||
assert app.force_endgame is False
|
||||
app.rounds_label.config.assert_called_with(text="0")
|
||||
app.mods_label.config.assert_called_with(text="0")
|
||||
|
||||
def test_reset_game_debug_mode_on(self) -> None:
|
||||
app = _make_app()
|
||||
app.debug_mode = True
|
||||
app.force_endgame = True
|
||||
app.reset_game()
|
||||
app.force_endgame_button.config.assert_called_with(
|
||||
text="Force Endgame", bg="#ff6b6b"
|
||||
)
|
||||
|
||||
|
||||
class TestAddModifier:
|
||||
"""Tests for add_modifier."""
|
||||
|
||||
def test_add_modifier(self) -> None:
|
||||
app = _make_app()
|
||||
initial_count = len(app.modifiers)
|
||||
app.add_modifier("New Mod", "New description")
|
||||
assert len(app.modifiers) == initial_count + 1
|
||||
assert app.modifiers[-1] == {
|
||||
"name": "New Mod",
|
||||
"description": "New description",
|
||||
}
|
||||
|
||||
|
||||
class TestGetStats:
|
||||
"""Tests for get_stats."""
|
||||
|
||||
def test_get_stats_no_rounds(self) -> None:
|
||||
app = _make_app()
|
||||
stats = app.get_stats()
|
||||
assert stats["rounds_played"] == 0
|
||||
assert stats["modifier_rate"] == 0
|
||||
assert stats["rounds_remaining"] == 20
|
||||
|
||||
def test_get_stats_with_rounds(self) -> None:
|
||||
app = _make_app()
|
||||
app.rounds_played = 10
|
||||
app.modifiers_applied = 3
|
||||
app.total_game_rounds = 20
|
||||
stats = app.get_stats()
|
||||
assert stats["rounds_played"] == 10
|
||||
assert stats["modifiers_applied"] == 3
|
||||
assert stats["modifier_rate"] == 30.0
|
||||
assert stats["rounds_remaining"] == 10
|
||||
assert stats["is_endgame"] is False
|
||||
|
||||
def test_get_stats_past_total(self) -> None:
|
||||
app = _make_app()
|
||||
app.rounds_played = 25
|
||||
app.total_game_rounds = 20
|
||||
stats = app.get_stats()
|
||||
assert stats["rounds_remaining"] == 0
|
||||
|
||||
|
||||
class TestRun:
|
||||
"""Tests for run method."""
|
||||
|
||||
def test_run(self) -> None:
|
||||
app = _make_app()
|
||||
app.run()
|
||||
app.root.mainloop.assert_called_once()
|
||||
|
||||
|
||||
class TestMainBlock:
|
||||
"""Test the if __name__ == '__main__' block."""
|
||||
|
||||
@patch("python_pkg.poker_modifier_app.poker_modifier_app.PokerGuiMixin.setup_gui")
|
||||
def test_main_block(self, _mock_setup: MagicMock) -> None:
|
||||
with patch(
|
||||
"python_pkg.poker_modifier_app.poker_modifier_app.PokerModifierApp.run"
|
||||
):
|
||||
import importlib
|
||||
|
||||
import python_pkg.poker_modifier_app.poker_modifier_app as mod
|
||||
|
||||
mod.__name__ = "__main__"
|
||||
importlib.reload(mod)
|
||||
# After reload with patched name, run should not be called
|
||||
# because __name__ is reset. Test the actual block via runpy.
|
||||
mod.__name__ = "python_pkg.poker_modifier_app.poker_modifier_app"
|
||||
@ -75,7 +75,7 @@ def _dijkstra_steps() -> list[CompositeVideoClip]:
|
||||
visited={"S", "A"},
|
||||
active_edge=("B", "A"),
|
||||
step_text=(
|
||||
"Zamknij A. Min=B(5). B→A: 5+1=6>2, " "nie zmieniaj. B→C: 5+6=11>5."
|
||||
"Zamknij A. Min=B(5). B→A: 5+1=6>2, nie zmieniaj. B→C: 5+6=11>5."
|
||||
),
|
||||
algo_name="Algorytm Dijkstry",
|
||||
),
|
||||
@ -88,7 +88,7 @@ def _dijkstra_steps() -> list[CompositeVideoClip]:
|
||||
current="C",
|
||||
visited={"S", "A", "B"},
|
||||
step_text=(
|
||||
"Zamknij B. Min=C(5). Koniec! " "Wynik: d={S:0, A:2, B:5, C:5}."
|
||||
"Zamknij B. Min=C(5). Koniec! Wynik: d={S:0, A:2, B:5, C:5}."
|
||||
),
|
||||
algo_name="Dijkstra -- WYNIK",
|
||||
),
|
||||
@ -119,7 +119,7 @@ def _bellman_ford_steps() -> list[CompositeVideoClip]:
|
||||
{"S": "0", "A": "2", "B": "5", "C": "5"},
|
||||
active_edge=("S", "A"),
|
||||
step_text=(
|
||||
"Iteracja 1: S→A:2, A→C:5, S→B:5. " "Potem B→A: 5+(-4)=1 < 2 → A=1!"
|
||||
"Iteracja 1: S→A:2, A→C:5, S→B:5. Potem B→A: 5+(-4)=1 < 2 → A=1!"
|
||||
),
|
||||
algo_name="Bellman-Ford -- iteracja 1",
|
||||
),
|
||||
@ -144,7 +144,7 @@ def _bellman_ford_steps() -> list[CompositeVideoClip]:
|
||||
{"S": "0", "A": "1", "B": "5", "C": "4"},
|
||||
active_edge=("A", "C"),
|
||||
step_text=(
|
||||
"Iteracja 2: A→C: 1+3=4 < 5 → C=4. " "Propagacja poprawionego A."
|
||||
"Iteracja 2: A→C: 1+3=4 < 5 → C=4. Propagacja poprawionego A."
|
||||
),
|
||||
algo_name="Bellman-Ford -- iteracja 2",
|
||||
),
|
||||
@ -188,9 +188,7 @@ def _astar_steps() -> list[CompositeVideoClip]:
|
||||
{"S": "0", "A": "2", "B": "5", "C": INF},
|
||||
current="S",
|
||||
active_edge=("S", "A"),
|
||||
step_text=(
|
||||
"Relaksuj S: A(g=2,f=2+3=5), " "B(g=5,f=5+4=9). Min f → A(5)."
|
||||
),
|
||||
step_text=("Relaksuj S: A(g=2,f=2+3=5), B(g=5,f=5+4=9). Min f → A(5)."),
|
||||
algo_name="A* -- rozwijanie S",
|
||||
),
|
||||
),
|
||||
@ -202,9 +200,7 @@ def _astar_steps() -> list[CompositeVideoClip]:
|
||||
current="A",
|
||||
visited={"S"},
|
||||
active_edge=("A", "C"),
|
||||
step_text=(
|
||||
"Rozwiń A(f=5): A→C: g=2+3=5, " "f=5+0=5. Min f → C(5) = CEL!"
|
||||
),
|
||||
step_text=("Rozwiń A(f=5): A→C: g=2+3=5, f=5+0=5. Min f → C(5) = CEL!"),
|
||||
algo_name="A* -- rozwijanie A",
|
||||
),
|
||||
),
|
||||
|
||||
@ -371,7 +371,7 @@ def _watershed_demo() -> list[CompositeVideoClip]:
|
||||
# Water fills below terrain surface
|
||||
fill_top = max(water_y, 0)
|
||||
fill_bot = min(t_y, oy)
|
||||
if fill_top < fill_bot:
|
||||
if fill_top < fill_bot: # pragma: no branch
|
||||
frame[fill_top:fill_bot, x : x + 1] = (70, 130, 220)
|
||||
|
||||
# Dam marker at ridge
|
||||
|
||||
@ -446,8 +446,7 @@ def _detr_demo() -> list[CompositeVideoClip]:
|
||||
(80, 580),
|
||||
),
|
||||
(
|
||||
"Metryki: mAP@0.5 (standard), mAP@0.5:0.95 (surowsza), "
|
||||
"IoU do dopasowania",
|
||||
"Metryki: mAP@0.5 (standard), mAP@0.5:0.95 (surowsza), IoU do dopasowania",
|
||||
15,
|
||||
"#78909C",
|
||||
FONT_R,
|
||||
|
||||
@ -35,7 +35,7 @@ def draw_behavior_tree() -> None:
|
||||
ax.set_ylim(0, 4.5)
|
||||
ax.axis("off")
|
||||
ax.set_title(
|
||||
"Behavior Tree: robot przenosz\u0105cy" " obiekt (pick-and-place)",
|
||||
"Behavior Tree: robot przenosz\u0105cy obiekt (pick-and-place)",
|
||||
fontsize=FS_TITLE,
|
||||
fontweight="bold",
|
||||
pad=10,
|
||||
@ -277,7 +277,7 @@ def draw_bdi_model() -> None:
|
||||
ax.set_ylim(0, 4)
|
||||
ax.axis("off")
|
||||
ax.set_title(
|
||||
"Model BDI agenta" " (Beliefs-Desires-Intentions)",
|
||||
"Model BDI agenta (Beliefs-Desires-Intentions)",
|
||||
fontsize=FS_TITLE,
|
||||
fontweight="bold",
|
||||
pad=10,
|
||||
|
||||
@ -36,7 +36,7 @@ def draw_see_think_act() -> None:
|
||||
ax.set_ylim(0, 4.5)
|
||||
ax.axis("off")
|
||||
ax.set_title(
|
||||
"Cykl agenta upostaciowionego:" " Percepcja \u2192 Deliberacja \u2192 Akcja",
|
||||
"Cykl agenta upostaciowionego: Percepcja \u2192 Deliberacja \u2192 Akcja",
|
||||
fontsize=FS_TITLE,
|
||||
fontweight="bold",
|
||||
pad=10,
|
||||
@ -57,7 +57,7 @@ def draw_see_think_act() -> None:
|
||||
ax.text(
|
||||
3.5,
|
||||
0.7,
|
||||
"\u015aRODOWISKO FIZYCZNE\n" "(przeszkody, obiekty, ludzie)",
|
||||
"\u015aRODOWISKO FIZYCZNE\n(przeszkody, obiekty, ludzie)",
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=FS,
|
||||
@ -220,7 +220,7 @@ def draw_3t_architecture() -> None:
|
||||
ax.set_ylim(0, 5.5)
|
||||
ax.axis("off")
|
||||
ax.set_title(
|
||||
"Architektura 3T sterownika robota" " (3-Layer Architecture)",
|
||||
"Architektura 3T sterownika robota (3-Layer Architecture)",
|
||||
fontsize=FS_TITLE,
|
||||
fontweight="bold",
|
||||
pad=10,
|
||||
|
||||
@ -183,7 +183,7 @@ def _draw_c4_container(ax2: Axes) -> None:
|
||||
ax2.text(
|
||||
50,
|
||||
8,
|
||||
"Jakie kontenery techniczne\n" "sk\u0142adaj\u0105 si\u0119 na system?",
|
||||
"Jakie kontenery techniczne\nsk\u0142adaj\u0105 si\u0119 na system?",
|
||||
ha="center",
|
||||
fontsize=7,
|
||||
fontstyle="italic",
|
||||
@ -249,7 +249,7 @@ def _draw_c4_component(ax3: Axes) -> None:
|
||||
ax3.text(
|
||||
50,
|
||||
8,
|
||||
"Jakie modu\u0142y/komponenty\n" "wewn\u0105trz kontenera?",
|
||||
"Jakie modu\u0142y/komponenty\nwewn\u0105trz kontenera?",
|
||||
ha="center",
|
||||
fontsize=7,
|
||||
fontstyle="italic",
|
||||
@ -321,7 +321,7 @@ def _draw_c4_code(ax4: Axes) -> None:
|
||||
ax4.text(
|
||||
50,
|
||||
3,
|
||||
"Diagramy klas UML\n" "(opcjonalny poziom szczeg\u00f3\u0142owo\u015bci)",
|
||||
"Diagramy klas UML\n(opcjonalny poziom szczeg\u00f3\u0142owo\u015bci)",
|
||||
ha="center",
|
||||
fontsize=7,
|
||||
fontstyle="italic",
|
||||
|
||||
@ -46,7 +46,7 @@ def draw_fa_recognition() -> None:
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
ax.set_title(
|
||||
"DFA — diagram stanów\n" 'L = {słowa nad {a,b} kończące się na "ab"}',
|
||||
'DFA — diagram stanów\nL = {słowa nad {a,b} kończące się na "ab"}',
|
||||
fontsize=FS_TITLE,
|
||||
fontweight="bold",
|
||||
pad=10,
|
||||
|
||||
@ -99,7 +99,7 @@ def draw_lba_recognition() -> None:
|
||||
fontsize=HEAD_MARKER_FONTSIZE,
|
||||
color="black",
|
||||
)
|
||||
if step_label:
|
||||
if step_label: # pragma: no branch
|
||||
sx = tape_x0 + 6 * cell_w + 0.5
|
||||
ax.text(
|
||||
sx,
|
||||
@ -255,7 +255,7 @@ def draw_lba_recognition() -> None:
|
||||
ax.text(
|
||||
tape_x0 + 3 * cell_w,
|
||||
tape_y + 0.3,
|
||||
"Wszystko zaznaczone → q_acc" ' → "aabbcc" AKCEPTOWANE ✓',
|
||||
'Wszystko zaznaczone → q_acc → "aabbcc" AKCEPTOWANE ✓',
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=FS + 1,
|
||||
@ -271,7 +271,7 @@ def draw_lba_recognition() -> None:
|
||||
ax.text(
|
||||
tape_x0 + 6 * cell_w + 0.5,
|
||||
tape_y + 0.3,
|
||||
"Ograniczenie LBA:\n" "głowica ≤ 6 komórek\n" '(= |w| = |"aabbcc"|)',
|
||||
'Ograniczenie LBA:\ngłowica ≤ 6 komórek\n(= |w| = |"aabbcc"|)',
|
||||
ha="left",
|
||||
va="center",
|
||||
fontsize=FS_SMALL,
|
||||
|
||||
@ -138,7 +138,7 @@ def draw_pda_recognition() -> None:
|
||||
ax2 = axes[1]
|
||||
ax2.axis("off")
|
||||
ax2.set_title(
|
||||
"Ślad wykonania z wizualizacją stosu" ' — wejście: "aabb"',
|
||||
'Ślad wykonania z wizualizacją stosu — wejście: "aabb"',
|
||||
fontsize=FS_TITLE,
|
||||
fontweight="bold",
|
||||
pad=10,
|
||||
|
||||
@ -117,7 +117,7 @@ def draw_tm_recognition() -> None:
|
||||
fontsize=HEAD_MARKER_FONTSIZE,
|
||||
color="black",
|
||||
)
|
||||
if step_label:
|
||||
if step_label: # pragma: no branch
|
||||
sx = tape_x0 + 8 * cell_w + 0.8
|
||||
ax.text(
|
||||
sx,
|
||||
|
||||
@ -82,7 +82,7 @@ def generate_bf_negative_weights() -> None:
|
||||
draw_neg_graph(
|
||||
ax1,
|
||||
NEG_EDGES,
|
||||
title=("Graf z ujemną wagą\n" "(B→A = -4, zaznaczona na czerwono)"),
|
||||
title=("Graf z ujemną wagą\n(B→A = -4, zaznaczona na czerwono)"),
|
||||
dist={"S": "0", "A": "?", "B": "?", "C": "?"},
|
||||
)
|
||||
ax1.annotate(
|
||||
@ -106,7 +106,7 @@ def generate_bf_negative_weights() -> None:
|
||||
ax2,
|
||||
NEG_EDGES,
|
||||
title=(
|
||||
"Dijkstra \u2014 BŁĘDNY wynik\n" "A zamknięty z d=2, nie poprawia przy B→A"
|
||||
"Dijkstra \u2014 BŁĘDNY wynik\nA zamknięty z d=2, nie poprawia przy B→A"
|
||||
),
|
||||
dist={"S": "0", "A": "2", "B": "5", "C": "5"},
|
||||
visited={"S", "A", "B", "C"},
|
||||
@ -135,8 +135,7 @@ def generate_bf_negative_weights() -> None:
|
||||
ax3,
|
||||
NEG_EDGES,
|
||||
title=(
|
||||
"Bellman-Ford \u2014 POPRAWNY wynik\n"
|
||||
"Ujemna waga B→A poprawnie propagowana"
|
||||
"Bellman-Ford \u2014 POPRAWNY wynik\nUjemna waga B→A poprawnie propagowana"
|
||||
),
|
||||
dist={"S": "0", "A": "1", "B": "5", "C": "4"},
|
||||
visited={"S", "A", "B", "C"},
|
||||
@ -162,7 +161,7 @@ def generate_bf_negative_weights() -> None:
|
||||
# Row 2: B-F iterations step by step
|
||||
iterations = [
|
||||
{
|
||||
"title": ("B-F Iteracja 1\n" "Relaksuj WSZYSTKIE krawędzie"),
|
||||
"title": ("B-F Iteracja 1\nRelaksuj WSZYSTKIE krawędzie"),
|
||||
"dist": {
|
||||
"S": "0",
|
||||
"A": "1",
|
||||
@ -183,7 +182,7 @@ def generate_bf_negative_weights() -> None:
|
||||
),
|
||||
},
|
||||
{
|
||||
"title": ("B-F Iteracja 2\n" "Propagacja poprawionego A"),
|
||||
"title": ("B-F Iteracja 2\nPropagacja poprawionego A"),
|
||||
"dist": {
|
||||
"S": "0",
|
||||
"A": "1",
|
||||
@ -192,14 +191,11 @@ def generate_bf_negative_weights() -> None:
|
||||
},
|
||||
"relaxed": {("A", "C")},
|
||||
"detail": (
|
||||
"S→A: 0+2=2>1 ✗\n"
|
||||
"A→C: 1+3=4<5 → C=4 ✓\n"
|
||||
"S→B: 0+5=5=5 ✗\n"
|
||||
"B→A: 5-4=1=1 ✗"
|
||||
"S→A: 0+2=2>1 ✗\nA→C: 1+3=4<5 → C=4 ✓\nS→B: 0+5=5=5 ✗\nB→A: 5-4=1=1 ✗"
|
||||
),
|
||||
},
|
||||
{
|
||||
"title": ("B-F Iteracja 3\n" "Brak zmian → stabilne!"),
|
||||
"title": ("B-F Iteracja 3\nBrak zmian → stabilne!"),
|
||||
"dist": {
|
||||
"S": "0",
|
||||
"A": "1",
|
||||
@ -293,7 +289,7 @@ def generate_bf_negative_cycle() -> None:
|
||||
draw_neg_graph(
|
||||
ax1,
|
||||
NEG_EDGES,
|
||||
title=("Graf z cyklem ujemnym\n" "Dodana krawędź C→B(-3) \u2014 przerywana"),
|
||||
title=("Graf z cyklem ujemnym\nDodana krawędź C→B(-3) \u2014 przerywana"),
|
||||
dist={"S": "0", "A": "?", "B": "?", "C": "?"},
|
||||
extra_edges=[("C", "B", -3)],
|
||||
)
|
||||
@ -318,7 +314,7 @@ def generate_bf_negative_cycle() -> None:
|
||||
draw_neg_graph(
|
||||
ax2,
|
||||
NEG_EDGES,
|
||||
title=("Po V-1=3 iteracjach\n" "dist wciąż maleje (niestabilne!)"),
|
||||
title=("Po V-1=3 iteracjach\ndist wciąż maleje (niestabilne!)"),
|
||||
dist={"S": "0", "A": "-7", "B": "-4", "C": "-4"},
|
||||
visited={"S", "A", "B", "C"},
|
||||
error_nodes={"A", "B", "C"},
|
||||
@ -327,7 +323,7 @@ def generate_bf_negative_cycle() -> None:
|
||||
ax2.text(
|
||||
3.2,
|
||||
-0.4,
|
||||
"Każde okrążenie cyklu\n" "zmniejsza dist o 4.\n" "Dist → -∞ (brak minimum!)",
|
||||
"Każde okrążenie cyklu\nzmniejsza dist o 4.\nDist → -∞ (brak minimum!)",
|
||||
ha="center",
|
||||
va="top",
|
||||
fontsize=FS_SMALL,
|
||||
@ -377,8 +373,7 @@ def generate_bf_negative_cycle() -> None:
|
||||
},
|
||||
)
|
||||
ax3.set_title(
|
||||
"Wykrywanie \u2014 V-ta iteracja\n"
|
||||
"Jeśli cokolwiek się poprawia → cykl ujemny!",
|
||||
"Wykrywanie \u2014 V-ta iteracja\nJeśli cokolwiek się poprawia → cykl ujemny!",
|
||||
fontsize=FS,
|
||||
fontweight="bold",
|
||||
pad=5,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user