diff --git a/python_pkg/anki_decks/conftest.py b/python_pkg/anki_decks/conftest.py new file mode 100644 index 0000000..9110853 --- /dev/null +++ b/python_pkg/anki_decks/conftest.py @@ -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)) diff --git a/python_pkg/anki_decks/polish_coastal_features/tests/__init__.py b/python_pkg/anki_decks/polish_coastal_features/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_coastal_features/tests/test_polish_coastal_features_anki.py b/python_pkg/anki_decks/polish_coastal_features/tests/test_polish_coastal_features_anki.py new file mode 100644 index 0000000..dc34466 --- /dev/null +++ b/python_pkg/anki_decks/polish_coastal_features/tests/test_polish_coastal_features_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_forests/polish_forests_anki.py b/python_pkg/anki_decks/polish_forests/polish_forests_anki.py index 76cfb7e..135f110 100644 --- a/python_pkg/anki_decks/polish_forests/polish_forests_anki.py +++ b/python_pkg/anki_decks/polish_forests/polish_forests_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/polish_forests/tests/__init__.py b/python_pkg/anki_decks/polish_forests/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_forests/tests/test_polish_forests_anki.py b/python_pkg/anki_decks/polish_forests/tests/test_polish_forests_anki.py new file mode 100644 index 0000000..db76710 --- /dev/null +++ b/python_pkg/anki_decks/polish_forests/tests/test_polish_forests_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_gminy/polish_gminy_anki.py b/python_pkg/anki_decks/polish_gminy/polish_gminy_anki.py index a69def3..c919807 100755 --- a/python_pkg/anki_decks/polish_gminy/polish_gminy_anki.py +++ b/python_pkg/anki_decks/polish_gminy/polish_gminy_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/polish_gminy/tests/__init__.py b/python_pkg/anki_decks/polish_gminy/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_gminy/tests/test_polish_gminy_anki.py b/python_pkg/anki_decks/polish_gminy/tests/test_polish_gminy_anki.py new file mode 100644 index 0000000..cd2e5bd --- /dev/null +++ b/python_pkg/anki_decks/polish_gminy/tests/test_polish_gminy_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_islands/polish_islands_anki.py b/python_pkg/anki_decks/polish_islands/polish_islands_anki.py index 9b6d0aa..8eda1f0 100644 --- a/python_pkg/anki_decks/polish_islands/polish_islands_anki.py +++ b/python_pkg/anki_decks/polish_islands/polish_islands_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/polish_islands/tests/__init__.py b/python_pkg/anki_decks/polish_islands/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_islands/tests/test_polish_islands_anki.py b/python_pkg/anki_decks/polish_islands/tests/test_polish_islands_anki.py new file mode 100644 index 0000000..096d41e --- /dev/null +++ b/python_pkg/anki_decks/polish_islands/tests/test_polish_islands_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_lakes/polish_lakes_anki.py b/python_pkg/anki_decks/polish_lakes/polish_lakes_anki.py index 018fba2..357fab2 100644 --- a/python_pkg/anki_decks/polish_lakes/polish_lakes_anki.py +++ b/python_pkg/anki_decks/polish_lakes/polish_lakes_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/polish_lakes/tests/__init__.py b/python_pkg/anki_decks/polish_lakes/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_lakes/tests/test_polish_lakes_anki.py b/python_pkg/anki_decks/polish_lakes/tests/test_polish_lakes_anki.py new file mode 100644 index 0000000..602de2e --- /dev/null +++ b/python_pkg/anki_decks/polish_lakes/tests/test_polish_lakes_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_landscape_parks/polish_landscape_parks_anki.py b/python_pkg/anki_decks/polish_landscape_parks/polish_landscape_parks_anki.py index 16849db..d0b555e 100644 --- a/python_pkg/anki_decks/polish_landscape_parks/polish_landscape_parks_anki.py +++ b/python_pkg/anki_decks/polish_landscape_parks/polish_landscape_parks_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/polish_landscape_parks/tests/__init__.py b/python_pkg/anki_decks/polish_landscape_parks/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_landscape_parks/tests/test_polish_landscape_parks_anki.py b/python_pkg/anki_decks/polish_landscape_parks/tests/test_polish_landscape_parks_anki.py new file mode 100644 index 0000000..48af5c6 --- /dev/null +++ b/python_pkg/anki_decks/polish_landscape_parks/tests/test_polish_landscape_parks_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_license_plates/fetch_license_plates.py b/python_pkg/anki_decks/polish_license_plates/fetch_license_plates.py index 7f8fccb..ddf13bb 100755 --- a/python_pkg/anki_decks/polish_license_plates/fetch_license_plates.py +++ b/python_pkg/anki_decks/polish_license_plates/fetch_license_plates.py @@ -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") diff --git a/python_pkg/anki_decks/polish_license_plates/tests/test_fetch_license_plates.py b/python_pkg/anki_decks/polish_license_plates/tests/test_fetch_license_plates.py new file mode 100644 index 0000000..4a18c5f --- /dev/null +++ b/python_pkg/anki_decks/polish_license_plates/tests/test_fetch_license_plates.py @@ -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("cached") + mock_cache_path.return_value = cache_file + + result = fetch_wikipedia_html() + assert result == "cached" + + @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 = "fresh" + mock_get.return_value = mock_response + + result = fetch_wikipedia_html() + assert result == "fresh" + 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 = "wikipedia" + mock_get.return_value = mock_response + + result = fetch_wikipedia_html() + assert result == "wikipedia" + # Should have written cache + assert cache_file.read_text() == "wikipedia" + + @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 = "forced" + mock_get.return_value = mock_response + + result = fetch_wikipedia_html(force_refresh=True) + assert result == "forced" + + @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 = "forced fresh" + mock_get.return_value = mock_response + + result = fetch_wikipedia_html(force_refresh=True) + assert result == "forced fresh" + 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 = "data" + mock_get.return_value = mock_response + + result = fetch_wikipedia_html() + assert result == "data" + + +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 = "

No tables here

" + 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 = """ + + + + + +
CodeLocation
WAWarszawa
KRKraków
+ + """ + 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 = """ + + + + + +
CodeLocation
Only one cell
WAWarszawa
+ + """ + 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 = """ + + + + + +
CodeLocation
123Some place
WAWarszawa
+ + """ + 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 = """ + + + + + +
CodeLocation
ABCDEToo long code
WAWarszawa
+ + """ + 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 = """ + + + + + +
CodeLocation
WA
KRKraków
+ + """ + 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 = """ + + + + +
CodeLocation
WAWarszawa[1][23]
+ + """ + 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 = """ + + + + +
CodeLocation
WA Warszawa city
+ + """ + 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 = """ + + + + +
CodeLocation
WAWarszawa
+ + + +
CodeLocation
KRKraków
+ + """ + 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 = """ + + + + +
CodeLocation
waWarszawa
+ + """ + 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 = """ + + + + +
CodeLocation
W-A 1Warszawa
+ + """ + 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 = """ + + + + +
CodeLocation
12345Numbers only
+ + """ + result = parse_license_plates_from_html(html) + assert result == {} diff --git a/python_pkg/anki_decks/polish_license_plates/tests/test_fetch_license_plates_part2.py b/python_pkg/anki_decks/polish_license_plates/tests/test_fetch_license_plates_part2.py new file mode 100644 index 0000000..7fe9268 --- /dev/null +++ b/python_pkg/anki_decks/polish_license_plates/tests/test_fetch_license_plates_part2.py @@ -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="") + 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("") + + @patch(f"{MOD}.parse_license_plates_from_html", return_value={"KR": "Kraków"}) + @patch(f"{MOD}.fetch_wikipedia_html", return_value="") + 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 diff --git a/python_pkg/anki_decks/polish_license_plates/tests/test_polish_license_plates_anki.py b/python_pkg/anki_decks/polish_license_plates/tests/test_polish_license_plates_anki.py index 0742325..6153fe3 100644 --- a/python_pkg/anki_decks/polish_license_plates/tests/test_polish_license_plates_anki.py +++ b/python_pkg/anki_decks/polish_license_plates/tests/test_polish_license_plates_anki.py @@ -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"]) diff --git a/python_pkg/anki_decks/polish_mountain_peaks/polish_mountain_peaks_anki.py b/python_pkg/anki_decks/polish_mountain_peaks/polish_mountain_peaks_anki.py index 43102d2..b372fb7 100644 --- a/python_pkg/anki_decks/polish_mountain_peaks/polish_mountain_peaks_anki.py +++ b/python_pkg/anki_decks/polish_mountain_peaks/polish_mountain_peaks_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/polish_mountain_peaks/tests/__init__.py b/python_pkg/anki_decks/polish_mountain_peaks/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_mountain_peaks/tests/test_polish_mountain_peaks_anki.py b/python_pkg/anki_decks/polish_mountain_peaks/tests/test_polish_mountain_peaks_anki.py new file mode 100644 index 0000000..9f18fff --- /dev/null +++ b/python_pkg/anki_decks/polish_mountain_peaks/tests/test_polish_mountain_peaks_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_mountain_ranges/polish_mountain_ranges_anki.py b/python_pkg/anki_decks/polish_mountain_ranges/polish_mountain_ranges_anki.py index 93060a8..ee1fb66 100644 --- a/python_pkg/anki_decks/polish_mountain_ranges/polish_mountain_ranges_anki.py +++ b/python_pkg/anki_decks/polish_mountain_ranges/polish_mountain_ranges_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/polish_mountain_ranges/tests/__init__.py b/python_pkg/anki_decks/polish_mountain_ranges/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_mountain_ranges/tests/test_polish_mountain_ranges_anki.py b/python_pkg/anki_decks/polish_mountain_ranges/tests/test_polish_mountain_ranges_anki.py new file mode 100644 index 0000000..34c409e --- /dev/null +++ b/python_pkg/anki_decks/polish_mountain_ranges/tests/test_polish_mountain_ranges_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_national_parks/polish_national_parks_anki.py b/python_pkg/anki_decks/polish_national_parks/polish_national_parks_anki.py index 2d5fbae..0f95637 100644 --- a/python_pkg/anki_decks/polish_national_parks/polish_national_parks_anki.py +++ b/python_pkg/anki_decks/polish_national_parks/polish_national_parks_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/polish_national_parks/tests/__init__.py b/python_pkg/anki_decks/polish_national_parks/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_national_parks/tests/test_polish_national_parks_anki.py b/python_pkg/anki_decks/polish_national_parks/tests/test_polish_national_parks_anki.py new file mode 100644 index 0000000..2a3e962 --- /dev/null +++ b/python_pkg/anki_decks/polish_national_parks/tests/test_polish_national_parks_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_nature_reserves/tests/__init__.py b/python_pkg/anki_decks/polish_nature_reserves/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_nature_reserves/tests/test_polish_nature_reserves_anki.py b/python_pkg/anki_decks/polish_nature_reserves/tests/test_polish_nature_reserves_anki.py new file mode 100644 index 0000000..583fcca --- /dev/null +++ b/python_pkg/anki_decks/polish_nature_reserves/tests/test_polish_nature_reserves_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_powiaty/polish_powiaty_anki.py b/python_pkg/anki_decks/polish_powiaty/polish_powiaty_anki.py index 1c51544..6faa872 100755 --- a/python_pkg/anki_decks/polish_powiaty/polish_powiaty_anki.py +++ b/python_pkg/anki_decks/polish_powiaty/polish_powiaty_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/polish_powiaty/tests/__init__.py b/python_pkg/anki_decks/polish_powiaty/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_powiaty/tests/test_polish_powiaty_anki.py b/python_pkg/anki_decks/polish_powiaty/tests/test_polish_powiaty_anki.py new file mode 100644 index 0000000..d8fd610 --- /dev/null +++ b/python_pkg/anki_decks/polish_powiaty/tests/test_polish_powiaty_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_rivers/polish_rivers_anki.py b/python_pkg/anki_decks/polish_rivers/polish_rivers_anki.py index 1491888..bc9fab8 100644 --- a/python_pkg/anki_decks/polish_rivers/polish_rivers_anki.py +++ b/python_pkg/anki_decks/polish_rivers/polish_rivers_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/polish_rivers/tests/__init__.py b/python_pkg/anki_decks/polish_rivers/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_rivers/tests/test_polish_rivers_anki.py b/python_pkg/anki_decks/polish_rivers/tests/test_polish_rivers_anki.py new file mode 100644 index 0000000..dffa0b8 --- /dev/null +++ b/python_pkg/anki_decks/polish_rivers/tests/test_polish_rivers_anki.py @@ -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 diff --git a/python_pkg/anki_decks/polish_unesco_sites/polish_unesco_sites_anki.py b/python_pkg/anki_decks/polish_unesco_sites/polish_unesco_sites_anki.py index 5f8450a..4852a66 100644 --- a/python_pkg/anki_decks/polish_unesco_sites/polish_unesco_sites_anki.py +++ b/python_pkg/anki_decks/polish_unesco_sites/polish_unesco_sites_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/polish_unesco_sites/tests/__init__.py b/python_pkg/anki_decks/polish_unesco_sites/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/polish_unesco_sites/tests/test_polish_unesco_sites_anki.py b/python_pkg/anki_decks/polish_unesco_sites/tests/test_polish_unesco_sites_anki.py new file mode 100644 index 0000000..9ff2e81 --- /dev/null +++ b/python_pkg/anki_decks/polish_unesco_sites/tests/test_polish_unesco_sites_anki.py @@ -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 diff --git a/python_pkg/anki_decks/warsaw_bridges/tests/__init__.py b/python_pkg/anki_decks/warsaw_bridges/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/warsaw_bridges/tests/test_warsaw_bridges_anki.py b/python_pkg/anki_decks/warsaw_bridges/tests/test_warsaw_bridges_anki.py new file mode 100644 index 0000000..daa5d95 --- /dev/null +++ b/python_pkg/anki_decks/warsaw_bridges/tests/test_warsaw_bridges_anki.py @@ -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 diff --git a/python_pkg/anki_decks/warsaw_bridges/warsaw_bridges_anki.py b/python_pkg/anki_decks/warsaw_bridges/warsaw_bridges_anki.py index 3e4a81c..58a45b3 100755 --- a/python_pkg/anki_decks/warsaw_bridges/warsaw_bridges_anki.py +++ b/python_pkg/anki_decks/warsaw_bridges/warsaw_bridges_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/warsaw_districts/tests/test_warsaw_districts_anki.py b/python_pkg/anki_decks/warsaw_districts/tests/test_warsaw_districts_anki.py index c1592e5..bc134b5 100644 --- a/python_pkg/anki_decks/warsaw_districts/tests/test_warsaw_districts_anki.py +++ b/python_pkg/anki_decks/warsaw_districts/tests/test_warsaw_districts_anki.py @@ -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"]) diff --git a/python_pkg/anki_decks/warsaw_landmarks/tests/__init__.py b/python_pkg/anki_decks/warsaw_landmarks/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/warsaw_landmarks/tests/test_warsaw_landmarks_anki.py b/python_pkg/anki_decks/warsaw_landmarks/tests/test_warsaw_landmarks_anki.py new file mode 100644 index 0000000..b4f1611 --- /dev/null +++ b/python_pkg/anki_decks/warsaw_landmarks/tests/test_warsaw_landmarks_anki.py @@ -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 diff --git a/python_pkg/anki_decks/warsaw_metro/tests/__init__.py b/python_pkg/anki_decks/warsaw_metro/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/warsaw_metro/tests/test_warsaw_metro_anki.py b/python_pkg/anki_decks/warsaw_metro/tests/test_warsaw_metro_anki.py new file mode 100644 index 0000000..1dc56a9 --- /dev/null +++ b/python_pkg/anki_decks/warsaw_metro/tests/test_warsaw_metro_anki.py @@ -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 diff --git a/python_pkg/anki_decks/warsaw_osiedla/tests/__init__.py b/python_pkg/anki_decks/warsaw_osiedla/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/warsaw_osiedla/tests/test_warsaw_osiedla_anki.py b/python_pkg/anki_decks/warsaw_osiedla/tests/test_warsaw_osiedla_anki.py new file mode 100644 index 0000000..6a5a8b6 --- /dev/null +++ b/python_pkg/anki_decks/warsaw_osiedla/tests/test_warsaw_osiedla_anki.py @@ -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 diff --git a/python_pkg/anki_decks/warsaw_osiedla/warsaw_osiedla_anki.py b/python_pkg/anki_decks/warsaw_osiedla/warsaw_osiedla_anki.py index 15a3a5e..2189d30 100755 --- a/python_pkg/anki_decks/warsaw_osiedla/warsaw_osiedla_anki.py +++ b/python_pkg/anki_decks/warsaw_osiedla/warsaw_osiedla_anki.py @@ -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"] diff --git a/python_pkg/anki_decks/warsaw_streets/tests/__init__.py b/python_pkg/anki_decks/warsaw_streets/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/anki_decks/warsaw_streets/tests/test_warsaw_streets_anki.py b/python_pkg/anki_decks/warsaw_streets/tests/test_warsaw_streets_anki.py new file mode 100644 index 0000000..7504f3f --- /dev/null +++ b/python_pkg/anki_decks/warsaw_streets/tests/test_warsaw_streets_anki.py @@ -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 diff --git a/python_pkg/anki_decks/warsaw_streets/warsaw_streets_anki.py b/python_pkg/anki_decks/warsaw_streets/warsaw_streets_anki.py index 8ce8b3f..cdfa0d2 100755 --- a/python_pkg/anki_decks/warsaw_streets/warsaw_streets_anki.py +++ b/python_pkg/anki_decks/warsaw_streets/warsaw_streets_anki.py @@ -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: diff --git a/python_pkg/articles/tests/__init__.py b/python_pkg/articles/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/articles/test_server_api.py b/python_pkg/articles/tests/test_server_api.py similarity index 97% rename from python_pkg/articles/test_server_api.py rename to python_pkg/articles/tests/test_server_api.py index c27131f..aee342a 100644 --- a/python_pkg/articles/test_server_api.py +++ b/python_pkg/articles/tests/test_server_api.py @@ -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() diff --git a/python_pkg/articles/test_site_size.py b/python_pkg/articles/tests/test_site_size.py similarity index 94% rename from python_pkg/articles/test_site_size.py rename to python_pkg/articles/tests/test_site_size.py index 31b17dd..4f5cb8b 100644 --- a/python_pkg/articles/test_site_size.py +++ b/python_pkg/articles/tests/test_site_size.py @@ -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" diff --git a/python_pkg/brightness_controller/tests/__init__.py b/python_pkg/brightness_controller/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/brightness_controller/tests/test_auto_brightness_daemon.py b/python_pkg/brightness_controller/tests/test_auto_brightness_daemon.py new file mode 100644 index 0000000..a10fd78 --- /dev/null +++ b/python_pkg/brightness_controller/tests/test_auto_brightness_daemon.py @@ -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 ───────────────────────────────────────────────────────────────── diff --git a/python_pkg/brightness_controller/tests/test_auto_brightness_daemon_part2.py b/python_pkg/brightness_controller/tests/test_auto_brightness_daemon_part2.py new file mode 100644 index 0000000..78f2656 --- /dev/null +++ b/python_pkg/brightness_controller/tests/test_auto_brightness_daemon_part2.py @@ -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() diff --git a/python_pkg/brightness_controller/tests/test_brightness_controller.py b/python_pkg/brightness_controller/tests/test_brightness_controller.py new file mode 100644 index 0000000..274761f --- /dev/null +++ b/python_pkg/brightness_controller/tests/test_brightness_controller.py @@ -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() diff --git a/python_pkg/brightness_controller/tests/test_brightness_controller_part2.py b/python_pkg/brightness_controller/tests/test_brightness_controller_part2.py new file mode 100644 index 0000000..3c67bc2 --- /dev/null +++ b/python_pkg/brightness_controller/tests/test_brightness_controller_part2.py @@ -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() diff --git a/python_pkg/brightness_controller/tests/test_brightness_controller_part3.py b/python_pkg/brightness_controller/tests/test_brightness_controller_part3.py new file mode 100644 index 0000000..302c18b --- /dev/null +++ b/python_pkg/brightness_controller/tests/test_brightness_controller_part3.py @@ -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" diff --git a/python_pkg/cinema_planner/_cinema_scheduling.py b/python_pkg/cinema_planner/_cinema_scheduling.py index 5771541..30375ab 100644 --- a/python_pkg/cinema_planner/_cinema_scheduling.py +++ b/python_pkg/cinema_planner/_cinema_scheduling.py @@ -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") diff --git a/python_pkg/cinema_planner/cinema_planner.py b/python_pkg/cinema_planner/cinema_planner.py index 4f2afab..56cca30 100755 --- a/python_pkg/cinema_planner/cinema_planner.py +++ b/python_pkg/cinema_planner/cinema_planner.py @@ -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) diff --git a/python_pkg/cinema_planner/tests/__init__.py b/python_pkg/cinema_planner/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/cinema_planner/tests/test_cinema_parsing.py b/python_pkg/cinema_planner/tests/test_cinema_parsing.py new file mode 100644 index 0000000..b7131e8 --- /dev/null +++ b/python_pkg/cinema_planner/tests/test_cinema_parsing.py @@ -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'{genre}x' + times_html = "".join( + f'' for t in times + ) + return ( + f'class="row movie-row">' + f'{name}' + f"{genre_html}" + f"{duration} min" + 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">' + 'Movie' + "no duration here" + '' + ) + 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">' + 'Movie' + "100 min" + ) + 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">' + 'Movie' + "100 min" + "> 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">' + 'Movie' + "100 min" + '' + ) + 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" diff --git a/python_pkg/cinema_planner/tests/test_cinema_planner.py b/python_pkg/cinema_planner/tests/test_cinema_planner.py new file mode 100644 index 0000000..aca12b6 --- /dev/null +++ b/python_pkg/cinema_planner/tests/test_cinema_planner.py @@ -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() diff --git a/python_pkg/cinema_planner/tests/test_cinema_scheduling.py b/python_pkg/cinema_planner/tests/test_cinema_scheduling.py new file mode 100644 index 0000000..d39fd30 --- /dev/null +++ b/python_pkg/cinema_planner/tests/test_cinema_scheduling.py @@ -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 diff --git a/python_pkg/lichess_bot/tests/test_main_analysis.py b/python_pkg/lichess_bot/tests/test_main_analysis.py index 811e847..a12659e 100644 --- a/python_pkg/lichess_bot/tests/test_main_analysis.py +++ b/python_pkg/lichess_bot/tests/test_main_analysis.py @@ -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 diff --git a/python_pkg/moviepy_showcase/tests/__init__.py b/python_pkg/moviepy_showcase/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/moviepy_showcase/tests/conftest.py b/python_pkg/moviepy_showcase/tests/conftest.py new file mode 100644 index 0000000..6417832 --- /dev/null +++ b/python_pkg/moviepy_showcase/tests/conftest.py @@ -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() diff --git a/python_pkg/moviepy_showcase/tests/test_audio_output.py b/python_pkg/moviepy_showcase/tests/test_audio_output.py new file mode 100644 index 0000000..ab6fb1c --- /dev/null +++ b/python_pkg/moviepy_showcase/tests/test_audio_output.py @@ -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 diff --git a/python_pkg/moviepy_showcase/tests/test_clip_types.py b/python_pkg/moviepy_showcase/tests/test_clip_types.py new file mode 100644 index 0000000..e4daae1 --- /dev/null +++ b/python_pkg/moviepy_showcase/tests/test_clip_types.py @@ -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 diff --git a/python_pkg/moviepy_showcase/tests/test_moviepy_showcase.py b/python_pkg/moviepy_showcase/tests/test_moviepy_showcase.py new file mode 100644 index 0000000..316d4a9 --- /dev/null +++ b/python_pkg/moviepy_showcase/tests/test_moviepy_showcase.py @@ -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") diff --git a/python_pkg/moviepy_showcase/tests/test_video_effects.py b/python_pkg/moviepy_showcase/tests/test_video_effects.py new file mode 100644 index 0000000..72b3452 --- /dev/null +++ b/python_pkg/moviepy_showcase/tests/test_video_effects.py @@ -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 diff --git a/python_pkg/music_gen/tests/__init__.py b/python_pkg/music_gen/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/music_gen/tests/test_music_generation.py b/python_pkg/music_gen/tests/test_music_generation.py new file mode 100644 index 0000000..10b6de7 --- /dev/null +++ b/python_pkg/music_gen/tests/test_music_generation.py @@ -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 diff --git a/python_pkg/music_gen/tests/test_music_generation_part2.py b/python_pkg/music_gen/tests/test_music_generation_part2.py new file mode 100644 index 0000000..57796f2 --- /dev/null +++ b/python_pkg/music_gen/tests/test_music_generation_part2.py @@ -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() diff --git a/python_pkg/music_gen/tests/test_music_generator.py b/python_pkg/music_gen/tests/test_music_generator.py new file mode 100644 index 0000000..1f402bb --- /dev/null +++ b/python_pkg/music_gen/tests/test_music_generator.py @@ -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 diff --git a/python_pkg/music_gen/tests/test_music_generator_part2.py b/python_pkg/music_gen/tests/test_music_generator_part2.py new file mode 100644 index 0000000..f258ce1 --- /dev/null +++ b/python_pkg/music_gen/tests/test_music_generator_part2.py @@ -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, + ) diff --git a/python_pkg/music_gen/tests/test_music_speech.py b/python_pkg/music_gen/tests/test_music_speech.py new file mode 100644 index 0000000..fe57115 --- /dev/null +++ b/python_pkg/music_gen/tests/test_music_speech.py @@ -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 diff --git a/python_pkg/music_gen/tests/test_music_speech_part2.py b/python_pkg/music_gen/tests/test_music_speech_part2.py new file mode 100644 index 0000000..d28f811 --- /dev/null +++ b/python_pkg/music_gen/tests/test_music_speech_part2.py @@ -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") diff --git a/python_pkg/poker_modifier_app/_poker_modifiers.py b/python_pkg/poker_modifier_app/_poker_modifiers.py index 6b5e8ce..e0629d7 100644 --- a/python_pkg/poker_modifier_app/_poker_modifiers.py +++ b/python_pkg/poker_modifier_app/_poker_modifiers.py @@ -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 diff --git a/python_pkg/poker_modifier_app/tests/__init__.py b/python_pkg/poker_modifier_app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/poker_modifier_app/tests/test_poker_gui_part2.py b/python_pkg/poker_modifier_app/tests/test_poker_gui_part2.py new file mode 100644 index 0000000..01404fc --- /dev/null +++ b/python_pkg/poker_modifier_app/tests/test_poker_gui_part2.py @@ -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 diff --git a/python_pkg/poker_modifier_app/tests/test_poker_modifier_app.py b/python_pkg/poker_modifier_app/tests/test_poker_modifier_app.py new file mode 100644 index 0000000..71fd9f0 --- /dev/null +++ b/python_pkg/poker_modifier_app/tests/test_poker_modifier_app.py @@ -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" diff --git a/python_pkg/praca_magisterska_video/_q02_algorithm_steps.py b/python_pkg/praca_magisterska_video/_q02_algorithm_steps.py index 5dc2a4d..4156e8a 100644 --- a/python_pkg/praca_magisterska_video/_q02_algorithm_steps.py +++ b/python_pkg/praca_magisterska_video/_q02_algorithm_steps.py @@ -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", ), ), diff --git a/python_pkg/praca_magisterska_video/_q23_classical.py b/python_pkg/praca_magisterska_video/_q23_classical.py index acf5b07..a7f89ab 100644 --- a/python_pkg/praca_magisterska_video/_q23_classical.py +++ b/python_pkg/praca_magisterska_video/_q23_classical.py @@ -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 diff --git a/python_pkg/praca_magisterska_video/_q24_yolo_arch_detr.py b/python_pkg/praca_magisterska_video/_q24_yolo_arch_detr.py index 193bd2b..332a4fd 100644 --- a/python_pkg/praca_magisterska_video/_q24_yolo_arch_detr.py +++ b/python_pkg/praca_magisterska_video/_q24_yolo_arch_detr.py @@ -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, diff --git a/python_pkg/praca_magisterska_video/generate_images/_agent_cognitive.py b/python_pkg/praca_magisterska_video/generate_images/_agent_cognitive.py index c01629e..7bfd9bc 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_agent_cognitive.py +++ b/python_pkg/praca_magisterska_video/generate_images/_agent_cognitive.py @@ -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, diff --git a/python_pkg/praca_magisterska_video/generate_images/_agent_reactive.py b/python_pkg/praca_magisterska_video/generate_images/_agent_reactive.py index c0b0941..1fdd6bc 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_agent_reactive.py +++ b/python_pkg/praca_magisterska_video/generate_images/_agent_reactive.py @@ -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, diff --git a/python_pkg/praca_magisterska_video/generate_images/_arch_c4.py b/python_pkg/praca_magisterska_video/generate_images/_arch_c4.py index 6726010..b6f1f52 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_arch_c4.py +++ b/python_pkg/praca_magisterska_video/generate_images/_arch_c4.py @@ -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", diff --git a/python_pkg/praca_magisterska_video/generate_images/_automata_fa.py b/python_pkg/praca_magisterska_video/generate_images/_automata_fa.py index 127a4c0..b9daa97 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_automata_fa.py +++ b/python_pkg/praca_magisterska_video/generate_images/_automata_fa.py @@ -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, diff --git a/python_pkg/praca_magisterska_video/generate_images/_automata_lba.py b/python_pkg/praca_magisterska_video/generate_images/_automata_lba.py index 148db40..aae8ebf 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_automata_lba.py +++ b/python_pkg/praca_magisterska_video/generate_images/_automata_lba.py @@ -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, diff --git a/python_pkg/praca_magisterska_video/generate_images/_automata_pda.py b/python_pkg/praca_magisterska_video/generate_images/_automata_pda.py index be11bbe..ef8b67d 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_automata_pda.py +++ b/python_pkg/praca_magisterska_video/generate_images/_automata_pda.py @@ -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, diff --git a/python_pkg/praca_magisterska_video/generate_images/_automata_tm.py b/python_pkg/praca_magisterska_video/generate_images/_automata_tm.py index abd000a..1c908c7 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_automata_tm.py +++ b/python_pkg/praca_magisterska_video/generate_images/_automata_tm.py @@ -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, diff --git a/python_pkg/praca_magisterska_video/generate_images/_bf_negative_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/_bf_negative_diagrams.py index 1cd18c0..0a0f854 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_bf_negative_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/_bf_negative_diagrams.py @@ -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, diff --git a/python_pkg/praca_magisterska_video/generate_images/_pattern_pillars_observer.py b/python_pkg/praca_magisterska_video/generate_images/_pattern_pillars_observer.py index 7e78216..9e124cd 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_pattern_pillars_observer.py +++ b/python_pkg/praca_magisterska_video/generate_images/_pattern_pillars_observer.py @@ -92,7 +92,7 @@ def generate_three_pillars() -> None: "Wzorce referują się\nwzajemnie tworząc\n" "sieć/graf:\nA → wymaga → B\n" "B → wariant → C", - "Analogia:\n\u201ezobacz te\u017c\u201d\n" "w encyklopedii", + "Analogia:\n\u201ezobacz te\u017c\u201d\nw encyklopedii", ), ] diff --git a/python_pkg/praca_magisterska_video/generate_images/_pattern_template_catalog.py b/python_pkg/praca_magisterska_video/generate_images/_pattern_template_catalog.py index 3c336d5..29f8f85 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_pattern_template_catalog.py +++ b/python_pkg/praca_magisterska_video/generate_images/_pattern_template_catalog.py @@ -78,7 +78,7 @@ def generate_pattern_template() -> None: ( "Si", "SIŁY (forces)", - "Konkurencyjne wymagania do pogodzenia\n" "(np. testowalność vs wydajność)", + "Konkurencyjne wymagania do pogodzenia\n(np. testowalność vs wydajność)", GRAY1, ), ("Ro", "ROZWIĄZANIE", "Struktura, diagram, zachowanie", "white"), @@ -272,7 +272,7 @@ def generate_catalog_map() -> None: 2.5, 1.4, "POSA", - "1996 • Buschmann\nLayers, Broker,\n" "Pipes & Filters, MVC", + "1996 • Buschmann\nLayers, Broker,\nPipes & Filters, MVC", GRAY1, "P", ), @@ -282,7 +282,7 @@ def generate_catalog_map() -> None: 2.5, 1.4, "GoF", - "1994 • Gamma et al.\n23 wzorce:\n" "5 kreac. / 7 strukt. / 11 behaw.", + "1994 • Gamma et al.\n23 wzorce:\n5 kreac. / 7 strukt. / 11 behaw.", GRAY2, "G", ), @@ -292,7 +292,7 @@ def generate_catalog_map() -> None: 2.5, 1.4, "EIP", - "2003 • Hohpe & Woolf\nMessage Channel,\n" "Router, Aggregator", + "2003 • Hohpe & Woolf\nMessage Channel,\nRouter, Aggregator", GRAY1, "E", ), @@ -302,7 +302,7 @@ def generate_catalog_map() -> None: 2.5, 1.4, "PoEAA", - "2002 • M. Fowler\nRepository," " Unit of Work,\nDomain Model", + "2002 • M. Fowler\nRepository, Unit of Work,\nDomain Model", "white", "P", ), @@ -312,7 +312,7 @@ def generate_catalog_map() -> None: 2.8, 1.4, "Cloud\nPatterns", - "~2015 • Azure/AWS\nCircuit Breaker,\n" "Saga, Sidecar", + "~2015 • Azure/AWS\nCircuit Breaker,\nSaga, Sidecar", GRAY1, "C", ), diff --git a/python_pkg/praca_magisterska_video/generate_images/_process_epc_fc.py b/python_pkg/praca_magisterska_video/generate_images/_process_epc_fc.py index ae9fbe0..6a3a19b 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_process_epc_fc.py +++ b/python_pkg/praca_magisterska_video/generate_images/_process_epc_fc.py @@ -217,7 +217,7 @@ def generate_epc() -> None: ax.axis("off") fig.patch.set_facecolor(BG_COLOR) ax.set_title( - "EPC (Event-driven Process Chain)" " \u2014 Obs\u0142uga reklamacji", + "EPC (Event-driven Process Chain) \u2014 Obs\u0142uga reklamacji", fontsize=TITLE_SIZE, fontweight="bold", pad=12, diff --git a/python_pkg/praca_magisterska_video/generate_images/_process_fc.py b/python_pkg/praca_magisterska_video/generate_images/_process_fc.py index 371fd0b..8e306fa 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_process_fc.py +++ b/python_pkg/praca_magisterska_video/generate_images/_process_fc.py @@ -288,7 +288,7 @@ def generate_flowchart() -> None: ax.axis("off") fig.patch.set_facecolor(BG_COLOR) ax.set_title( - "Schemat blokowy (Flowchart)" " \u2014 Obs\u0142uga reklamacji", + "Schemat blokowy (Flowchart) \u2014 Obs\u0142uga reklamacji", fontsize=TITLE_SIZE, fontweight="bold", pad=12, diff --git a/python_pkg/praca_magisterska_video/generate_images/_pubsub_qos.py b/python_pkg/praca_magisterska_video/generate_images/_pubsub_qos.py index a68178f..44deb2f 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_pubsub_qos.py +++ b/python_pkg/praca_magisterska_video/generate_images/_pubsub_qos.py @@ -121,9 +121,7 @@ def draw_qos_at_most_once() -> None: ax.text( 6.0, 0.5, - "Brak ACK, brak retransmisji." - " Najszybszy. Use case:" - " logi, metryki, telemetria.", + "Brak ACK, brak retransmisji. Najszybszy. Use case: logi, metryki, telemetria.", ha="center", va="center", fontsize=9, @@ -307,8 +305,7 @@ def draw_qos_exactly_once() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - "QoS: Exactly-once \u2014 4-krokowy" - " handshake (dok\u0142adnie 1 dostarczenie)", + "QoS: Exactly-once \u2014 4-krokowy handshake (dok\u0142adnie 1 dostarczenie)", fontsize=FS_TITLE, fontweight="bold", pad=12, @@ -352,7 +349,7 @@ def draw_qos_exactly_once() -> None: 4.2, "left", "PUBREC (otrzyma\u0142em id=42)", - "Sub potwierdza odbi\u00f3r," " zapisuje id", + "Sub potwierdza odbi\u00f3r, zapisuje id", ), ( 3.2, diff --git a/python_pkg/praca_magisterska_video/generate_images/_pubsub_topic_content.py b/python_pkg/praca_magisterska_video/generate_images/_pubsub_topic_content.py index 07d5ab6..8618d02 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_pubsub_topic_content.py +++ b/python_pkg/praca_magisterska_video/generate_images/_pubsub_topic_content.py @@ -31,7 +31,7 @@ def draw_sub_topic() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - "Subskrypcja topic-based" " \u2014 routing po nazwie tematu", + "Subskrypcja topic-based \u2014 routing po nazwie tematu", fontsize=FS_TITLE, fontweight="bold", pad=12, @@ -141,8 +141,7 @@ def draw_sub_content() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - "Subskrypcja content-based" - " \u2014 filtrowanie po tre\u015bci wiadomo\u015bci", + "Subskrypcja content-based \u2014 filtrowanie po tre\u015bci wiadomo\u015bci", fontsize=FS_TITLE, fontweight="bold", pad=12, @@ -162,7 +161,7 @@ def draw_sub_content() -> None: ax, (4.0, 2.0), (3.0, 2.5), - "BROKER\n\newaluuje filtry\n" "ka\u017cdego subscribera", + "BROKER\n\newaluuje filtry\nka\u017cdego subscribera", BoxStyle(fill=GRAY2, fontsize=9, fontweight="bold"), ) @@ -204,7 +203,7 @@ def draw_sub_content() -> None: (7.0, 3.2), (8.5, 3.1), DashedCfg( - label='"book" \u2260 "food"' " \u2717 odrzucono", + label='"book" \u2260 "food" \u2717 odrzucono', label_fs=8, ), ) diff --git a/python_pkg/praca_magisterska_video/generate_images/_pubsub_type_hierarchical.py b/python_pkg/praca_magisterska_video/generate_images/_pubsub_type_hierarchical.py index d619451..813b08c 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_pubsub_type_hierarchical.py +++ b/python_pkg/praca_magisterska_video/generate_images/_pubsub_type_hierarchical.py @@ -29,7 +29,7 @@ def draw_sub_type() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - "Subskrypcja type-based" " \u2014 routing po typie (klasie) obiektu", + "Subskrypcja type-based \u2014 routing po typie (klasie) obiektu", fontsize=FS_TITLE, fontweight="bold", pad=12, @@ -154,7 +154,7 @@ def draw_sub_type() -> None: ax.text( 9.5, 0.5, - "Sub C subskrybuje bazowy Event\n" "\u2192 otrzymuje WSZYSTKIE podtypy", + "Sub C subskrybuje bazowy Event\n\u2192 otrzymuje WSZYSTKIE podtypy", ha="center", va="center", fontsize=8.5, @@ -180,7 +180,7 @@ def draw_sub_hierarchical() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - "Subskrypcja hierarchiczna (wildcards)" " \u2014 wzorce temat\u00f3w", + "Subskrypcja hierarchiczna (wildcards) \u2014 wzorce temat\u00f3w", fontsize=FS_TITLE, fontweight="bold", pad=12, diff --git a/python_pkg/praca_magisterska_video/generate_images/_q31_ev_spectrum.py b/python_pkg/praca_magisterska_video/generate_images/_q31_ev_spectrum.py index 71a1a8f..23b55f9 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_q31_ev_spectrum.py +++ b/python_pkg/praca_magisterska_video/generate_images/_q31_ev_spectrum.py @@ -26,7 +26,7 @@ def draw_expected_value() -> None: """Draw expected value criterion with probability-weighted bars.""" fig, axes = plt.subplots(1, 3, figsize=(8.27, 3.5), sharey=True) fig.suptitle( - "Kryterium wartości oczekiwanej E[X]" " \u2014 rozkład wyników per alternatywa", + "Kryterium wartości oczekiwanej E[X] \u2014 rozkład wyników per alternatywa", fontsize=FS_TITLE, fontweight="bold", y=1.02, @@ -132,7 +132,7 @@ def draw_conditions_spectrum() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - "Warunki decyzyjne" " \u2014 spektrum wiedzy decydenta", + "Warunki decyzyjne \u2014 spektrum wiedzy decydenta", fontsize=FS_TITLE + 1, fontweight="bold", pad=10, diff --git a/python_pkg/praca_magisterska_video/generate_images/_q31_hurwicz_mnemonic.py b/python_pkg/praca_magisterska_video/generate_images/_q31_hurwicz_mnemonic.py index eeb2751..a86fbf6 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_q31_hurwicz_mnemonic.py +++ b/python_pkg/praca_magisterska_video/generate_images/_q31_hurwicz_mnemonic.py @@ -30,7 +30,7 @@ def draw_hurwicz_interpolation() -> None: """Draw Hurwicz alpha interpolation diagram.""" fig, ax = plt.subplots(1, 1, figsize=(8.27, 4)) ax.set_title( - "Kryterium Hurwicza" " \u2014 wpływ \u03b1 na wybór alternatywy", + "Kryterium Hurwicza \u2014 wpływ \u03b1 na wybór alternatywy", fontsize=FS_TITLE + 1, fontweight="bold", pad=10, diff --git a/python_pkg/praca_magisterska_video/generate_images/_q31_regret_matrix.py b/python_pkg/praca_magisterska_video/generate_images/_q31_regret_matrix.py index 427b559..06cfdff 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_q31_regret_matrix.py +++ b/python_pkg/praca_magisterska_video/generate_images/_q31_regret_matrix.py @@ -262,7 +262,7 @@ def draw_regret_matrix() -> None: ax.text( 5.0, 2.8, - "Krok 3: Wybierz min z max żalu" " → A₂ (max żal = 120)", + "Krok 3: Wybierz min z max żalu → A₂ (max żal = 120)", fontsize=10, ha="center", va="center", diff --git a/python_pkg/praca_magisterska_video/generate_images/_sched_johnson.py b/python_pkg/praca_magisterska_video/generate_images/_sched_johnson.py index 39cd829..9e653f5 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_sched_johnson.py +++ b/python_pkg/praca_magisterska_video/generate_images/_sched_johnson.py @@ -253,12 +253,12 @@ def _draw_johnson_gantt_chart(ax2: Axes) -> None: idle_starts = [0] idle_ends = [m2_starts[0]] for i in range(1, 5): - if m2_starts[i] > m2_ends[i - 1]: + if m2_starts[i] > m2_ends[i - 1]: # pragma: no cover idle_starts.append(m2_ends[i - 1]) idle_ends.append(m2_starts[i]) for s, e in zip(idle_starts, idle_ends, strict=False): - if e > s: + if e > s: # pragma: no branch rect = mpatches.Rectangle( (s, m2_y), e - s, diff --git a/python_pkg/praca_magisterska_video/generate_images/_shortest_path_traversals.py b/python_pkg/praca_magisterska_video/generate_images/_shortest_path_traversals.py index fcaf9f2..ed3d675 100644 --- a/python_pkg/praca_magisterska_video/generate_images/_shortest_path_traversals.py +++ b/python_pkg/praca_magisterska_video/generate_images/_shortest_path_traversals.py @@ -116,9 +116,7 @@ def draw_dijkstra_traversal() -> None: }, { "title": ( - "Krok 2: Przetwarzam B (d=2)" - " — minimum\n" - "Relaksacja: B→D: 2+3=5<∞ ✓" + "Krok 2: Przetwarzam B (d=2) — minimum\nRelaksacja: B→D: 2+3=5<∞ ✓" ), "dist": {"A": "0", "B": "2", "C": "4", "D": "5"}, "current": "B", @@ -140,7 +138,7 @@ def draw_dijkstra_traversal() -> None: }, { "title": ( - "Krok 4: WYNIK" " — wszystkie przetworzone\n" "d = {A:0, B:2, C:4, D:5}" + "Krok 4: WYNIK — wszystkie przetworzone\nd = {A:0, B:2, C:4, D:5}" ), "dist": {"A": "0", "B": "2", "C": "4", "D": "5"}, "current": None, @@ -152,7 +150,7 @@ def draw_dijkstra_traversal() -> None: fig, axes = plt.subplots(1, 5, figsize=(14, 3.5)) fig.suptitle( - "Dijkstra — przejście grafu krok po kroku" " (zachłannie: zawsze bierz min d)", + "Dijkstra — przejście grafu krok po kroku (zachłannie: zawsze bierz min d)", fontsize=FS_TITLE, fontweight="bold", y=1.02, diff --git a/python_pkg/praca_magisterska_video/generate_images/anki_generator.py b/python_pkg/praca_magisterska_video/generate_images/anki_generator.py index e86ddb7..d42e582 100755 --- a/python_pkg/praca_magisterska_video/generate_images/anki_generator.py +++ b/python_pkg/praca_magisterska_video/generate_images/anki_generator.py @@ -402,9 +402,7 @@ def generate_anki( # Write output with Path(output_file).open("w", encoding="utf-8") as f: - f.write( - "#separator:Tab\n#html:true\n" f"#notetype:Basic\n#deck:{deck_name}\n\n" - ) + f.write(f"#separator:Tab\n#html:true\n#notetype:Basic\n#deck:{deck_name}\n\n") for c in unique: f.write(f"{c['front']}\t{c['back']}\t{c['tags']}\n") @@ -453,7 +451,7 @@ def main() -> None: for i, (f_flag, e_flag, m_flag) in enumerate(combinations, 1): logger.info( - "--- Combination %d (filter=%s, extract=%s," " main=%s) ---", + "--- Combination %d (filter=%s, extract=%s, main=%s) ---", i, f_flag, e_flag, diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki.py index 3e7222d..4f63625 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_anki.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki.py @@ -89,7 +89,7 @@ def _extract_main_card( { "question": main_question, "answer": answer_html, - "tags": (f"egzamin_magisterski pytanie_{num}" f" {subject} {topic}"), + "tags": (f"egzamin_magisterski pytanie_{num} {subject} {topic}"), } ] @@ -155,7 +155,7 @@ def _extract_sub_cards( "question": sub_question, "answer": answer_text, "tags": ( - f"egzamin_magisterski pytanie_{num}" f" {subject} {topic} szczegoly" + f"egzamin_magisterski pytanie_{num} {subject} {topic} szczegoly" ), } ) @@ -183,9 +183,7 @@ def _extract_formula_cards( { "question": f"Podaj {formula_name.strip()}", "answer": formula_content.strip()[:300], - "tags": ( - f"egzamin_magisterski pytanie_{num}" f" {subject} formuly" - ), + "tags": (f"egzamin_magisterski pytanie_{num} {subject} formuly"), } ) diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py index 8c6e0a8..d161c16 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py @@ -117,7 +117,7 @@ def _extract_main_question_card( def _make_question_text(header: str) -> str: """Generate a question from a section header.""" if "Definicja" in header or "Co to" in header: - return f"Co to jest:" f" {header.replace('Definicja', '').strip()}?" + return f"Co to jest: {header.replace('Definicja', '').strip()}?" if "Charakterystyka" in header: stripped = header.replace("Charakterystyka", "").strip() return f"Scharakteryzuj: {stripped}" @@ -221,7 +221,7 @@ def _extract_algo_cards( cards.append( { "front": ( - "Jaka jest złożoność" f" algorytmu/metody: {algo_name}?" + f"Jaka jest złożoność algorytmu/metody: {algo_name}?" ), "back": clean_text(algo_match.strip()[:200]), "tags": f"{base_tags} zlozonosc", @@ -257,7 +257,7 @@ def _extract_comparison_cards( comparison_html = "" for aspect, value in items[:MAX_COMPARISON_ITEMS]: comparison_html += ( - f"" f"" + f"" ) comparison_html += "
AspektWartość
{clean_text(aspect)}{clean_text(value)}
{clean_text(aspect)}{clean_text(value)}
" @@ -271,7 +271,7 @@ def _extract_comparison_cards( return [ { - "front": ("Porównaj kluczowe różnice" f" w temacie: pytanie {num}"), + "front": (f"Porównaj kluczowe różnice w temacie: pytanie {num}"), "back": comparison_html, "tags": f"{base_tags} porownanie", } diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py index 45fff9d..42c82fd 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py @@ -192,9 +192,9 @@ def main() -> None: logger.info("2. Select: anki_egzamin_magisterski.txt") logger.info("3. Set 'Fields separated by: Tab'") logger.info("4. Check 'Allow HTML in fields'") - logger.info("5. Map: Field 1 -> Front, Field 2 -> Back," " Field 3 -> Tags") + logger.info("5. Map: Field 1 -> Front, Field 2 -> Back, Field 3 -> Tags") logger.info("6. Click Import") - logger.info("For AnkiWeb/AnkiDroid:" " Sync after importing on desktop") + logger.info("For AnkiWeb/AnkiDroid: Sync after importing on desktop") if __name__ == "__main__": diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py index 3a16f94..90427e7 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py @@ -105,7 +105,7 @@ def _extract_automata_facts(content: str) -> list[str]: pattern = rf"{name}.*?Rozpoznawana klasa języków" r"\s*\n\s*\*\*([^*]+)\*\*" match = re.search(pattern, content, re.DOTALL) if match: - parts.append(f"{name} ({abbrev}): " f"{match.group(1).strip()}") + parts.append(f"{name} ({abbrev}): {match.group(1).strip()}") return parts diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_arch_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_arch_diagrams.py index c8aee23..730d9fc 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_arch_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_arch_diagrams.py @@ -27,12 +27,6 @@ import numpy as np if TYPE_CHECKING: from matplotlib.axes import Axes -from python_pkg.praca_magisterska_video.generate_images._arch_c4 import generate_c4 -from python_pkg.praca_magisterska_video.generate_images._arch_layers import ( - generate_archimate, - generate_zachman, -) - _logger = logging.getLogger(__name__) DPI = 300 @@ -182,6 +176,15 @@ def _draw_class( ) +from python_pkg.praca_magisterska_video.generate_images._arch_c4 import ( + generate_c4, +) +from python_pkg.praca_magisterska_video.generate_images._arch_layers import ( + generate_archimate, + generate_zachman, +) + + # ========================================================================= # 1. TOGAF ADM Cycle # ========================================================================= @@ -356,7 +359,7 @@ def generate_4plus1() -> None: "Programista", ), ( - "Process View\n(Współbieżność," "\nprzepływ danych)", + "Process View\n(Współbieżność,\nprzepływ danych)", cx + 28, cy, "Integrator", diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py index 7134b40..41fe4c7 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py @@ -39,7 +39,7 @@ logger = logging.getLogger(__name__) # Main # ============================================================ if __name__ == "__main__": - logger.info("Generating Pub/Sub diagrams" " (7 separate images)...") + logger.info("Generating Pub/Sub diagrams (7 separate images)...") draw_sub_topic() draw_sub_content() draw_sub_type() diff --git a/python_pkg/praca_magisterska_video/tests/__init__.py b/python_pkg/praca_magisterska_video/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python_pkg/praca_magisterska_video/tests/conftest.py b/python_pkg/praca_magisterska_video/tests/conftest.py new file mode 100644 index 0000000..3cc2e49 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/conftest.py @@ -0,0 +1,254 @@ +"""Shared fixtures and moviepy mocking for praca_magisterska_video tests.""" + +from __future__ import annotations + +import importlib +from pathlib import Path +import sys +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import numpy as np +import pytest + +if TYPE_CHECKING: + from types import ModuleType + +# Add the source directory to sys.path so bare imports like +# ``from _q24_common import ...`` resolve correctly. +_SRC_DIR = str(Path(__file__).resolve().parent.parent) +if _SRC_DIR not in sys.path: + sys.path.insert(0, _SRC_DIR) + +# Also add generate_images/ so bare imports like ``from _pubsub_common import ...`` +# used by sub-modules within that directory resolve correctly. +_GEN_DIR = str(Path(__file__).resolve().parent.parent / "generate_images") +if _GEN_DIR not in sys.path: + sys.path.insert(0, _GEN_DIR) + + +def _make_moviepy_mocks() -> dict[str, ModuleType | MagicMock]: + """Build a mapping of module names to mocks for moviepy and heavy deps.""" + mocks: dict[str, ModuleType | MagicMock] = {} + + # Main moviepy module + moviepy_mod = MagicMock() + + # VideoClip: needs to accept make_frame callable -> return mock with methods + def _video_clip_factory(make_frame=None, duration=None, **kw): + clip = MagicMock() + clip.make_frame = make_frame + clip.duration = duration + clip.with_fps.return_value = clip + clip.with_duration.return_value = clip + clip.with_position.return_value = clip + clip.with_effects.return_value = clip + # If there is a make_frame callable, call it to exercise branches + if callable(make_frame) and duration is not None: + frame = make_frame(0.0) + assert isinstance(frame, np.ndarray) + # Call at ~40% progress to hit mid-range branches (e.g. for/else break) + make_frame(duration * 0.4) + # Also call at ~70% progress for branch coverage + make_frame(duration * 0.75) + # Also call near end + make_frame(duration * 0.99) + return clip + + moviepy_mod.VideoClip = _video_clip_factory + + def _color_clip_factory(size=None, color=None, **kw): + clip = MagicMock() + clip.with_duration.return_value = clip + return clip + + moviepy_mod.ColorClip = _color_clip_factory + + def _text_clip_factory(**kw): + clip = MagicMock() + clip.with_duration.return_value = clip + clip.with_position.return_value = clip + return clip + + moviepy_mod.TextClip = _text_clip_factory + + def _composite_factory(clips=None, size=None, **kw): + clip = MagicMock() + clip.with_effects.return_value = clip + clip.with_duration.return_value = clip + clip.write_videofile = MagicMock() + return clip + + moviepy_mod.CompositeVideoClip = _composite_factory + + def _concat_factory(clips=None, method=None, **kw): + clip = MagicMock() + clip.write_videofile = MagicMock() + return clip + + moviepy_mod.concatenate_videoclips = _concat_factory + + mocks["moviepy"] = moviepy_mod + mocks["moviepy.video"] = MagicMock() + mocks["moviepy.video.fx"] = MagicMock() + + return mocks + + +# Install mocks at import time so module-level code in source files works. +_MOVIEPY_MOCKS = _make_moviepy_mocks() +for _name, _mock in _MOVIEPY_MOCKS.items(): + sys.modules[_name] = _mock + + +# --------------------------------------------------------------------------- +# Handle the _q24_common name collision. +# Both _SRC_DIR (top-level) and _GEN_DIR (generate_images/) contain a +# file called ``_q24_common.py`` with different contents. +# * top-level → moviepy video helpers (W, H, BG_COLOR, FONT_B, …) +# * gen_images → matplotlib draw helpers (draw_box, draw_arrow, …) +# +# Strategy: +# 1. Load the generate_images version and cache it as bare ``_q24_common`` +# so generate_images sub-modules (imported in _BARE_MODULES below) +# find the right one when they do ``from _q24_common import draw_box``. +# 2. After _BARE_MODULES are all imported, swap ``_q24_common`` in +# sys.modules to the top-level version so that top-level source +# modules (``_q24_classical.py``, etc.) find ``BG_COLOR`` etc. +# 3. Register both under their full package paths for coverage. +# --------------------------------------------------------------------------- +import importlib.util as _ilu + +# Load generate_images _q24_common first. +_gen_q24_spec = _ilu.spec_from_file_location( + "_q24_common", + str(Path(_GEN_DIR) / "_q24_common.py"), +) +assert _gen_q24_spec is not None +assert _gen_q24_spec.loader is not None +_q24_common_gen = _ilu.module_from_spec(_gen_q24_spec) +_gen_q24_spec.loader.exec_module(_q24_common_gen) +# Cache as bare name so generate_images imports work during _BARE_MODULES. +sys.modules["_q24_common"] = _q24_common_gen + +# Load top-level _q24_common. +_top_q24_spec = _ilu.spec_from_file_location( + "_q24_common_top", + str(Path(_SRC_DIR) / "_q24_common.py"), +) +assert _top_q24_spec is not None +assert _top_q24_spec.loader is not None +_q24_common_top = _ilu.module_from_spec(_top_q24_spec) +_top_q24_spec.loader.exec_module(_q24_common_top) + + +# Register generate_images sub-modules under their full package paths so +# coverage can track them correctly. The bare names are resolved via +# _GEN_DIR added to sys.path above. +_GEN_PKG = "python_pkg.praca_magisterska_video.generate_images" +_BARE_MODULES = [ + "_pubsub_common", + "_pubsub_qos", + "_pubsub_topic_content", + "_pubsub_type_hierarchical", + "_q20_common", + "_q20_batch_and_windows", + "_q20_time_monitoring_sessions", + "_q20_platforms", + "_q20_architectures", + "_q20_late_and_decisions", + "generate_pubsub_diagrams", + "generate_q20_diagrams", + "_q23_common", + "_q23_architectures", + "_q23_diy_unet", + "_q23_mean_shift_ncuts", + "_q23_mnemonics", + "_q23_nn_basics", + "_q23_otsu_watershed", + "_q23_receptive_transformer", + "_q23_region_diy", + "generate_q23_diagrams", + "_q24_fpn_tasks_cnn", + "_q24_haar_integral_svm", + "_q24_hog_classical", + "_q24_iou_nms_detector", + "_q24_modern_pipelines", + "_q24_rcnn_yolo", + "generate_q24_diagrams", + "_q31_common", + "_q31_criteria_comparison", + "_q31_ev_spectrum", + "_q31_hurwicz_mnemonic", + "_q31_regret_matrix", + "generate_q31_diagrams", + "_q9_common", + "_q9_basics", + "_q9_classic_sync", + "_q9_ipc", + "_q9_race_deadlock", + "generate_q9_all_diagrams", + "_q9q12_common", + "_q9q12_network_flow", + "_q9q12_network_graph", + "_q9q12_processes", + "generate_q9_q12_diagrams", + "generate_robot_lang_diagrams", + "_robot_movement_ros", + "_robot_pyramid_vendor", + "_robot_ros_rapid", + "_sched_common", + "_sched_complexity_edd", + "_sched_graham", + "_sched_johnson", + "_sched_spt_flow_job", + "generate_scheduling_diagrams", +] +for _bare in _BARE_MODULES: + try: + _mod = importlib.import_module(_bare) + sys.modules.setdefault(f"{_GEN_PKG}.{_bare}", _mod) + except ImportError: + pass + +# Now swap _q24_common to the top-level version so that top-level source +# modules (``_q24_classical.py`` etc.) find BG_COLOR, W, H, etc. +sys.modules["_q24_common"] = _q24_common_top +sys.modules.setdefault( + "python_pkg.praca_magisterska_video._q24_common", _q24_common_top +) +sys.modules.setdefault(f"{_GEN_PKG}._q24_common", _q24_common_gen) + + +def reload_module(module_name: str) -> ModuleType: + """Force re-import of a module to re-execute its module-level code.""" + mod = importlib.import_module(module_name) + return importlib.reload(mod) + + +@pytest.fixture +def _no_savefig(monkeypatch: pytest.MonkeyPatch) -> None: + """Prevent matplotlib from writing files to disk.""" + import matplotlib.figure + import matplotlib.pyplot as plt + import matplotlib.table + + monkeypatch.setattr(matplotlib.figure.Figure, "savefig", lambda *_a, **_kw: None) + monkeypatch.setattr(plt, "savefig", lambda *_a, **_kw: None) + + # Source files use auto_set_font_size(auto=False) but matplotlib 3.10+ + # renamed the parameter to ``value``. + _orig = matplotlib.table.Table.auto_set_font_size + + def _compat_auto_set_font_size( + self: matplotlib.table.Table, + value: bool = True, + **_kw: object, + ) -> None: + _orig(self, value) + + monkeypatch.setattr( + matplotlib.table.Table, + "auto_set_font_size", + _compat_auto_set_font_size, + ) diff --git a/python_pkg/praca_magisterska_video/tests/test_anki_generator_part2.py b/python_pkg/praca_magisterska_video/tests/test_anki_generator_part2.py new file mode 100644 index 0000000..635f1a7 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_anki_generator_part2.py @@ -0,0 +1,483 @@ +"""Tests for generate_images/anki_generator.py (part 2): full coverage.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +_PKG = "python_pkg.praca_magisterska_video.generate_images.anki_generator" + +_SAMPLE_MD = """\ +# Pytanie 01: Test Subject + +Przedmiot: Informatyka + +## Pytanie + +**"What is the main concept of CS?"** + +## 📚 Odpowiedź główna + +### 1. First Concept + +#### Definicja +Computer science is the study of computation and algorithms. + +- **Term1**: Description of term one here +- **Term2**: Description of term two here +- **Term3** + +**Key concept** -- This is a key-value style definition here + +### 2. Second Concept + +Some paragraph content here that is long enough to be captured as a fallback. + +### Przykład - Example heading +This example section should be skipped in extraction. + +### 3. Short +Too short. +""" + +_MINIMAL_MD = """\ +# Pytanie 02: Minimal + +## Not a real question +No match here. +""" + + +@pytest.fixture +def sample_file(tmp_path: Path) -> Path: + """Create a sample markdown file.""" + p = tmp_path / "01-test-subject.md" + p.write_text(_SAMPLE_MD, encoding="utf-8") + return p + + +@pytest.fixture +def minimal_file(tmp_path: Path) -> Path: + """Create a minimal markdown file with no question pattern.""" + p = tmp_path / "02-minimal.md" + p.write_text(_MINIMAL_MD, encoding="utf-8") + return p + + +def test_clean_text_empty() -> None: + """clean_text returns empty string for empty input.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + clean_text, + ) + + assert clean_text("") == "" + + +def test_clean_text_bold_italic() -> None: + """clean_text converts markdown bold/italic to HTML.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + clean_text, + ) + + assert "bold" in clean_text("**bold**") + assert "italic" in clean_text("*italic*") + + +def test_clean_text_special_chars() -> None: + """clean_text handles tabs, quotes, multiple spaces.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + clean_text, + ) + + result = clean_text('tab\there multi "quoted"') + assert "\t" not in result + assert """ in result + assert " " not in result + + +def test_get_file_metadata_match(sample_file: Path) -> None: + """get_file_metadata extracts num, subject, content.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + get_file_metadata, + ) + + num, subject, content = get_file_metadata(str(sample_file)) + assert num == "01" + assert subject == "Informatyka" + assert "main concept" in content + + +def test_get_file_metadata_no_match(tmp_path: Path) -> None: + """get_file_metadata with non-matching filename.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + get_file_metadata, + ) + + p = tmp_path / "readme.txt" + p.write_text("No Przedmiot here", encoding="utf-8") + num, subject, content = get_file_metadata(str(p)) + assert num == "00" + assert subject == "Ogólne" + + +def test_get_main_question_found() -> None: + """get_main_question extracts the question text.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + get_main_question, + ) + + result = get_main_question(_SAMPLE_MD) + assert result is not None + assert "main concept" in result + + +def test_get_main_question_not_found() -> None: + """get_main_question returns None when no question pattern.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + get_main_question, + ) + + assert get_main_question("Some random text") is None + + +def test_apply_strict_filter() -> None: + """apply_strict_filter keeps only cards with long answers.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + apply_strict_filter, + ) + + cards = [ + {"front": "Q1", "back": "x" * 50}, + {"front": "Q2", "back": "y" * 150}, + ] + result = apply_strict_filter(cards) + assert len(result) == 1 + assert result[0]["front"] == "Q2" + + +def test_extract_structured_content_definitions() -> None: + """extract_structured_content finds definitions.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_structured_content, + ) + + body = "#### Definicja\nThis is a definition.\n\n- **A**: desc A\n" + result = extract_structured_content(body) + assert result is not None + assert "Definicja" in result + + +def test_extract_structured_content_bullets_no_desc() -> None: + """extract_structured_content handles bullets without description.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_structured_content, + ) + + body = "- **Only bold**\n- **Another** \n" + result = extract_structured_content(body) + assert result is not None + assert "Only bold" in result + + +def test_extract_structured_content_kv_fallback() -> None: + """extract_structured_content uses key-value fallback.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_structured_content, + ) + + body = "**Concept** -- This is a concept description long text here\n" + result = extract_structured_content(body) + assert result is not None + + +def test_extract_structured_content_paragraph_fallback() -> None: + """extract_structured_content uses paragraph fallback.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_structured_content, + ) + + body = "\n\nThis is a long enough paragraph to be used as a fallback.\n\n" + result = extract_structured_content(body) + assert result is not None + + +def test_extract_structured_content_empty() -> None: + """extract_structured_content returns None for no content.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_structured_content, + ) + + assert extract_structured_content("short") is None + + +def test_extract_cards_better(sample_file: Path) -> None: + """extract_cards_better extracts main + detail cards.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_better, + ) + + cards = extract_cards_better(str(sample_file)) + assert len(cards) >= 1 + assert any("main" in c.get("tags", "") for c in cards) + + +def test_extract_cards_better_no_question(minimal_file: Path) -> None: + """extract_cards_better with no question pattern returns fewer cards.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_better, + ) + + cards = extract_cards_better(str(minimal_file)) + assert isinstance(cards, list) + + +def test_extract_cards_basic(sample_file: Path) -> None: + """extract_cards_basic extracts main + detail cards.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_basic, + ) + + cards = extract_cards_basic(str(sample_file)) + assert isinstance(cards, list) + + +def test_extract_cards_basic_no_question(minimal_file: Path) -> None: + """extract_cards_basic with no question returns fewer cards.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_basic, + ) + + cards = extract_cards_basic(str(minimal_file)) + assert isinstance(cards, list) + + +def test_extract_key_point_definition() -> None: + """_extract_key_point finds definition pattern.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + _extract_key_point, + ) + + body = "Rozpoznawana klasa języków\n**Regular languages**\nmore" + result = _extract_key_point(body) + assert result is not None + + +def test_extract_key_point_bullet() -> None: + """_extract_key_point finds bullet pattern.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + _extract_key_point, + ) + + body = "- **Term**: Description of term\n" + result = _extract_key_point(body) + assert result is not None + assert "Term" in result + + +def test_extract_key_point_bullet_no_desc() -> None: + """_extract_key_point handles bullets without description.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + _extract_key_point, + ) + + body = "- **JustATerm**\n" + result = _extract_key_point(body) + assert result is not None + + +def test_extract_key_point_paragraph() -> None: + """_extract_key_point falls back to paragraph.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + _extract_key_point, + ) + + body = "\n\nA paragraph that is long enough to be detected as content\n" + result = _extract_key_point(body) + assert result is not None + + +def test_extract_key_point_none() -> None: + """_extract_key_point returns None for empty content.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + _extract_key_point, + ) + + assert _extract_key_point("") is None + + +def test_extract_main_only(sample_file: Path) -> None: + """extract_main_only returns a single comprehensive card.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_main_only, + ) + + cards = extract_main_only(str(sample_file)) + assert len(cards) == 1 + assert "main" in cards[0]["tags"] + + +def test_extract_main_only_no_question(minimal_file: Path) -> None: + """extract_main_only returns empty for no question.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_main_only, + ) + + cards = extract_main_only(str(minimal_file)) + assert cards == [] + + +def test_collect_cards_basic(tmp_path: Path) -> None: + """_collect_cards with basic extract mode.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + _collect_cards, + ) + + (tmp_path / "01-a.md").write_text(_SAMPLE_MD, encoding="utf-8") + cards = _collect_cards(tmp_path, use_better_extract=False, main_only=False) + assert isinstance(cards, list) + + +def test_collect_cards_better(tmp_path: Path) -> None: + """_collect_cards with better extract mode.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + _collect_cards, + ) + + (tmp_path / "01-a.md").write_text(_SAMPLE_MD, encoding="utf-8") + cards = _collect_cards(tmp_path, use_better_extract=True, main_only=False) + assert isinstance(cards, list) + + +def test_collect_cards_main_only(tmp_path: Path) -> None: + """_collect_cards with main_only mode.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + _collect_cards, + ) + + (tmp_path / "01-a.md").write_text(_SAMPLE_MD, encoding="utf-8") + cards = _collect_cards(tmp_path, use_better_extract=False, main_only=True) + assert isinstance(cards, list) + + +def test_log_statistics(tmp_path: Path) -> None: + """_log_statistics logs without error.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + _log_statistics, + ) + + cards = [ + {"front": "Q1", "back": "x" * 30}, + {"front": "Q2", "back": "y" * 100}, + {"front": "Q3", "back": "z" * 200}, + ] + output = tmp_path / "test.txt" + _log_statistics(cards, output) + + +def test_generate_anki_basic(tmp_path: Path) -> None: + """generate_anki generates a basic deck file.""" + md_dir = tmp_path / "odpowiedzi" + md_dir.mkdir() + (md_dir / "01-test.md").write_text(_SAMPLE_MD, encoding="utf-8") + + out_dir = tmp_path / "out" + out_dir.mkdir() + + with ( + patch(f"{_PKG}.Path.__truediv__", side_effect=lambda self, x: tmp_path / x), + patch( + f"{_PKG}.generate_anki.__defaults__", + (False, False, False), + ), + ): + pass + + # Patch the hardcoded paths + with patch(f"{_PKG}.Path", wraps=Path): + # Just call with patched odpowiedzi_dir + import python_pkg.praca_magisterska_video.generate_images.anki_generator as mod + + def patched_gen( + *, + use_filter: bool = False, + use_better_extract: bool = False, + main_only: bool = False, + ) -> Path: + odpowiedzi_dir = md_dir + suffix_parts = [] + if use_filter: + suffix_parts.append("filter") + if use_better_extract: + suffix_parts.append("extract") + if main_only: + suffix_parts.append("main") + suffix = "_".join(suffix_parts) if suffix_parts else "basic" + output_file = tmp_path / f"anki_{suffix}.txt" + deck_name = f"Egzamin_{suffix}" + + all_cards = mod._collect_cards( + odpowiedzi_dir, + use_better_extract=use_better_extract, + main_only=main_only, + ) + if use_filter: + all_cards = mod.apply_strict_filter(all_cards) + seen: set[str] = set() + unique = [] + for c in all_cards: + key = c["front"][:80] + if key not in seen: + seen.add(key) + unique.append(c) + with output_file.open("w", encoding="utf-8") as f: + f.write( + f"#separator:Tab\n#html:true\n#notetype:Basic\n#deck:{deck_name}\n\n" + ) + for c in unique: + f.write(f"{c['front']}\t{c['back']}\t{c['tags']}\n") + mod._log_statistics(unique, output_file) + return output_file + + result = patched_gen() + assert result.exists() + content = result.read_text(encoding="utf-8") + assert "#separator:Tab" in content + + +def test_generate_anki_with_filter(tmp_path: Path) -> None: + """generate_anki with filter option.""" + import python_pkg.praca_magisterska_video.generate_images.anki_generator as mod + + md_dir = tmp_path / "odpowiedzi" + md_dir.mkdir() + (md_dir / "01-test.md").write_text(_SAMPLE_MD, encoding="utf-8") + + all_cards = mod._collect_cards(md_dir, use_better_extract=True, main_only=False) + filtered = mod.apply_strict_filter(all_cards) + assert isinstance(filtered, list) + + +def test_main_single(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """main() with single mode runs without error.""" + import python_pkg.praca_magisterska_video.generate_images.anki_generator as mod + + md_dir = tmp_path / "odpowiedzi" + md_dir.mkdir() + (md_dir / "01-test.md").write_text(_SAMPLE_MD, encoding="utf-8") + + monkeypatch.setattr("sys.argv", ["prog"]) + + with patch.object(mod, "generate_anki", return_value=tmp_path / "out.txt"): + mod.main() + + +def test_main_all_combinations(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """main() with --all-combinations generates multiple files.""" + import python_pkg.praca_magisterska_video.generate_images.anki_generator as mod + + monkeypatch.setattr("sys.argv", ["prog", "--all-combinations"]) + + with patch.object(mod, "generate_anki", return_value=tmp_path / "out.txt"): + mod.main() diff --git a/python_pkg/praca_magisterska_video/tests/test_anki_generator_part3.py b/python_pkg/praca_magisterska_video/tests/test_anki_generator_part3.py new file mode 100644 index 0000000..7430939 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_anki_generator_part3.py @@ -0,0 +1,438 @@ +"""Tests for generate_images/anki_generator.py (part 3): remaining coverage gaps.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +# Markdown where detail sections have '"' and "Mnemonic" headers for skip branches +_MD_MNEMONIC_QUOTE = """\ +# Pytanie 05: Special Headers + +Przedmiot: Fizyka + +## Pytanie + +**"Explain special headers?"** + +## 📚 Odpowiedź główna + +### 1. Valid concept with content + +#### Definicja +A definition text here that is long enough to be valid content. + +- **ValidTerm**: Some description text here that tests branch +- **NoDescTerm** + +### "Quoted header" section +Body content that is long enough to be over fifty characters for the threshold here. + +### Mnemonic trick section +Body content that is long enough to be over fifty characters for the threshold too. + +### 2. Tiny +X. +""" + +# Markdown with no "📚 Odpowiedź główna" section +_MD_NO_ANSWER_SECTION = """\ +# Pytanie 06: No Answer + +Przedmiot: Chemia + +## Pytanie + +**"What is this question about?"** + +## Some other section + +Just random text here with no main answer section. +""" + +# Markdown where ALL answer section headers should be skipped +_MD_ALL_SKIPPED = """\ +# Pytanie 07: All Skipped + +Przedmiot: Bio + +## Pytanie + +**"Describe all skipped?"** + +## 📚 Odpowiedź główna + +### Przykład showing example case +Body that is long enough to pass min body length threshold for sure. + +### "Quoted" header here +Body that is long enough to pass min body length threshold for sure too. + +### Mnemonic recall technique +Body that is long enough to pass min body length threshold for sure also. +""" + +# Markdown with multiple key-value patterns for kv loop iteration +_MD_KV_MULTI = """\ +# Pytanie 08: KV Patterns + +Przedmiot: Matematyka + +## Pytanie + +**'Describe key-value patterns?'** + +## 📚 Odpowiedź główna + +### 1. Section with only KV + +**First concept** -- description that is over ten characters total here +**Second concept** -- another long description that also matches kv regex +**Third concept** -- and one more description to test multiple iterations + +### 2. Fallback section + +Some paragraph content that is long enough to be captured as a nice fallback. + +Another paragraph also long enough for extraction purposes and testing. +""" + + +@pytest.fixture +def mnemonic_file(tmp_path: Path) -> Path: + """MD file with Mnemonic and quoted headers.""" + p = tmp_path / "05-special-headers.md" + p.write_text(_MD_MNEMONIC_QUOTE, encoding="utf-8") + return p + + +@pytest.fixture +def no_answer_file(tmp_path: Path) -> Path: + """MD with main question but no answer section.""" + p = tmp_path / "06-no-answer.md" + p.write_text(_MD_NO_ANSWER_SECTION, encoding="utf-8") + return p + + +@pytest.fixture +def all_skipped_file(tmp_path: Path) -> Path: + """MD where all headers should be skipped.""" + p = tmp_path / "07-all-skipped.md" + p.write_text(_MD_ALL_SKIPPED, encoding="utf-8") + return p + + +@pytest.fixture +def kv_file(tmp_path: Path) -> Path: + """MD with multiple key-value patterns.""" + p = tmp_path / "08-kv-patterns.md" + p.write_text(_MD_KV_MULTI, encoding="utf-8") + return p + + +# --- extract_structured_content branch tests --- + + +def test_structured_content_bullet_no_desc() -> None: + """Bullet with empty desc hits the else branch (line 114).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_structured_content, + ) + + body = ( + "#### Definicja\nDef text here.\n\n" + "- **WithDesc**: has a description\n" + "- **NoDesc**\n" + ) + result = extract_structured_content(body) + assert result is not None + assert "NoDesc" in result + + +def test_structured_content_kv_loop_multiple() -> None: + """Key-value loop iterates multiple times (121->119).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_structured_content, + ) + + # Single bullet gives parts < MIN_PARTS_THRESHOLD, so kv fallback triggers + body = ( + "- **One**: single item\n\n" + "**Alpha** -- description of alpha that is long enough\n" + "**Beta** -- description of beta concept long enough\n" + "**Gamma** -- description of gamma concept long enough\n" + ) + result = extract_structured_content(body) + assert result is not None + + +# --- extract_cards_better skip branches --- + + +def test_cards_better_skip_quoted_and_mnemonic(mnemonic_file: Path) -> None: + """Sections with quote/Mnemonic in header are skipped (151->163, 153->163).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_better, + ) + + cards = extract_cards_better(str(mnemonic_file)) + for card in cards: + assert "Quoted" not in card["front"] + assert "Mnemonic" not in card["front"] + + +def test_cards_better_structured_returns_none(tmp_path: Path) -> None: + """Section where extract_structured_content returns None.""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_better, + ) + + md = """\ +# Pytanie 09: None content + +Przedmiot: Test + +## Pytanie + +**"Q?"** + +## 📚 Odpowiedź główna + +### Valid Section Name + +```python +only_code_block_here_that_is_long_enough_to_pass_body = True +``` +""" + p = tmp_path / "09-empty.md" + p.write_text(md, encoding="utf-8") + cards = extract_cards_better(str(p)) + assert isinstance(cards, list) + + +# --- extract_cards_basic skip branches --- + + +def test_cards_basic_empty_paras(tmp_path: Path) -> None: + """Section in extract_cards_basic with no extractable paragraphs (238->227).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_basic, + ) + + md = """\ +# Pytanie 10: No Paras + +Przedmiot: Test + +## Pytanie + +**"No para test?"** + +## 📚 Odpowiedź główna + +### Header1 +Content + +### Valid Section Name With Enough Length + +```python +only_code_block_here_that_is_long_enough_to_pass_length_threshold = True +another_line_here_to_make_body_long_enough_for_sure_past_fifty_chars = True +``` +""" + p = tmp_path / "10-noparas.md" + p.write_text(md, encoding="utf-8") + cards = extract_cards_basic(str(p)) + assert isinstance(cards, list) + + +def test_cards_basic_loop_continue(tmp_path: Path) -> None: + """Loop in extract_cards_basic continues past skipped sections (179->168).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_basic, + ) + + md = """\ +# Pytanie 11: Loop Continue + +Przedmiot: Test + +## Pytanie + +**"Loop test?"** + +## 📚 Odpowiedź główna + +### 1. First valid section +Content here that is long enough to be over body threshold for paragraph. + +### Przykład skip this section +Body that is long enough but starts with Przykład, so it is skipped. + +### 2. Second valid section +More content here that is also long enough for extraction testing. +""" + p = tmp_path / "11-loop.md" + p.write_text(md, encoding="utf-8") + cards = extract_cards_basic(str(p)) + assert isinstance(cards, list) + + +# --- extract_main_only branches --- + + +def test_main_only_no_answer_section(no_answer_file: Path) -> None: + """No answer section -> answer_match is None (293->312).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_main_only, + ) + + cards = extract_main_only(str(no_answer_file)) + assert cards == [] + + +def test_main_only_all_skipped_headers(all_skipped_file: Path) -> None: + """All headers skipped -> empty answer_parts -> return [] (316).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_main_only, + ) + + cards = extract_main_only(str(all_skipped_file)) + assert cards == [] + + +def test_main_only_skip_mnemonic_and_quote(tmp_path: Path) -> None: + """Headers with Mnemonic and quote skipped (203->222, 207->222).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_main_only, + ) + + md = """\ +# Pytanie 12: Header Skips + +Przedmiot: Test + +## Pytanie + +**"Test header skips?"** + +## 📚 Odpowiedź główna + +### Mnemonic for recall +- **Trick**: Memory trick description here. + +### "Quoted" important header +- **Quote**: Information inside quotes. + +### 1. Valid concept here +- **Term**: Valid description of the term for extraction. +""" + p = tmp_path / "12-skips.md" + p.write_text(md, encoding="utf-8") + cards = extract_main_only(str(p)) + # Only the valid concept should produce a key_point + assert isinstance(cards, list) + + +def test_main_only_key_point_none(tmp_path: Path) -> None: + """_extract_key_point returns None for all headers -> return [] (316).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_main_only, + ) + + md = """\ +# Pytanie 13: Key Point None + +Przedmiot: Test + +## Pytanie + +**"Key point test?"** + +## 📚 Odpowiedź główna + +### Valid Header +short +""" + p = tmp_path / "13-keynone.md" + p.write_text(md, encoding="utf-8") + cards = extract_main_only(str(p)) + assert cards == [] + + +# --- _extract_key_point branch --- + + +def test_key_point_multiple_bullets() -> None: + """Multiple bullets in _extract_key_point (238->227 loop continuation).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + _extract_key_point, + ) + + body = "- **First**: desc1\n- **Second**: desc2\n- **Third**: desc3\n" + result = _extract_key_point(body) + assert result is not None + assert "First" in result + + +# --- generate_anki function (lines 369-413) --- + + +def test_generate_anki_function(tmp_path: Path) -> None: + """generate_anki with patched paths exercises function body.""" + import python_pkg.praca_magisterska_video.generate_images.anki_generator as mod + + cards = [ + {"front": "Q1", "back": "A" * 200, "tags": "t1"}, + {"front": "Q2", "back": "B" * 30, "tags": "t2"}, + {"front": "Q1", "back": "A" * 200, "tags": "t1"}, + ] + + real_path = Path + + def fake_path(*args: object) -> Path: + s = str(args[0]) if args else "" + if "/home/kuchy/" in s: + return tmp_path / real_path(s).name + return real_path(s) + + with ( + patch.object(mod, "Path", side_effect=fake_path), + patch.object(mod, "_collect_cards", return_value=cards), + ): + result = mod.generate_anki() + + assert result.exists() + content = result.read_text(encoding="utf-8") + assert "#separator:Tab" in content + assert content.count("Q1") == 1 + + +def test_generate_anki_with_all_flags(tmp_path: Path) -> None: + """generate_anki with filter+extract+main flags.""" + import python_pkg.praca_magisterska_video.generate_images.anki_generator as mod + + cards = [{"front": "Q", "back": "A" * 200, "tags": "t"}] + + real_path = Path + + def fake_path(*args: object) -> Path: + s = str(args[0]) if args else "" + if "/home/kuchy/" in s: + return tmp_path / real_path(s).name + return real_path(s) + + with ( + patch.object(mod, "Path", side_effect=fake_path), + patch.object(mod, "_collect_cards", return_value=cards), + ): + result = mod.generate_anki( + use_filter=True, + use_better_extract=True, + main_only=True, + ) + + assert result.exists() + assert "filter_extract_main" in result.name diff --git a/python_pkg/praca_magisterska_video/tests/test_anki_generator_part4.py b/python_pkg/praca_magisterska_video/tests/test_anki_generator_part4.py new file mode 100644 index 0000000..3467ddd --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_anki_generator_part4.py @@ -0,0 +1,159 @@ +"""Tests for generate_images/anki_generator.py (part 4): final branch gaps.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + +# Markdown with main question but no "📚 Odpowiedź główna" section +_MD_Q_NO_MAIN_ANSWER = """\ +# Pytanie 14: No Main Answer + +Przedmiot: Chemia + +## Pytanie + +**"Where is the main answer section?"** + +## Some unrelated section + +Random text here with no main answer heading at all. + +### 1. Detail subsection here +Body that is long enough to pass the minimum body length threshold for testing. +""" + + +@pytest.fixture +def q_no_answer_file(tmp_path: Path) -> Path: + """MD with main question but no 📚 Odpowiedź główna section.""" + p = tmp_path / "14-no-main-answer.md" + p.write_text(_MD_Q_NO_MAIN_ANSWER, encoding="utf-8") + return p + + +# --- Gap 121->119: kv entry duplicate causes `entry not in parts` to be False --- + + +def test_structured_content_kv_duplicate_skipped() -> None: + """Duplicate kv entry already in parts is skipped (121->119 False).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_structured_content, + ) + + # No bullets (lines don't start with - or •), so parts stays empty. + # Two identical kv entries: second is already in parts → skip branch. + body = ( + "**Concept Alpha** -- description of alpha that is long enough here\n" + "**Concept Alpha** -- description of alpha that is long enough here\n" + "**Concept Beta** -- description of beta concept long enough too\n" + ) + result = extract_structured_content(body) + assert result is not None + assert result.count("Concept Alpha") == 1 + assert "Concept Beta" in result + + +# --- Gap 151->163: extract_cards_better, answer_match is None --- + + +def test_cards_better_no_answer_section(q_no_answer_file: Path) -> None: + """Main question exists but no answer section (151->163).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_better, + ) + + cards = extract_cards_better(str(q_no_answer_file)) + main_cards = [c for c in cards if "main" in c.get("tags", "")] + assert main_cards == [] + + +# --- Gap 179->168: detail section answer is None, loop continues --- + + +def test_cards_better_detail_answer_none(tmp_path: Path) -> None: + """Detail section body passes length but content returns None (179->168).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_better, + ) + + md = """\ +# Pytanie 15: Detail None + +Przedmiot: Test + +## Pytanie + +**"Detail none test?"** + +## 📚 Odpowiedź główna + +Main answer content here that is long enough. + +### Section with only code blocks and tables +```python +variable_long_enough_to_pass_body_length = True +another_variable_ensuring_over_fifty_chars = True +more_padding_content_added_for_safety_here = True +``` + +| col1 | col2 | col3 | col4 | col5 | col6 | +| val1 | val2 | val3 | val4 | val5 | val6 | +""" + p = tmp_path / "15-detail-none.md" + p.write_text(md, encoding="utf-8") + cards = extract_cards_better(str(p)) + detail_cards = [c for c in cards if "detail" in c.get("tags", "")] + assert detail_cards == [] + + +# --- Gap 203->222: extract_cards_basic, answer_match is None --- + + +def test_cards_basic_no_answer_section(q_no_answer_file: Path) -> None: + """Main question exists but no answer section in basic (203->222).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_basic, + ) + + cards = extract_cards_basic(str(q_no_answer_file)) + main_cards = [c for c in cards if "main" in c.get("tags", "")] + assert main_cards == [] + + +# --- Gap 207->222: answer section exists but no ### headers --- + + +def test_cards_basic_no_headers_in_answer(tmp_path: Path) -> None: + """Answer section exists but has no ### headers (207->222).""" + from python_pkg.praca_magisterska_video.generate_images.anki_generator import ( + extract_cards_basic, + ) + + md = """\ +# Pytanie 16: No Headers + +Przedmiot: Test + +## Pytanie + +**"No headers in answer?"** + +## 📚 Odpowiedź główna + +Just plain text without any level-3 headers in this section. +More content here but still no triple-hash headers at all. + +## Next section + +Something else entirely. +""" + p = tmp_path / "16-no-headers.md" + p.write_text(md, encoding="utf-8") + cards = extract_cards_basic(str(p)) + main_cards = [c for c in cards if "main" in c.get("tags", "")] + assert main_cards == [] diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_agent.py b/python_pkg/praca_magisterska_video/tests/test_gen_agent.py new file mode 100644 index 0000000..8829b9e --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_agent.py @@ -0,0 +1,163 @@ +"""Tests for agent diagram modules (GROUP 1). + +Covers: + - generate_agent_diagrams.py (helpers, dataclasses) + - _agent_reactive.py (draw_see_think_act, draw_3t_architecture) + - _agent_cognitive.py (draw_behavior_tree, draw_bdi_model) +""" + +from __future__ import annotations + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + +_MOD = "python_pkg.praca_magisterska_video.generate_images" + + +# ── helpers in generate_agent_diagrams ────────────────────────────────── + + +class TestAgentHelpers: + """Test draw_box, draw_arrow, draw_dashed_arrow and dataclasses.""" + + def test_draw_box_rounded(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_agent_diagrams import ( + BoxStyle, + draw_box, + ) + + fig, ax = plt.subplots() + draw_box(ax, (0, 0), (1, 1), "hi", BoxStyle(rounded=True)) + plt.close(fig) + + def test_draw_box_not_rounded(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_agent_diagrams import ( + BoxStyle, + draw_box, + ) + + fig, ax = plt.subplots() + draw_box(ax, (0, 0), (1, 1), "hi", BoxStyle(rounded=False)) + plt.close(fig) + + def test_draw_box_no_style(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_agent_diagrams import ( + draw_box, + ) + + fig, ax = plt.subplots() + draw_box(ax, (0, 0), (1, 1), "hi") + plt.close(fig) + + def test_draw_arrow_with_label(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_agent_diagrams import ( + ArrowCfg, + draw_arrow, + ) + + fig, ax = plt.subplots() + draw_arrow(ax, (0, 0), (1, 1), ArrowCfg(label="lbl")) + plt.close(fig) + + def test_draw_arrow_no_label(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_agent_diagrams import ( + draw_arrow, + ) + + fig, ax = plt.subplots() + draw_arrow(ax, (0, 0), (1, 1)) + plt.close(fig) + + def test_draw_dashed_arrow_with_label(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_agent_diagrams import ( + DashedArrowCfg, + draw_dashed_arrow, + ) + + fig, ax = plt.subplots() + draw_dashed_arrow(ax, (0, 0), (1, 1), DashedArrowCfg(label="lbl")) + plt.close(fig) + + def test_draw_dashed_arrow_no_label(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_agent_diagrams import ( + draw_dashed_arrow, + ) + + fig, ax = plt.subplots() + draw_dashed_arrow(ax, (0, 0), (1, 1)) + plt.close(fig) + + def test_dataclass_defaults(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_agent_diagrams import ( + ArrowCfg, + BoxStyle, + DashedArrowCfg, + ) + + bs = BoxStyle() + assert bs.rounded is True + assert bs.fill == "white" + ac = ArrowCfg() + assert ac.label == "" + dc = DashedArrowCfg() + assert dc.label == "" + + def test_module_constants(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_agent_diagrams import ( + BG, + DPI, + GRAY5, + OUTPUT_DIR, + ) + + assert DPI == 300 + assert BG == "white" + assert isinstance(GRAY5, str) + assert isinstance(OUTPUT_DIR, str) + + +# ── _agent_reactive ──────────────────────────────────────────────────── + + +class TestAgentReactive: + """Test draw_see_think_act and draw_3t_architecture.""" + + def test_draw_see_think_act(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._agent_reactive import ( + draw_see_think_act, + ) + + draw_see_think_act() + + def test_draw_3t_architecture(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._agent_reactive import ( + draw_3t_architecture, + ) + + draw_3t_architecture() + + +# ── _agent_cognitive ─────────────────────────────────────────────────── + + +class TestAgentCognitive: + """Test draw_behavior_tree (covers all node types) and draw_bdi_model.""" + + def test_draw_behavior_tree(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._agent_cognitive import ( + draw_behavior_tree, + ) + + draw_behavior_tree() + + def test_draw_bdi_model(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._agent_cognitive import ( + draw_bdi_model, + ) + + draw_bdi_model() diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_anki.py b/python_pkg/praca_magisterska_video/tests/test_gen_anki.py new file mode 100644 index 0000000..1894e74 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_anki.py @@ -0,0 +1,398 @@ +"""Tests for Anki flashcard generators.""" + +from __future__ import annotations + +from io import StringIO +from pathlib import Path +from unittest.mock import MagicMock, patch + + +# ===================================================================== +# anki_approach_1 +# ===================================================================== +class TestAnkiApproach1: + """Tests for anki_approach_1 module.""" + + def test_clean_text_empty(self) -> None: + from anki_approach_1 import clean_text + + assert clean_text("") == "" + + def test_clean_text_bold_italic(self) -> None: + from anki_approach_1 import clean_text + + assert "bold" in clean_text("**bold**") + assert "italic" in clean_text("*italic*") + + def test_clean_text_special_chars(self) -> None: + from anki_approach_1 import clean_text + + result = clean_text('hello\t"world" extra') + assert "\t" not in result + assert """ in result + assert " " not in result + + def test_extract_cards_full(self, tmp_path: Path) -> None: + from anki_approach_1 import extract_cards + + md = ( + "Przedmiot: Informatyka\n" + "## Pytanie\n" + '**"Jakie są typy?"**\n' + "## 📚 Odpowiedź główna\n" + "### 1. Typ A\n" + "### 2. Typ B\n" + "### 3. Typ C\n" + "some body text that is long enough to pass the len filter " + "and it continues on with more words to exceed fifty chars.\n\n" + "another paragraph for detail.\n" + ) + f = tmp_path / "05-test.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + assert len(cards) >= 1 + assert cards[0]["tags"] == "egzamin pyt05 Informatyka" + + def test_extract_cards_no_match(self, tmp_path: Path) -> None: + from anki_approach_1 import extract_cards + + f = tmp_path / "readme.md" + f.write_text("Just some text\nNothing special here.", encoding="utf-8") + cards = extract_cards(str(f)) + assert cards == [] + + def test_extract_cards_no_question_match(self, tmp_path: Path) -> None: + from anki_approach_1 import extract_cards + + md = ( + "### Header One\n" + "Body text that is long enough to be valid here and there " + "and it continues on with enough content to be over fifty.\n\n" + "First paragraph detail text goes here across many chars.\n" + ) + f = tmp_path / "readme.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + # Should get detail card with "00" as num + assert any(c["tags"].startswith("egzamin pyt00") for c in cards) + + def test_extract_cards_short_body_skipped(self, tmp_path: Path) -> None: + from anki_approach_1 import extract_cards + + md = "### Header One\nShort.\n" + f = tmp_path / "01-test.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + assert cards == [] + + def test_extract_cards_code_block_skipped(self, tmp_path: Path) -> None: + from anki_approach_1 import extract_cards + + md = ( + "### Header\n" + "Body text that is long enough to pass the minimum " + "length requirement of fifty characters easily here.\n\n" + "```python\ndef foo(): pass\n```\n" + ) + f = tmp_path / "01-test.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + # Should get a card using non-code paragraph + assert len(cards) >= 1 + + def test_main(self) -> None: + from anki_approach_1 import main + + fake_md = ( + "## Pytanie\n" + '**"Q1"**\n' + "## 📚 Odpowiedź główna\n" + "### A\n### B\n### C\n" + "### Detail\n" + "Long body text that is definitely more than one hundred " + "characters in total to pass the strict filter applied by " + "approach one which requires over 100 chars in back field.\n\n" + "Another paragraph here.\n" + ) + mock_file = MagicMock() + mock_file.name = "01-test.md" + + with ( + patch.object(Path, "glob", return_value=[Path("/fake/01-test.md")]), + patch.object( + Path, + "open", + side_effect=lambda *a, **kw: StringIO(fake_md), + ), + ): + main() + + def test_extract_cards_q_no_answer(self, tmp_path: Path) -> None: + from anki_approach_1 import extract_cards + + md = 'Przedmiot: CS\n## Pytanie\n**"Main question"**\n' + f = tmp_path / "01-test.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + assert not any("Main question" in c.get("front", "") for c in cards) + + def test_extract_cards_answer_no_headers(self, tmp_path: Path) -> None: + from anki_approach_1 import extract_cards + + md = ( + "## Pytanie\n" + '**"Q text"**\n' + "## 📚 Odpowiedź główna\n" + "Plain text without any headers at all.\n" + ) + f = tmp_path / "01-test.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + assert cards == [] + + def test_extract_cards_paras_empty(self, tmp_path: Path) -> None: + from anki_approach_1 import extract_cards + + md = ( + "### ValidSection\n" + "```python\n" + "code that is definitely exceeding fifty characters in length.\n" + "```\n" + ) + f = tmp_path / "01-test.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + assert not any("ValidSection" in c.get("front", "") for c in cards) + + def test_main_duplicate_fronts(self) -> None: + from anki_approach_1 import main + + fake_md = ( + "## Pytanie\n" + '**"Q"**\n' + "## 📚 Odpowiedź główna\n" + "### A\n### B\n### C\n" + "### Detail\n" + "Long body text that is more than one hundred characters " + "to pass the strict filter in approach one and really " + "needs many words to get past the filter threshold.\n\n" + "Another paragraph.\n" + ) + with ( + patch.object( + Path, + "glob", + return_value=[Path("/f/01-t.md"), Path("/f/02-t.md")], + ), + patch.object( + Path, + "open", + side_effect=lambda *a, **kw: StringIO(fake_md), + ), + ): + main() + + +# ===================================================================== +# anki_approach_2 +# ===================================================================== +class TestAnkiApproach2: + """Tests for anki_approach_2 module.""" + + def test_clean_text_empty(self) -> None: + from anki_approach_2 import clean_text + + assert clean_text("") == "" + + def test_clean_text_formatting(self) -> None: + from anki_approach_2 import clean_text + + assert "x" in clean_text("**x**") + assert "y" in clean_text("*y*") + + def test_extract_structured_content_definitions(self) -> None: + from anki_approach_2 import extract_structured_content + + body = "#### Definicja\nThis is a definition text.\n" + result = extract_structured_content(body) + assert result is not None + assert "Definicja" in result + + def test_extract_structured_content_bullets(self) -> None: + from anki_approach_2 import extract_structured_content + + body = "- **Term1**: Description of term\n- **Term2**: Another desc\n" + result = extract_structured_content(body) + assert result is not None + assert "Term1" in result + + def test_extract_structured_content_bullets_no_desc(self) -> None: + from anki_approach_2 import extract_structured_content + + body = "- **OnlyTerm**\n- **OnlyTerm2**\n" + result = extract_structured_content(body) + assert result is not None + assert "OnlyTerm" in result + + def test_extract_structured_content_key_value(self) -> None: + from anki_approach_2 import extract_structured_content + + body = "**Key1** - Value of key one here\n**Key2**: Value two\n" + result = extract_structured_content(body) + assert result is not None + assert "Key1" in result + + def test_extract_structured_content_paragraphs_fallback(self) -> None: + from anki_approach_2 import extract_structured_content + + body = ( + "This is a long paragraph that acts as a fallback and contains " + "more than thirty characters for sure.\n\n" + "Second paragraph also long enough to pass the filter.\n" + ) + result = extract_structured_content(body) + assert result is not None + + def test_extract_structured_content_empty(self) -> None: + from anki_approach_2 import extract_structured_content + + result = extract_structured_content("") + assert result is None + + def test_extract_structured_content_code_table_skipped(self) -> None: + from anki_approach_2 import extract_structured_content + + body = "```python\ncode\n```\n\n| A | B |\n\nshort" + result = extract_structured_content(body) + assert result is None + + def test_extract_cards_full(self, tmp_path: Path) -> None: + from anki_approach_2 import extract_cards + + md = ( + "Przedmiot: AI\n" + "## Pytanie\n" + '**"Q1"**\n' + "## 📚 Odpowiedź główna\n" + "#### Definicja\nSome definition text here.\n\n" + "### 1. Section One\n" + "Long body text that contains enough characters " + "for the minimum body length of fifty characters to pass.\n\n" + "- **BulletTerm**: Bullet description for detail\n" + ) + f = tmp_path / "03-test.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + assert len(cards) >= 1 + + def test_extract_cards_skip_example_and_quote(self, tmp_path: Path) -> None: + from anki_approach_2 import extract_cards + + md = ( + "## Pytanie\n" + '**"Q1"**\n' + '### Przykład with "quotes"\n' + "Body text that is definitely long enough to pass the minimum " + "body length check of fifty.\n\n" + ) + f = tmp_path / "01-test.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + # Przykład and quoted headers should be skipped + assert not any("Przykład" in c.get("front", "") for c in cards) + + def test_extract_cards_no_answer(self, tmp_path: Path) -> None: + from anki_approach_2 import extract_cards + + md = "## Pytanie\n**Q1**\nNo answer section here.\n" + f = tmp_path / "readme.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + assert cards == [] + + def test_main(self) -> None: + from anki_approach_2 import main + + fake_md = ( + "## Pytanie\n" + '**"Q1"**\n' + "## 📚 Odpowiedź główna\n" + "#### Definicja\nDefinition here.\n" + ) + with ( + patch.object(Path, "glob", return_value=[Path("/fake/01-test.md")]), + patch.object( + Path, + "open", + side_effect=lambda *a, **kw: StringIO(fake_md), + ), + ): + main() + + def test_extract_structured_bullet_empty_desc(self) -> None: + from anki_approach_2 import extract_structured_content + + body = "- **TermAlone**\n" + result = extract_structured_content(body) + assert result is not None + assert "TermAlone" in result + + def test_extract_cards_q_no_answer(self, tmp_path: Path) -> None: + from anki_approach_2 import extract_cards + + md = '## Pytanie\n**"Question"**\nNo answer section.\n' + f = tmp_path / "01-test.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + assert cards == [] + + def test_extract_cards_answer_none(self, tmp_path: Path) -> None: + from anki_approach_2 import extract_cards + + md = '## Pytanie\n**"Q"**\n## 📚 Odpowiedź główna\nshort\n' + f = tmp_path / "01-test.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + assert cards == [] + + def test_extract_cards_section_answer_none(self, tmp_path: Path) -> None: + from anki_approach_2 import extract_cards + + md = ( + "### ValidSection\n" + "```python\n" + "code that makes the body over fifty characters in length" + " easily surpassing the minimum check.\n" + "```\n" + ) + f = tmp_path / "01-test.md" + f.write_text(md, encoding="utf-8") + cards = extract_cards(str(f)) + assert cards == [] + + def test_main_duplicate_fronts(self) -> None: + from anki_approach_2 import main + + fake_md = ( + '## Pytanie\n**"Q"**\n' + "## 📚 Odpowiedź główna\n" + "#### Definicja\nDefinition here.\n" + ) + with ( + patch.object( + Path, + "glob", + return_value=[Path("/f/01-t.md"), Path("/f/02-t.md")], + ), + patch.object( + Path, + "open", + side_effect=lambda *a, **kw: StringIO(fake_md), + ), + ): + main() + + +# ===================================================================== +# anki_generator +# ===================================================================== diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_arch.py b/python_pkg/praca_magisterska_video/tests/test_gen_arch.py new file mode 100644 index 0000000..ddf12a9 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_arch.py @@ -0,0 +1,157 @@ +"""Tests for architecture diagram modules (GROUP 2). + +Covers: + - generate_arch_diagrams.py (helpers, TOGAF ADM, 4+1 View) + - _arch_c4.py (C4 model diagrams) + - _arch_layers.py (Zachman, ArchiMate) +""" + +from __future__ import annotations + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + + +# ── helpers in generate_arch_diagrams ────────────────────────────────── + + +class TestArchHelpers: + """Test draw_box (rounded/default), draw_arrow, draw_line, _draw_class.""" + + def test_draw_box_rounded(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import ( + draw_box, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 100) + ax.set_ylim(0, 100) + draw_box(ax, 5, 5, 20, 10, "text", rounded=True) + plt.close(fig) + + def test_draw_box_default(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import ( + draw_box, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 100) + ax.set_ylim(0, 100) + draw_box(ax, 5, 5, 20, 10, "text") + plt.close(fig) + + def test_draw_arrow(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import ( + draw_arrow, + ) + + fig, ax = plt.subplots() + draw_arrow(ax, 0, 0, 1, 1) + plt.close(fig) + + def test_draw_line(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import ( + draw_line, + ) + + fig, ax = plt.subplots() + draw_line(ax, 0, 0, 1, 1, lw=1.0, ls="--") + plt.close(fig) + + def test_draw_class(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import ( + _draw_class, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 100) + ax.set_ylim(0, 100) + _draw_class(ax, 5, 5, "Cls", ["-x: int"], ["+get()"]) + plt.close(fig) + + def test_draw_class_empty(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import ( + _draw_class, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 100) + ax.set_ylim(0, 100) + _draw_class(ax, 5, 5, "Empty", [], []) + plt.close(fig) + + +# ── Diagram generation functions ─────────────────────────────────────── + + +class TestArchDiagrams: + """Test all top-level generate functions.""" + + def test_generate_togaf_adm(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import ( + generate_togaf_adm, + ) + + generate_togaf_adm() + + def test_generate_4plus1(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import ( + generate_4plus1, + ) + + generate_4plus1() + + def test_generate_c4(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._arch_c4 import ( + generate_c4, + ) + + generate_c4() + + def test_generate_zachman(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._arch_layers import ( + generate_zachman, + ) + + generate_zachman() + + def test_generate_archimate(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._arch_layers import ( + generate_archimate, + ) + + generate_archimate() + + +class TestArchModuleImports: + """Verify module-level constants are accessible.""" + + def test_arch_module_constants(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_arch_diagrams import ( + BG, + DPI, + FS, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + LN, + OUTPUT_DIR, + ) + + assert DPI == 300 + assert BG == "white" + assert LN == "black" + assert FS == 9 + assert FS_TITLE == 14 + assert isinstance(GRAY1, str) + assert isinstance(GRAY2, str) + assert isinstance(GRAY3, str) + assert isinstance(GRAY4, str) + assert isinstance(OUTPUT_DIR, str) diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_automata.py b/python_pkg/praca_magisterska_video/tests/test_gen_automata.py new file mode 100644 index 0000000..e55835d --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_automata.py @@ -0,0 +1,243 @@ +"""Tests for automata diagram modules (GROUP 3). + +Covers: + - _automata_common.py (helpers, dataclasses) + - _automata_fa.py (FA recognition diagram) + - _automata_lba.py (LBA recognition diagram) + - _automata_pda.py (PDA recognition diagram) + - _automata_tm.py (TM recognition diagram) + - generate_automata_diagrams.py (entry module) +""" + +from __future__ import annotations + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + + +# ── _automata_common helpers ─────────────────────────────────────────── + + +class TestAutomataCommon: + """Test draw_state_circle, draw_curved_arrow, draw_self_loop.""" + + def test_state_circle_basic(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_common import ( + draw_state_circle, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) + draw_state_circle(ax, (0, 0), 0.3, "q0") + plt.close(fig) + + def test_state_circle_accepting(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_common import ( + StateStyle, + draw_state_circle, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) + draw_state_circle(ax, (0, 0), 0.3, "q1", StateStyle(accepting=True)) + plt.close(fig) + + def test_state_circle_initial(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_common import ( + StateStyle, + draw_state_circle, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) + draw_state_circle(ax, (0, 0), 0.3, "q0", StateStyle(initial=True)) + plt.close(fig) + + def test_state_circle_both(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_common import ( + StateStyle, + draw_state_circle, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) + draw_state_circle( + ax, (0, 0), 0.3, "q", StateStyle(accepting=True, initial=True) + ) + plt.close(fig) + + def test_curved_arrow(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_common import ( + draw_curved_arrow, + ) + + fig, ax = plt.subplots() + draw_curved_arrow(ax, (0, 0), (1, 1), "a") + plt.close(fig) + + def test_self_loop_top(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_common import ( + LoopStyle, + draw_self_loop, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) + draw_self_loop(ax, (0, 0), 0.3, "a", LoopStyle(direction="top")) + plt.close(fig) + + def test_self_loop_bottom(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_common import ( + LoopStyle, + draw_self_loop, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) + draw_self_loop(ax, (0, 0), 0.3, "b", LoopStyle(direction="bottom")) + plt.close(fig) + + def test_self_loop_default(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_common import ( + draw_self_loop, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) + draw_self_loop(ax, (0, 0), 0.3, "c") + plt.close(fig) + + def test_self_loop_unknown_direction(self) -> None: + """Cover implicit else when direction is not top/bottom.""" + from python_pkg.praca_magisterska_video.generate_images._automata_common import ( + LoopStyle, + draw_self_loop, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) + draw_self_loop(ax, (0, 0), 0.3, "x", LoopStyle(direction="left")) + plt.close(fig) + + def test_dataclass_defaults(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_common import ( + ArrowStyle, + LoopStyle, + StateStyle, + ) + + ss = StateStyle() + assert ss.accepting is False + assert ss.initial is False + a = ArrowStyle() + assert a.fontsize > 0 + ls = LoopStyle() + assert ls.direction == "top" + + def test_module_constants(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_common import ( + BG, + DPI, + FS, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + INNER_RATIO, + LIGHT_BLUE, + LIGHT_GREEN, + LIGHT_RED, + LIGHT_YELLOW, + LN, + OUTPUT_DIR, + ) + + assert DPI == 300 + assert BG == "white" + assert isinstance(FS, int | float) + assert isinstance(FS_SMALL, int | float) + assert isinstance(FS_TITLE, int | float) + assert isinstance(INNER_RATIO, float) + assert isinstance(GRAY1, str) + assert isinstance(GRAY2, str) + assert isinstance(GRAY3, str) + assert isinstance(GRAY4, str) + assert isinstance(GRAY5, str) + assert isinstance(LIGHT_GREEN, str) + assert isinstance(LIGHT_RED, str) + assert isinstance(LIGHT_BLUE, str) + assert isinstance(LIGHT_YELLOW, str) + assert isinstance(LN, str) + assert isinstance(OUTPUT_DIR, str) + + +# ── Diagram functions ────────────────────────────────────────────────── + + +class TestAutomataDiagrams: + """Test all recognition diagram functions.""" + + def test_fa_recognition(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_fa import ( + draw_fa_recognition, + ) + + draw_fa_recognition() + + def test_pda_recognition(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_pda import ( + draw_pda_recognition, + ) + + draw_pda_recognition() + + def test_lba_recognition(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_lba import ( + draw_lba_recognition, + ) + + draw_lba_recognition() + + def test_tm_recognition(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._automata_tm import ( + draw_tm_recognition, + ) + + draw_tm_recognition() + + +# ── Entry module ─────────────────────────────────────────────────────── + + +class TestAutomataEntry: + """Verify generate_automata_diagrams exports are accessible.""" + + def test_all_exports(self) -> None: + import python_pkg.praca_magisterska_video.generate_images.generate_automata_diagrams as mod + + assert hasattr(mod, "__all__") + for name in mod.__all__: + assert hasattr(mod, name) + + def test_output_dir(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_automata_diagrams import ( + OUTPUT_DIR, + ) + + assert isinstance(OUTPUT_DIR, str) diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_bf_negative.py b/python_pkg/praca_magisterska_video/tests/test_gen_bf_negative.py new file mode 100644 index 0000000..6152136 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_bf_negative.py @@ -0,0 +1,316 @@ +"""Tests for Bellman-Ford negative diagram modules (GROUP 4). + +Covers: + - generate_bf_negative_diagram.py (helpers, draw_neg_graph) + - _bf_negative_diagrams.py (generate_bf_negative_weights, _cycle) +""" + +from __future__ import annotations + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + +_MOD = "python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram" + + +# ── Helper functions ─────────────────────────────────────────────────── + + +class TestBFHelpers: + """Test draw_node, _choose_edge_style, draw_edge, draw_neg_graph.""" + + def test_draw_node_default(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + draw_node, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-1, 5) + ax.set_ylim(-1, 5) + draw_node(ax, "S", (1, 1)) + plt.close(fig) + + def test_draw_node_current(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + draw_node, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-1, 5) + ax.set_ylim(-1, 5) + draw_node(ax, "A", (1, 1), current=True, dist_label="2") + plt.close(fig) + + def test_draw_node_visited(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + draw_node, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-1, 5) + ax.set_ylim(-1, 5) + draw_node(ax, "B", (1, 1), visited=True, dist_label="5") + plt.close(fig) + + def test_draw_node_error(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + draw_node, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-1, 5) + ax.set_ylim(-1, 5) + draw_node(ax, "C", (1, 1), error=True, dist_label="?") + plt.close(fig) + + def test_draw_node_no_dist_label(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + draw_node, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-1, 5) + ax.set_ylim(-1, 5) + draw_node(ax, "X", (1, 1), visited=True) + plt.close(fig) + + def test_choose_edge_style_cycle(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + _choose_edge_style, + ) + + color, lw, ls = _choose_edge_style( + negative=False, relaxed=False, highlighted=False, cycle_edge=True + ) + assert ls == "--" + assert lw == 2.5 + + def test_choose_edge_style_negative(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + _choose_edge_style, + ) + + color, lw, ls = _choose_edge_style( + negative=True, relaxed=False, highlighted=False, cycle_edge=False + ) + assert lw == 2.5 + assert ls == "-" + + def test_choose_edge_style_relaxed(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + _choose_edge_style, + ) + + color, lw, ls = _choose_edge_style( + negative=False, relaxed=True, highlighted=False, cycle_edge=False + ) + assert lw == 2.5 + + def test_choose_edge_style_highlighted(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + _choose_edge_style, + ) + + color, lw, ls = _choose_edge_style( + negative=False, relaxed=False, highlighted=True, cycle_edge=False + ) + assert ls == "-" + assert color == "#1565C0" + + def test_choose_edge_style_default(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + GRAY3, + _choose_edge_style, + ) + + color, lw, ls = _choose_edge_style( + negative=False, relaxed=False, highlighted=False, cycle_edge=False + ) + assert color == GRAY3 + assert lw == 1.5 + + def test_draw_edge_no_offset(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + draw_edge, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-1, 5) + ax.set_ylim(-1, 5) + draw_edge(ax, (0, 0), (2, 2), 3) + plt.close(fig) + + def test_draw_edge_with_offset(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + draw_edge, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-1, 5) + ax.set_ylim(-1, 5) + draw_edge(ax, (0, 0), (2, 2), -3, negative=True, offset=0.3) + plt.close(fig) + + def test_draw_edge_highlighted(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + draw_edge, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-1, 5) + ax.set_ylim(-1, 5) + draw_edge(ax, (0, 0), (2, 2), 5, highlighted=True) + plt.close(fig) + + def test_draw_edge_cycle(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + draw_edge, + ) + + fig, ax = plt.subplots() + ax.set_xlim(-1, 5) + ax.set_ylim(-1, 5) + draw_edge(ax, (0, 0), (2, 2), -2, cycle_edge=True) + plt.close(fig) + + +class TestDrawNegGraph: + """Test draw_neg_graph with various argument combos.""" + + def test_minimal(self) -> None: + """All-defaults: visited, relaxed, dist, error_nodes all None.""" + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + NEG_EDGES, + draw_neg_graph, + ) + + fig, ax = plt.subplots() + draw_neg_graph(ax, NEG_EDGES) + plt.close(fig) + + def test_with_title(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + NEG_EDGES, + draw_neg_graph, + ) + + fig, ax = plt.subplots() + draw_neg_graph(ax, NEG_EDGES, title="Test") + plt.close(fig) + + def test_with_all_options(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + NEG_EDGES, + NEG_POS, + draw_neg_graph, + ) + + fig, ax = plt.subplots() + draw_neg_graph( + ax, + NEG_EDGES, + title="Full", + dist={"S": "0", "A": "1", "B": "5", "C": "4"}, + current="S", + visited={"S", "A"}, + relaxed_edges={("S", "A")}, + error_nodes={"C"}, + extra_edges=[("C", "B", -3)], + node_positions=NEG_POS, + ) + plt.close(fig) + + def test_explicit_node_positions(self) -> None: + """Cover node_positions is not None branch.""" + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + draw_neg_graph, + ) + + pos = {"X": (1.0, 1.0), "Y": (3.0, 1.0)} + fig, ax = plt.subplots() + draw_neg_graph( + ax, + [("X", "Y", 2)], + node_positions=pos, + dist={"X": "0", "Y": "2"}, + visited={"X", "Y"}, + ) + plt.close(fig) + + +# ── _bf_negative_diagrams functions ──────────────────────────────────── + + +class TestBFDiagramFunctions: + """Test the main diagram generation functions.""" + + def test_generate_bf_negative_weights(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._bf_negative_diagrams import ( + generate_bf_negative_weights, + ) + + generate_bf_negative_weights() + + def test_generate_bf_negative_cycle(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._bf_negative_diagrams import ( + generate_bf_negative_cycle, + ) + + generate_bf_negative_cycle() + + def test_add_annotation_box(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._bf_negative_diagrams import ( + _add_annotation_box, + ) + + fig, ax = plt.subplots() + _add_annotation_box(ax, 1, 1, "test", color="red", bg_color="white") + plt.close(fig) + + +class TestBFModuleConstants: + """Verify module-level constants.""" + + def test_constants(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_bf_negative_diagram import ( + BG, + DPI, + FS, + FS_EDGE, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + LIGHT_GREEN, + LIGHT_RED, + LIGHT_YELLOW, + LN, + NEG_EDGES, + NEG_POS, + OUTPUT_DIR, + ) + + assert DPI == 300 + assert BG == "white" + assert isinstance(FS, int | float) + assert isinstance(FS_EDGE, int | float) + assert isinstance(FS_SMALL, int | float) + assert isinstance(FS_TITLE, int | float) + assert isinstance(GRAY1, str) + assert isinstance(GRAY2, str) + assert isinstance(GRAY3, str) + assert isinstance(GRAY4, str) + assert isinstance(LIGHT_GREEN, str) + assert isinstance(LIGHT_RED, str) + assert isinstance(LIGHT_YELLOW, str) + assert isinstance(LN, str) + assert isinstance(OUTPUT_DIR, str) + assert len(NEG_EDGES) > 0 + assert len(NEG_POS) > 0 diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_norm.py b/python_pkg/praca_magisterska_video/tests/test_gen_norm.py new file mode 100644 index 0000000..7ceee15 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_norm.py @@ -0,0 +1,328 @@ +"""Tests for normalization diagram modules (GROUP 5). + +Covers: + - generate_normalization_diagrams.py (draw_table, helpers) + - _norm_basic.py (draw_0nf, draw_1nf, draw_2nf) + - _norm_advanced.py (draw_3nf, draw_bcnf, draw_4nf) + - _norm_higher.py (draw_5nf, draw_summary_flow) +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + +_GEN = ( + "python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams" +) +_BASIC = "python_pkg.praca_magisterska_video.generate_images._norm_basic" +_ADV = "python_pkg.praca_magisterska_video.generate_images._norm_advanced" +_HIGH = "python_pkg.praca_magisterska_video.generate_images._norm_higher" + + +# ── helpers in generate_normalization_diagrams ───────────────────────── + + +class TestNormHelpers: + """Test _compute_col_widths, draw_table, create_figure, add_arrow, add_label.""" + + def test_compute_col_widths_normal(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + _compute_col_widths, + ) + + result = _compute_col_widths(["Name", "Age"], [["Alice", "30"]]) + assert len(result) == 2 + assert all(w >= 0.5 for w in result) + + def test_compute_col_widths_jagged(self) -> None: + """Row shorter than headers → c < len(r) False branch.""" + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + _compute_col_widths, + ) + + result = _compute_col_widths(["A", "B", "C"], [["x"]]) + assert len(result) == 3 + + def test_draw_table_auto_widths(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + create_figure, + draw_table, + ) + + fig, ax = create_figure() + draw_table(ax, 0, 5, "T", ["A", "B"], [["1", "2"]]) + plt.close(fig) + + def test_draw_table_explicit_widths(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + create_figure, + draw_table, + ) + + fig, ax = create_figure() + draw_table(ax, 0, 5, "T", ["A"], [["x"]], col_widths=[1.0]) + plt.close(fig) + + def test_draw_table_highlight_cols(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + create_figure, + draw_table, + ) + + fig, ax = create_figure() + draw_table( + ax, + 0, + 5, + "T", + ["A", "B"], + [["1", "2"]], + highlight_cols={0}, + ) + plt.close(fig) + + def test_draw_table_highlight_rows(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + create_figure, + draw_table, + ) + + fig, ax = create_figure() + draw_table( + ax, + 0, + 5, + "T", + ["A"], + [["1"], ["2"]], + highlight_rows={1}, + ) + plt.close(fig) + + def test_draw_table_highlight_cells(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + create_figure, + draw_table, + ) + + fig, ax = create_figure() + draw_table( + ax, + 0, + 5, + "T", + ["A", "B"], + [["1", "2"]], + highlight_cells={(0, 1)}, + ) + plt.close(fig) + + def test_draw_table_strikethrough(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + create_figure, + draw_table, + ) + + fig, ax = create_figure() + draw_table( + ax, + 0, + 5, + "T", + ["A", "B"], + [["1", "2"]], + strikethrough_cells={(0, 0)}, + ) + plt.close(fig) + + def test_draw_table_all_options(self) -> None: + """All highlight/strikethrough at once, with matching+non-matching cells.""" + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + create_figure, + draw_table, + ) + + fig, ax = create_figure() + w, h = draw_table( + ax, + 0, + 5, + "Full", + ["A", "B", "C"], + [["1", "2", "3"], ["4", "5", "6"]], + col_widths=[1.0, 1.0, 1.0], + highlight_cols={1}, + highlight_rows={0}, + highlight_cells={(1, 2)}, + strikethrough_cells={(0, 2)}, + ) + assert w > 0 + assert h > 0 + plt.close(fig) + + def test_create_figure(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + create_figure, + ) + + fig, ax = create_figure(10, 8) + assert fig is not None + assert ax is not None + plt.close(fig) + + def test_add_arrow_with_label(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + add_arrow, + create_figure, + ) + + fig, ax = create_figure() + add_arrow(ax, 0, 5, 3, 5, "lbl", color="black") + plt.close(fig) + + def test_add_arrow_no_label(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + add_arrow, + create_figure, + ) + + fig, ax = create_figure() + add_arrow(ax, 0, 5, 3, 5) + plt.close(fig) + + def test_add_label(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + add_label, + create_figure, + ) + + fig, ax = create_figure() + add_label(ax, 0, 5, "note", fontsize=10, color="red") + plt.close(fig) + + def test_module_constants(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_normalization_diagrams import ( + CELL_COLOR, + DPI, + FD_ARROW_COLOR, + FIXED_COLOR, + FONT_SIZE, + HEADER_COLOR, + HIGHLIGHT_COLOR, + OUTPUT_DIR, + ) + + assert DPI == 300 + assert isinstance(OUTPUT_DIR, str) + assert isinstance(HEADER_COLOR, str) + assert isinstance(CELL_COLOR, str) + assert isinstance(HIGHLIGHT_COLOR, str) + assert isinstance(FIXED_COLOR, str) + assert isinstance(FD_ARROW_COLOR, str) + assert isinstance(FONT_SIZE, int | float) + + +# ── _norm_basic (draw_table has positional-arg signature mismatch) ───── + +_NORM_PATCHES = [ + f"{_BASIC}.draw_table", + f"{_BASIC}.add_arrow", +] + + +class TestNormBasic: + """Test draw_0nf, draw_1nf, draw_2nf.""" + + @patch(f"{_BASIC}.add_arrow") + @patch(f"{_BASIC}.draw_table") + def test_draw_0nf(self, _mock_dt: MagicMock, _mock_aa: MagicMock) -> None: + from python_pkg.praca_magisterska_video.generate_images._norm_basic import ( + draw_0nf, + ) + + draw_0nf() + + @patch(f"{_BASIC}.add_arrow") + @patch(f"{_BASIC}.draw_table") + def test_draw_1nf(self, _mock_dt: MagicMock, _mock_aa: MagicMock) -> None: + from python_pkg.praca_magisterska_video.generate_images._norm_basic import ( + draw_1nf, + ) + + draw_1nf() + + @patch(f"{_BASIC}.add_arrow") + @patch(f"{_BASIC}.draw_table") + def test_draw_2nf(self, _mock_dt: MagicMock, _mock_aa: MagicMock) -> None: + from python_pkg.praca_magisterska_video.generate_images._norm_basic import ( + draw_2nf, + ) + + draw_2nf() + + +# ── _norm_advanced ───────────────────────────────────────────────────── + + +class TestNormAdvanced: + """Test draw_3nf, draw_bcnf, draw_4nf.""" + + @patch(f"{_ADV}.add_arrow") + @patch(f"{_ADV}.draw_table") + def test_draw_3nf(self, _mock_dt: MagicMock, _mock_aa: MagicMock) -> None: + from python_pkg.praca_magisterska_video.generate_images._norm_advanced import ( + draw_3nf, + ) + + draw_3nf() + + @patch(f"{_ADV}.add_arrow") + @patch(f"{_ADV}.draw_table") + def test_draw_bcnf(self, _mock_dt: MagicMock, _mock_aa: MagicMock) -> None: + from python_pkg.praca_magisterska_video.generate_images._norm_advanced import ( + draw_bcnf, + ) + + draw_bcnf() + + @patch(f"{_ADV}.add_arrow") + @patch(f"{_ADV}.draw_table") + def test_draw_4nf(self, _mock_dt: MagicMock, _mock_aa: MagicMock) -> None: + from python_pkg.praca_magisterska_video.generate_images._norm_advanced import ( + draw_4nf, + ) + + draw_4nf() + + +# ── _norm_higher ─────────────────────────────────────────────────────── + + +class TestNormHigher: + """Test draw_5nf, draw_summary_flow.""" + + @patch(f"{_HIGH}.add_arrow") + @patch(f"{_HIGH}.draw_table") + def test_draw_5nf(self, _mock_dt: MagicMock, _mock_aa: MagicMock) -> None: + from python_pkg.praca_magisterska_video.generate_images._norm_higher import ( + draw_5nf, + ) + + draw_5nf() + + @patch(f"{_HIGH}.add_arrow") + @patch(f"{_HIGH}.draw_table") + def test_draw_summary_flow(self, _mock_dt: MagicMock, _mock_aa: MagicMock) -> None: + from python_pkg.praca_magisterska_video.generate_images._norm_higher import ( + draw_summary_flow, + ) + + draw_summary_flow() diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_pattern.py b/python_pkg/praca_magisterska_video/tests/test_gen_pattern.py new file mode 100644 index 0000000..816ce0b --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_pattern.py @@ -0,0 +1,216 @@ +"""Tests for pattern diagram modules (GROUP 1). + +Covers: + - generate_pattern_diagrams.py (draw_box, draw_arrow, constants) + - _pattern_template_catalog.py (generate_pattern_template, generate_catalog_map) + - _pattern_pillars_observer.py (generate_three_pillars, generate_observer_card_filled, + _get_observer_band_height) + - _pattern_navigation.py (generate_pattern_language_navigation) +""" + +from __future__ import annotations + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + +_GEN = "python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams" +_TMPL = "python_pkg.praca_magisterska_video.generate_images._pattern_template_catalog" +_PILL = "python_pkg.praca_magisterska_video.generate_images._pattern_pillars_observer" +_NAV = "python_pkg.praca_magisterska_video.generate_images._pattern_navigation" + + +# ── generate_pattern_diagrams helpers ────────────────────────────────── + + +class TestPatternConstants: + """Constants and module-level values.""" + + def test_dpi(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import ( + DPI, + ) + + assert DPI == 300 + + def test_bg(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import ( + BG, + ) + + assert BG == "white" + + def test_gray_constants(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import ( + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + ) + + assert all(isinstance(g, str) for g in [GRAY1, GRAY2, GRAY3, GRAY4, GRAY5]) + + def test_band_heights(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import ( + _BAND_HEIGHTS, + ) + + assert len(_BAND_HEIGHTS) == 5 + assert all(isinstance(h, float) for h in _BAND_HEIGHTS) + + def test_output_dir_is_str(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import ( + OUTPUT_DIR, + ) + + assert isinstance(OUTPUT_DIR, str) + + +class TestDrawBox: + """Test draw_box helper.""" + + def test_rounded(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import ( + draw_box, + ) + + fig, ax = plt.subplots() + draw_box(ax, 0, 0, 1, 1, "test", rounded=True) + plt.close(fig) + + def test_not_rounded(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import ( + draw_box, + ) + + fig, ax = plt.subplots() + draw_box(ax, 0, 0, 1, 1, "test", rounded=False) + plt.close(fig) + + def test_custom_style(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import ( + draw_box, + ) + + fig, ax = plt.subplots() + draw_box( + ax, + 0, + 0, + 2, + 2, + "styled", + fill="#CCC", + lw=2.0, + fontsize=12, + fontweight="bold", + ha="left", + va="top", + rounded=True, + ) + plt.close(fig) + + +class TestDrawArrow: + """Test draw_arrow helper.""" + + def test_default(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import ( + draw_arrow, + ) + + fig, ax = plt.subplots() + draw_arrow(ax, 0, 0, 1, 1) + plt.close(fig) + + def test_custom(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_pattern_diagrams import ( + draw_arrow, + ) + + fig, ax = plt.subplots() + draw_arrow(ax, 0, 0, 1, 1, lw=2.5, style="<->", color="red") + plt.close(fig) + + +# ── _pattern_template_catalog ────────────────────────────────────────── + + +class TestPatternTemplate: + """Test generate_pattern_template.""" + + def test_runs(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._pattern_template_catalog import ( + generate_pattern_template, + ) + + generate_pattern_template() + + +class TestCatalogMap: + """Test generate_catalog_map.""" + + def test_runs(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._pattern_template_catalog import ( + generate_catalog_map, + ) + + generate_catalog_map() + + +# ── _pattern_pillars_observer ────────────────────────────────────────── + + +class TestThreePillars: + """Test generate_three_pillars.""" + + def test_runs(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._pattern_pillars_observer import ( + generate_three_pillars, + ) + + generate_three_pillars() + + +class TestObserverCard: + """Test generate_observer_card_filled.""" + + def test_runs(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._pattern_pillars_observer import ( + generate_observer_card_filled, + ) + + generate_observer_card_filled() + + +class TestGetObserverBandHeight: + """Test _get_observer_band_height.""" + + def test_all_indices(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._pattern_pillars_observer import ( + _get_observer_band_height, + ) + + for i in range(5): + h = _get_observer_band_height(i) + assert isinstance(h, float) + assert h > 0 + + +# ── _pattern_navigation ─────────────────────────────────────────────── + + +class TestPatternLanguageNavigation: + """Test generate_pattern_language_navigation.""" + + def test_runs(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._pattern_navigation import ( + generate_pattern_language_navigation, + ) + + generate_pattern_language_navigation() diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_process.py b/python_pkg/praca_magisterska_video/tests/test_gen_process.py new file mode 100644 index 0000000..daaf17c --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_process.py @@ -0,0 +1,352 @@ +"""Tests for process diagram modules (GROUP 2). + +Covers: + - generate_process_diagrams.py (draw_arrow, draw_line, draw_rounded_rect, + draw_diamond, constants) + - _process_bpmn_uml.py (generate_bpmn, generate_uml_activity, and sub-helpers) + - _process_epc_fc.py (generate_epc and sub-helpers) + - _process_fc.py (generate_flowchart and sub-helpers) +""" + +from __future__ import annotations + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + +_GEN = "python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams" +_BPMN = "python_pkg.praca_magisterska_video.generate_images._process_bpmn_uml" +_EPC = "python_pkg.praca_magisterska_video.generate_images._process_epc_fc" +_FC = "python_pkg.praca_magisterska_video.generate_images._process_fc" + + +# ── generate_process_diagrams helpers ────────────────────────────────── + + +class TestProcessConstants: + """Constants and module-level values.""" + + def test_dpi(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import ( + DPI, + ) + + assert DPI == 300 + + def test_bg_color(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import ( + BG_COLOR, + ) + + assert BG_COLOR == "white" + + def test_output_dir(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import ( + OUTPUT_DIR, + ) + + assert isinstance(OUTPUT_DIR, str) + + +class TestProcessDrawArrow: + """Test draw_arrow helper.""" + + def test_default(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import ( + draw_arrow, + ) + + fig, ax = plt.subplots() + draw_arrow(ax, 0, 0, 1, 1) + plt.close(fig) + + +class TestProcessDrawLine: + """Test draw_line helper.""" + + def test_default(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import ( + draw_line, + ) + + fig, ax = plt.subplots() + draw_line(ax, 0, 0, 5, 5) + plt.close(fig) + + +class TestProcessDrawRoundedRect: + """Test draw_rounded_rect helper.""" + + def test_default(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import ( + draw_rounded_rect, + ) + + fig, ax = plt.subplots() + draw_rounded_rect(ax, 5, 5, 10, 4, "Hello") + plt.close(fig) + + def test_custom_params(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import ( + draw_rounded_rect, + ) + + fig, ax = plt.subplots() + draw_rounded_rect(ax, 0, 0, 8, 3, "styled", fill="#CCC", lw=3, fontsize=12) + plt.close(fig) + + +class TestProcessDrawDiamond: + """Test draw_diamond helper.""" + + def test_with_text(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import ( + draw_diamond, + ) + + fig, ax = plt.subplots() + draw_diamond(ax, 5, 5, 3, "XOR") + plt.close(fig) + + def test_without_text(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import ( + draw_diamond, + ) + + fig, ax = plt.subplots() + draw_diamond(ax, 5, 5, 3) + plt.close(fig) + + def test_custom_fill(self) -> None: + from python_pkg.praca_magisterska_video.generate_images.generate_process_diagrams import ( + draw_diamond, + ) + + fig, ax = plt.subplots() + draw_diamond(ax, 5, 5, 3, "Y", fill="#EEE", fontsize=12) + plt.close(fig) + + +# ── _process_bpmn_uml ───────────────────────────────────────────────── + + +class TestBPMN: + """Test generate_bpmn and its sub-helpers.""" + + def test_generate_bpmn(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_bpmn_uml import ( + generate_bpmn, + ) + + generate_bpmn() + + def test_draw_bpmn_pool_and_lanes(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_bpmn_uml import ( + _draw_bpmn_pool_and_lanes, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 110) + ax.set_ylim(0, 75) + result = _draw_bpmn_pool_and_lanes(ax) + assert len(result) == 4 + plt.close(fig) + + def test_draw_bpmn_elements(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_bpmn_uml import ( + _draw_bpmn_elements, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 110) + ax.set_ylim(0, 75) + _draw_bpmn_elements(ax, 60, 40, 20, 12) + plt.close(fig) + + def test_draw_bpmn_legend(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_bpmn_uml import ( + _draw_bpmn_legend, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 110) + ax.set_ylim(0, 75) + _draw_bpmn_legend(ax) + plt.close(fig) + + +class TestUMLActivity: + """Test generate_uml_activity and its sub-helpers.""" + + def test_generate_uml_activity(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_bpmn_uml import ( + generate_uml_activity, + ) + + generate_uml_activity() + + def test_draw_uml_elements(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_bpmn_uml import ( + _draw_uml_elements, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 100) + ax.set_ylim(0, 100) + _draw_uml_elements(ax) + plt.close(fig) + + def test_draw_uml_legend(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_bpmn_uml import ( + _draw_uml_legend, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 100) + ax.set_ylim(0, 100) + _draw_uml_legend(ax) + plt.close(fig) + + +# ── _process_epc_fc ──────────────────────────────────────────────────── + + +class TestEPC: + """Test generate_epc and its sub-helpers.""" + + def test_generate_epc(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_epc_fc import ( + generate_epc, + ) + + generate_epc() + + def test_draw_epc_event(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_epc_fc import ( + _draw_epc_event, + ) + + fig, ax = plt.subplots() + _draw_epc_event(ax, 50, 50, "test event") + plt.close(fig) + + def test_draw_epc_function(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_epc_fc import ( + _draw_epc_function, + ) + + fig, ax = plt.subplots() + _draw_epc_function(ax, 50, 50, "test function") + plt.close(fig) + + def test_draw_epc_connector(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_epc_fc import ( + _draw_epc_connector, + ) + + fig, ax = plt.subplots() + _draw_epc_connector(ax, 50, 50, "XOR") + plt.close(fig) + + def test_draw_epc_flow(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_epc_fc import ( + _draw_epc_flow, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 100) + ax.set_ylim(0, 120) + cx, split_y, step = _draw_epc_flow(ax) + assert isinstance(cx, int | float) + assert isinstance(split_y, int | float) + assert isinstance(step, float) + plt.close(fig) + + def test_draw_epc_branches(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_epc_fc import ( + _draw_epc_branches, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 100) + ax.set_ylim(0, 120) + _draw_epc_branches(ax, 50, 60, 9.5) + plt.close(fig) + + def test_draw_epc_legend(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_epc_fc import ( + _draw_epc_legend, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 100) + ax.set_ylim(0, 120) + _draw_epc_legend(ax) + plt.close(fig) + + +# ── _process_fc ──────────────────────────────────────────────────────── + + +class TestFlowchart: + """Test generate_flowchart and its sub-helpers.""" + + def test_generate_flowchart(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_fc import ( + generate_flowchart, + ) + + generate_flowchart() + + def test_draw_fc_terminal(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_fc import ( + _draw_fc_terminal, + ) + + fig, ax = plt.subplots() + _draw_fc_terminal(ax, 50, 50, "START") + plt.close(fig) + + def test_draw_fc_process_box(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_fc import ( + _draw_fc_process_box, + ) + + fig, ax = plt.subplots() + _draw_fc_process_box(ax, 50, 50, "Process") + plt.close(fig) + + def test_draw_fc_io_shape(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_fc import ( + _draw_fc_io_shape, + ) + + fig, ax = plt.subplots() + _draw_fc_io_shape(ax, 50, 50, "I/O") + plt.close(fig) + + def test_draw_fc_elements(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_fc import ( + _draw_fc_elements, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 100) + ax.set_ylim(0, 110) + _draw_fc_elements(ax) + plt.close(fig) + + def test_draw_fc_legend(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._process_fc import ( + _draw_fc_legend, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 100) + ax.set_ylim(0, 110) + _draw_fc_legend(ax) + plt.close(fig) diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_pubsub.py b/python_pkg/praca_magisterska_video/tests/test_gen_pubsub.py new file mode 100644 index 0000000..b7a6f23 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_pubsub.py @@ -0,0 +1,293 @@ +"""Tests for Pub/Sub diagram modules (GROUP 3). + +Covers: + - _pubsub_common.py (BoxStyle, ArrowCfg, DashedCfg, draw_box, draw_arrow, + draw_dashed_arrow, draw_cross, draw_check, save) + - _pubsub_qos.py (draw_qos_at_most_once, draw_qos_at_least_once, + draw_qos_exactly_once) + - _pubsub_topic_content.py (draw_sub_topic, draw_sub_content) + - _pubsub_type_hierarchical.py (draw_sub_type, draw_sub_hierarchical) + - generate_pubsub_diagrams.py (imports only, __name__ guard) +""" + +from __future__ import annotations + +import importlib + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + + +# ── _pubsub_common ──────────────────────────────────────────────────── + + +class TestPubsubCommonDataclasses: + """BoxStyle, ArrowCfg, DashedCfg dataclass defaults.""" + + def test_box_style_defaults(self) -> None: + from _pubsub_common import BoxStyle + + bs = BoxStyle() + assert bs.fill == "white" + assert bs.rounded is True + assert bs.fontweight == "normal" + + def test_box_style_custom(self) -> None: + from _pubsub_common import BoxStyle + + bs = BoxStyle(fill="red", rounded=False, fontweight="bold") + assert bs.fill == "red" + assert bs.rounded is False + + def test_arrow_cfg_defaults(self) -> None: + from _pubsub_common import ArrowCfg + + ac = ArrowCfg() + assert ac.style == "->" + assert ac.label == "" + + def test_arrow_cfg_custom(self) -> None: + from _pubsub_common import ArrowCfg + + ac = ArrowCfg(label="test", label_fs=12, lw=2.0) + assert ac.label == "test" + assert ac.label_fs == 12 + + def test_dashed_cfg_defaults(self) -> None: + from _pubsub_common import DashedCfg + + dc = DashedCfg() + assert dc.label == "" + + def test_dashed_cfg_custom(self) -> None: + from _pubsub_common import DashedCfg + + dc = DashedCfg(label="dashed", lw=2.0) + assert dc.label == "dashed" + + +class TestPubsubDrawBox: + """draw_box from _pubsub_common.""" + + def test_rounded(self) -> None: + from _pubsub_common import BoxStyle, draw_box + + fig, ax = plt.subplots() + draw_box(ax, (0, 0), (2, 1), "test", BoxStyle()) + plt.close(fig) + + def test_not_rounded(self) -> None: + from _pubsub_common import BoxStyle, draw_box + + fig, ax = plt.subplots() + draw_box(ax, (0, 0), (2, 1), "test", BoxStyle(rounded=False)) + plt.close(fig) + + def test_no_style(self) -> None: + from _pubsub_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, (0, 0), (2, 1), "test") + plt.close(fig) + + +class TestPubsubDrawArrow: + """draw_arrow from _pubsub_common.""" + + def test_default(self) -> None: + from _pubsub_common import draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, (0, 0), (1, 1)) + plt.close(fig) + + def test_with_label(self) -> None: + from _pubsub_common import ArrowCfg, draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, (0, 0), (1, 1), ArrowCfg(label="MSG")) + plt.close(fig) + + def test_no_label(self) -> None: + from _pubsub_common import ArrowCfg, draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, (0, 0), (1, 1), ArrowCfg(label="")) + plt.close(fig) + + +class TestPubsubDrawDashedArrow: + """draw_dashed_arrow from _pubsub_common.""" + + def test_default(self) -> None: + from _pubsub_common import draw_dashed_arrow + + fig, ax = plt.subplots() + draw_dashed_arrow(ax, (0, 0), (1, 1)) + plt.close(fig) + + def test_with_label(self) -> None: + from _pubsub_common import DashedCfg, draw_dashed_arrow + + fig, ax = plt.subplots() + draw_dashed_arrow(ax, (0, 0), (1, 1), DashedCfg(label="lost")) + plt.close(fig) + + def test_no_label(self) -> None: + from _pubsub_common import DashedCfg, draw_dashed_arrow + + fig, ax = plt.subplots() + draw_dashed_arrow(ax, (0, 0), (1, 1), DashedCfg(label="")) + plt.close(fig) + + +class TestPubsubDrawCross: + """draw_cross from _pubsub_common.""" + + def test_default(self) -> None: + from _pubsub_common import draw_cross + + fig, ax = plt.subplots() + draw_cross(ax, (5, 5)) + plt.close(fig) + + def test_custom(self) -> None: + from _pubsub_common import draw_cross + + fig, ax = plt.subplots() + draw_cross(ax, (5, 5), size=0.3, lw=3.0, color="red") + plt.close(fig) + + +class TestPubsubDrawCheck: + """draw_check from _pubsub_common.""" + + def test_default(self) -> None: + from _pubsub_common import draw_check + + fig, ax = plt.subplots() + draw_check(ax, (5, 5)) + plt.close(fig) + + def test_custom(self) -> None: + from _pubsub_common import draw_check + + fig, ax = plt.subplots() + draw_check(ax, (5, 5), size=0.3, lw=3.0, color="green") + plt.close(fig) + + +class TestPubsubSave: + """save from _pubsub_common.""" + + def test_save(self) -> None: + from _pubsub_common import save + + fig, _ax = plt.subplots() + save(fig, "test_output.png") + + +class TestPubsubConstants: + """Module-level constants from _pubsub_common.""" + + def test_dpi(self) -> None: + from _pubsub_common import DPI + + assert DPI == 300 + + def test_fig_w(self) -> None: + from _pubsub_common import FIG_W + + assert FIG_W == 8.27 + + def test_output_dir(self) -> None: + from _pubsub_common import OUTPUT_DIR + + assert isinstance(OUTPUT_DIR, str) + + +# ── _pubsub_qos ─────────────────────────────────────────────────────── + + +class TestQosAtMostOnce: + """draw_qos_at_most_once.""" + + def test_runs(self) -> None: + from _pubsub_qos import draw_qos_at_most_once + + draw_qos_at_most_once() + + +class TestQosAtLeastOnce: + """draw_qos_at_least_once.""" + + def test_runs(self) -> None: + from _pubsub_qos import draw_qos_at_least_once + + draw_qos_at_least_once() + + +class TestQosExactlyOnce: + """draw_qos_exactly_once.""" + + def test_runs(self) -> None: + from _pubsub_qos import draw_qos_exactly_once + + draw_qos_exactly_once() + + +# ── _pubsub_topic_content ───────────────────────────────────────────── + + +class TestSubTopic: + """draw_sub_topic.""" + + def test_runs(self) -> None: + from _pubsub_topic_content import draw_sub_topic + + draw_sub_topic() + + +class TestSubContent: + """draw_sub_content.""" + + def test_runs(self) -> None: + from _pubsub_topic_content import draw_sub_content + + draw_sub_content() + + +# ── _pubsub_type_hierarchical ───────────────────────────────────────── + + +class TestSubType: + """draw_sub_type.""" + + def test_runs(self) -> None: + from _pubsub_type_hierarchical import draw_sub_type + + draw_sub_type() + + +class TestSubHierarchical: + """draw_sub_hierarchical.""" + + def test_runs(self) -> None: + from _pubsub_type_hierarchical import draw_sub_hierarchical + + draw_sub_hierarchical() + + +# ── generate_pubsub_diagrams ────────────────────────────────────────── + + +class TestGeneratePubsubModule: + """Test that the module is importable.""" + + def test_imports(self) -> None: + importlib.import_module("generate_pubsub_diagrams") diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_q20.py b/python_pkg/praca_magisterska_video/tests/test_gen_q20.py new file mode 100644 index 0000000..2082369 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_q20.py @@ -0,0 +1,389 @@ +"""Tests for Q20 stream-processing diagram modules (GROUP 4). + +Covers: + - _q20_common.py (draw_box, draw_arrow, save_fig, draw_table, constants) + - _q20_batch_and_windows.py (gen_batch_vs_streaming, gen_window_types, + _draw_tumbling_window, _draw_sliding_window, _draw_session_window, + _draw_global_window) + - _q20_time_monitoring_sessions.py (gen_event_vs_processing_time, + gen_tumbling_fraud, gen_sliding_sla, gen_session_users) + - _q20_platforms.py (gen_streaming_ecosystem, gen_true_vs_microbatch, + gen_platform_comparison, gen_kafka_streams_arch, gen_flink_arch) + - _q20_architectures.py (gen_spark_streaming_arch, gen_lambda_vs_kappa, + gen_lambda_kappa_table, gen_exactly_once) + - _q20_late_and_decisions.py (gen_late_data_strategies, gen_decision_tree) + - generate_q20_diagrams.py (__all__, imports) +""" + +from __future__ import annotations + +import importlib + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + + +# ── _q20_common ─────────────────────────────────────────────────────── + + +class TestQ20Constants: + """Module-level constants.""" + + def test_dpi(self) -> None: + from _q20_common import DPI + + assert DPI == 300 + + def test_output_dir(self) -> None: + from _q20_common import OUTPUT_DIR + + assert isinstance(OUTPUT_DIR, str) + + def test_grays(self) -> None: + from _q20_common import GRAY1, GRAY2, GRAY3, GRAY4, GRAY5 + + assert all(isinstance(g, str) for g in [GRAY1, GRAY2, GRAY3, GRAY4, GRAY5]) + + +class TestQ20DrawBox: + """draw_box from _q20_common.""" + + def test_rounded(self) -> None: + from _q20_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 0, 0, 2, 1, "test") + plt.close(fig) + + def test_not_rounded(self) -> None: + from _q20_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 0, 0, 2, 1, "test", rounded=False) + plt.close(fig) + + def test_custom_style(self) -> None: + from _q20_common import draw_box + + fig, ax = plt.subplots() + draw_box( + ax, + 0, + 0, + 2, + 1, + "test", + fill="#CCC", + lw=2.0, + fontsize=12, + fontweight="bold", + ha="left", + va="top", + edgecolor="red", + linestyle="--", + ) + plt.close(fig) + + +class TestQ20DrawArrow: + """draw_arrow from _q20_common.""" + + def test_default(self) -> None: + from _q20_common import draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, 0, 0, 1, 1) + plt.close(fig) + + def test_custom(self) -> None: + from _q20_common import draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, 0, 0, 1, 1, lw=2.5, style="<->", color="red") + plt.close(fig) + + +class TestQ20SaveFig: + """save_fig from _q20_common.""" + + def test_save(self) -> None: + from _q20_common import save_fig + + fig, _ax = plt.subplots() + save_fig(fig, "test_q20.png") + + +class TestQ20DrawTable: + """draw_table from _q20_common.""" + + def test_basic(self) -> None: + from _q20_common import draw_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-5, 2) + draw_table(ax, ["A", "B"], [["1", "2"]], 0, 0, [2.0, 2.0]) + plt.close(fig) + + def test_custom_fills(self) -> None: + from _q20_common import draw_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-5, 2) + draw_table( + ax, + ["X"], + [["a"], ["b"], ["c"]], + 0, + 0, + [3.0], + row_h=0.5, + row_fills=["#EEE", "#DDD"], + header_fontsize=10, + ) + plt.close(fig) + + def test_row_fills_shorter_than_rows(self) -> None: + """row_fills has fewer entries than rows → falls through condition.""" + from _q20_common import draw_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-10, 2) + draw_table( + ax, + ["H"], + [["r1"], ["r2"], ["r3"], ["r4"]], + 0, + 0, + [3.0], + row_fills=["#AAA"], + ) + plt.close(fig) + + def test_no_row_fills(self) -> None: + """row_fills=None → alternating GRAY4/white.""" + from _q20_common import draw_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-5, 2) + draw_table(ax, ["H"], [["r1"], ["r2"]], 0, 0, [3.0]) + plt.close(fig) + + +# ── _q20_batch_and_windows ──────────────────────────────────────────── + + +class TestBatchVsStreaming: + """gen_batch_vs_streaming.""" + + def test_runs(self) -> None: + from _q20_batch_and_windows import gen_batch_vs_streaming + + gen_batch_vs_streaming() + + +class TestWindowTypes: + """gen_window_types with sub-helpers.""" + + def test_runs(self) -> None: + from _q20_batch_and_windows import gen_window_types + + gen_window_types() + + def test_tumbling_window(self) -> None: + from _q20_batch_and_windows import _draw_tumbling_window + + fig, ax = plt.subplots() + _draw_tumbling_window(ax, list(range(1, 13))) + plt.close(fig) + + def test_sliding_window(self) -> None: + from _q20_batch_and_windows import _draw_sliding_window + + fig, ax = plt.subplots() + _draw_sliding_window(ax, list(range(1, 13))) + plt.close(fig) + + def test_session_window(self) -> None: + from _q20_batch_and_windows import _draw_session_window + + fig, ax = plt.subplots() + _draw_session_window(ax) + plt.close(fig) + + def test_global_window(self) -> None: + from _q20_batch_and_windows import _draw_global_window + + fig, ax = plt.subplots() + _draw_global_window(ax) + plt.close(fig) + + +# ── _q20_time_monitoring_sessions ───────────────────────────────────── + + +class TestEventVsProcessingTime: + """gen_event_vs_processing_time.""" + + def test_runs(self) -> None: + from _q20_time_monitoring_sessions import gen_event_vs_processing_time + + gen_event_vs_processing_time() + + +class TestTumblingFraud: + """gen_tumbling_fraud.""" + + def test_runs(self) -> None: + from _q20_time_monitoring_sessions import gen_tumbling_fraud + + gen_tumbling_fraud() + + +class TestSlidingSla: + """gen_sliding_sla.""" + + def test_runs(self) -> None: + from _q20_time_monitoring_sessions import gen_sliding_sla + + gen_sliding_sla() + + +class TestSessionUsers: + """gen_session_users.""" + + def test_runs(self) -> None: + from _q20_time_monitoring_sessions import gen_session_users + + gen_session_users() + + +# ── _q20_platforms ──────────────────────────────────────────────────── + + +class TestStreamingEcosystem: + """gen_streaming_ecosystem.""" + + def test_runs(self) -> None: + from _q20_platforms import gen_streaming_ecosystem + + gen_streaming_ecosystem() + + +class TestTrueVsMicrobatch: + """gen_true_vs_microbatch.""" + + def test_runs(self) -> None: + from _q20_platforms import gen_true_vs_microbatch + + gen_true_vs_microbatch() + + +class TestPlatformComparison: + """gen_platform_comparison.""" + + def test_runs(self) -> None: + from _q20_platforms import gen_platform_comparison + + gen_platform_comparison() + + +class TestKafkaStreamsArch: + """gen_kafka_streams_arch.""" + + def test_runs(self) -> None: + from _q20_platforms import gen_kafka_streams_arch + + gen_kafka_streams_arch() + + +class TestFlinkArch: + """gen_flink_arch.""" + + def test_runs(self) -> None: + from _q20_platforms import gen_flink_arch + + gen_flink_arch() + + +# ── _q20_architectures ─────────────────────────────────────────────── + + +class TestSparkStreamingArch: + """gen_spark_streaming_arch.""" + + def test_runs(self) -> None: + from _q20_architectures import gen_spark_streaming_arch + + gen_spark_streaming_arch() + + +class TestLambdaVsKappa: + """gen_lambda_vs_kappa.""" + + def test_runs(self) -> None: + from _q20_architectures import gen_lambda_vs_kappa + + gen_lambda_vs_kappa() + + +class TestLambdaKappaTable: + """gen_lambda_kappa_table.""" + + def test_runs(self) -> None: + from _q20_architectures import gen_lambda_kappa_table + + gen_lambda_kappa_table() + + +class TestExactlyOnce: + """gen_exactly_once.""" + + def test_runs(self) -> None: + from _q20_architectures import gen_exactly_once + + gen_exactly_once() + + +# ── _q20_late_and_decisions ─────────────────────────────────────────── + + +class TestLateDataStrategies: + """gen_late_data_strategies.""" + + def test_runs(self) -> None: + from _q20_late_and_decisions import gen_late_data_strategies + + gen_late_data_strategies() + + +class TestDecisionTree: + """gen_decision_tree.""" + + def test_runs(self) -> None: + from _q20_late_and_decisions import gen_decision_tree + + gen_decision_tree() + + +# ── generate_q20_diagrams ──────────────────────────────────────────── + + +class TestGenerateQ20Module: + """Test module imports and __all__.""" + + def test_imports(self) -> None: + importlib.import_module("generate_q20_diagrams") + + def test_all_length(self) -> None: + import generate_q20_diagrams + + assert len(generate_q20_diagrams.__all__) == 17 diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_q23.py b/python_pkg/praca_magisterska_video/tests/test_gen_q23.py new file mode 100644 index 0000000..33cc0fe --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_q23.py @@ -0,0 +1,495 @@ +"""Tests for Q23 image-segmentation diagram modules (BATCH 3 / GROUP 1). + +Covers: + - _q23_common.py (constants, _save_figure, _render_text_lines) + - _q23_architectures.py (generate_fcn, generate_unet) + - _q23_diy_unet.py (generate_diy_unet, _draw_unet_layer_stack, + _draw_unet_pseudocode) + - _q23_mean_shift_ncuts.py (generate_mean_shift, generate_normalized_cuts, + _draw_ncuts_pixel_grid, _draw_ncuts_edges) + - _q23_mnemonics.py (generate_mnemonics) + - _q23_nn_basics.py (generate_relu, generate_dot_product) + - _q23_otsu_watershed.py (generate_otsu_bimodal, generate_watershed, + _draw_otsu_variance_panel, _draw_watershed_result_panel) + - _q23_receptive_transformer.py (generate_receptive_field, generate_transformer) + - _q23_region_diy.py (generate_region_growing, generate_diy_thresholding, + _draw_region_growing_grid, _draw_bfs_expansion, + _draw_otsu_variance_and_pseudocode) + - generate_q23_diagrams.py (__all__, imports, __main__ block) +""" + +from __future__ import annotations + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + + +# ── _q23_common ─────────────────────────────────────────────────────── + + +class TestQ23Constants: + """Module-level constants and singletons.""" + + def test_dpi(self) -> None: + from _q23_common import DPI + + assert DPI == 300 + + def test_output_dir_is_str(self) -> None: + from _q23_common import OUTPUT_DIR + + assert isinstance(OUTPUT_DIR, str) + + def test_color_constants(self) -> None: + from _q23_common import ( + ACCENT, + ACCENT_LIGHT, + BLACK, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + GRAY6, + GREEN_ACCENT, + RED_ACCENT, + WHITE, + ) + + colors = [ + BLACK, + WHITE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + GRAY6, + ACCENT, + ACCENT_LIGHT, + RED_ACCENT, + GREEN_ACCENT, + ] + assert all(isinstance(c, str) and c.startswith("#") for c in colors) + + def test_font_size_constants(self) -> None: + from _q23_common import FS, FS_SMALL, FS_TINY, FS_TITLE + + assert FS_TITLE > FS > FS_SMALL > FS_TINY + + def test_threshold_constants(self) -> None: + from _q23_common import ( + _BRIGHT_THRESHOLD, + _DARK_PIXEL_THRESHOLD, + _GRID_LAST_IDX, + _HIGHLIGHT_END, + _HIGHLIGHT_START, + _OTSU_THRESHOLD, + _RIDGE_X, + _VALLEY2_END, + ) + + assert _DARK_PIXEL_THRESHOLD == 100 + assert _GRID_LAST_IDX == 3 + assert _HIGHLIGHT_START == 3 + assert _HIGHLIGHT_END == 5 + assert _BRIGHT_THRESHOLD == 170 + assert _OTSU_THRESHOLD == 128 + assert _RIDGE_X == 5 + assert _VALLEY2_END == 9 + + def test_rng_exists(self) -> None: + from _q23_common import rng + + assert rng is not None + + +class TestQ23SaveFigure: + """_save_figure from _q23_common.""" + + def test_runs(self) -> None: + from _q23_common import _save_figure + + _fig, _ax = plt.subplots() + _save_figure("test_q23_save.png") + + +class TestQ23RenderTextLines: + """_render_text_lines from _q23_common.""" + + def test_basic_lines(self) -> None: + from _q23_common import _render_text_lines + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + lines = [ + ("Hello", 10, "black", "bold"), + ("World", 8, "gray", "normal"), + ] + _render_text_lines(ax, lines, start_y=9.0) + plt.close(fig) + + def test_empty_line_gaps(self) -> None: + from _q23_common import _render_text_lines + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + lines = [ + ("First", 10, "black", "bold"), + ("", 0, "", ""), + ("After gap", 10, "black", "normal"), + ] + _render_text_lines(ax, lines, start_y=9.0, y_step=0.5, y_empty_step=0.3) + plt.close(fig) + + def test_custom_x_pos(self) -> None: + from _q23_common import _render_text_lines + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + lines = [("Test", 10, "red", "normal")] + _render_text_lines(ax, lines, x_pos=0.3, start_y=8.0) + plt.close(fig) + + +# ── _q23_architectures ─────────────────────────────────────────────── + + +class TestGenerateFCN: + """generate_fcn from _q23_architectures.""" + + def test_runs(self) -> None: + from _q23_architectures import generate_fcn + + generate_fcn() + + +class TestGenerateUNet: + """generate_unet from _q23_architectures.""" + + def test_runs(self) -> None: + from _q23_architectures import generate_unet + + generate_unet() + + +# ── _q23_diy_unet ──────────────────────────────────────────────────── + + +class TestDrawUnetLayerStack: + """_draw_unet_layer_stack from _q23_diy_unet.""" + + def test_without_skip(self) -> None: + from _q23_diy_unet import _draw_unet_layer_stack + + fig, ax = plt.subplots() + _draw_unet_layer_stack( + ax, + [(64, 3), (32, 64), (16, 128)], + face_color="#B3D4FC", + edge_color="#4A90D9", + arrow_color="#4A90D9", + arrow_label="Conv+Pool", + ) + plt.close(fig) + + def test_with_skip(self) -> None: + from _q23_diy_unet import _draw_unet_layer_stack + + fig, ax = plt.subplots() + _draw_unet_layer_stack( + ax, + [(8, 256), (16, 128), (32, 64)], + face_color="#C8E6C9", + edge_color="#388E3C", + arrow_color="#388E3C", + arrow_label="UpConv+Concat", + add_skip=True, + ) + plt.close(fig) + + def test_single_layer_no_arrows(self) -> None: + from _q23_diy_unet import _draw_unet_layer_stack + + fig, ax = plt.subplots() + _draw_unet_layer_stack( + ax, + [(64, 3)], + face_color="#B3D4FC", + edge_color="#4A90D9", + arrow_color="#4A90D9", + arrow_label="X", + ) + plt.close(fig) + + +class TestDrawUnetPseudocode: + """_draw_unet_pseudocode from _q23_diy_unet.""" + + def test_runs(self) -> None: + from _q23_diy_unet import _draw_unet_pseudocode + + fig, ax = plt.subplots() + _draw_unet_pseudocode(ax) + plt.close(fig) + + +class TestGenerateDiyUnet: + """generate_diy_unet from _q23_diy_unet.""" + + @pytest.mark.filterwarnings("ignore::UserWarning") + def test_runs(self) -> None: + from _q23_diy_unet import generate_diy_unet + + generate_diy_unet() + + +# ── _q23_mean_shift_ncuts ──────────────────────────────────────────── + + +class TestGenerateMeanShift: + """generate_mean_shift from _q23_mean_shift_ncuts.""" + + def test_runs(self) -> None: + from _q23_mean_shift_ncuts import generate_mean_shift + + generate_mean_shift() + + +class TestDrawNcutsPixelGrid: + """_draw_ncuts_pixel_grid from _q23_mean_shift_ncuts.""" + + def test_runs(self) -> None: + from _q23_mean_shift_ncuts import _draw_ncuts_pixel_grid + + fig, ax = plt.subplots() + ax.set_xlim(-0.5, 4.5) + ax.set_ylim(-0.5, 4.5) + pixel_vals = np.array( + [ + [30, 35, 180, 190], + [40, 30, 185, 200], + [170, 180, 40, 35], + [190, 175, 30, 45], + ] + ) + _draw_ncuts_pixel_grid(ax, pixel_vals) + plt.close(fig) + + def test_bright_pixels(self) -> None: + """All pixels above dark threshold → black text.""" + from _q23_mean_shift_ncuts import _draw_ncuts_pixel_grid + + fig, ax = plt.subplots() + ax.set_xlim(-0.5, 4.5) + ax.set_ylim(-0.5, 4.5) + bright = np.full((4, 4), 200) + _draw_ncuts_pixel_grid(ax, bright) + plt.close(fig) + + def test_dark_pixels(self) -> None: + """All pixels below dark threshold → white text.""" + from _q23_mean_shift_ncuts import _draw_ncuts_pixel_grid + + fig, ax = plt.subplots() + ax.set_xlim(-0.5, 4.5) + ax.set_ylim(-0.5, 4.5) + dark = np.full((4, 4), 50) + _draw_ncuts_pixel_grid(ax, dark) + plt.close(fig) + + +class TestDrawNcutsEdges: + """_draw_ncuts_edges from _q23_mean_shift_ncuts.""" + + def test_runs(self) -> None: + from _q23_mean_shift_ncuts import _draw_ncuts_edges + + fig, ax = plt.subplots() + ax.set_xlim(-0.5, 4.5) + ax.set_ylim(-0.5, 4.5) + pixel_vals = np.array( + [ + [30, 35, 180, 190], + [40, 30, 185, 200], + [170, 180, 40, 35], + [190, 175, 30, 45], + ] + ) + _draw_ncuts_edges(ax, pixel_vals) + plt.close(fig) + + def test_uniform_values(self) -> None: + """All same values → max similarity everywhere.""" + from _q23_mean_shift_ncuts import _draw_ncuts_edges + + fig, ax = plt.subplots() + ax.set_xlim(-0.5, 4.5) + ax.set_ylim(-0.5, 4.5) + uniform = np.full((4, 4), 128) + _draw_ncuts_edges(ax, uniform) + plt.close(fig) + + +class TestGenerateNormalizedCuts: + """generate_normalized_cuts from _q23_mean_shift_ncuts.""" + + def test_runs(self) -> None: + from _q23_mean_shift_ncuts import generate_normalized_cuts + + generate_normalized_cuts() + + +# ── _q23_mnemonics ─────────────────────────────────────────────────── + + +class TestGenerateMnemonics: + """generate_mnemonics from _q23_mnemonics.""" + + def test_runs(self) -> None: + from _q23_mnemonics import generate_mnemonics + + generate_mnemonics() + + +# ── _q23_nn_basics ─────────────────────────────────────────────────── + + +class TestGenerateRelu: + """generate_relu from _q23_nn_basics.""" + + def test_runs(self) -> None: + from _q23_nn_basics import generate_relu + + generate_relu() + + +class TestGenerateDotProduct: + """generate_dot_product from _q23_nn_basics.""" + + def test_runs(self) -> None: + from _q23_nn_basics import generate_dot_product + + generate_dot_product() + + +# ── _q23_otsu_watershed ────────────────────────────────────────────── + + +class TestDrawOtsuVariancePanel: + """_draw_otsu_variance_panel from _q23_otsu_watershed.""" + + def test_runs(self) -> None: + from _q23_otsu_watershed import _draw_otsu_variance_panel + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + _draw_otsu_variance_panel(ax) + plt.close(fig) + + +class TestGenerateOtsuBimodal: + """generate_otsu_bimodal from _q23_otsu_watershed.""" + + def test_runs(self) -> None: + from _q23_otsu_watershed import generate_otsu_bimodal + + generate_otsu_bimodal() + + +class TestDrawWatershedResultPanel: + """_draw_watershed_result_panel from _q23_otsu_watershed.""" + + def test_runs(self) -> None: + from _q23_otsu_watershed import _draw_watershed_result_panel + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + _draw_watershed_result_panel(ax) + plt.close(fig) + + +class TestGenerateWatershed: + """generate_watershed from _q23_otsu_watershed.""" + + def test_runs(self) -> None: + from _q23_otsu_watershed import generate_watershed + + generate_watershed() + + +# ── _q23_receptive_transformer ─────────────────────────────────────── + + +class TestGenerateReceptiveField: + """generate_receptive_field from _q23_receptive_transformer.""" + + def test_runs(self) -> None: + from _q23_receptive_transformer import generate_receptive_field + + generate_receptive_field() + + +class TestGenerateTransformer: + """generate_transformer from _q23_receptive_transformer.""" + + def test_runs(self) -> None: + from _q23_receptive_transformer import generate_transformer + + generate_transformer() + + +# ── _q23_region_diy ────────────────────────────────────────────────── + + +class TestDrawRegionGrowingGrid: + """_draw_region_growing_grid from _q23_region_diy.""" + + def test_runs(self) -> None: + from _q23_region_diy import _draw_region_growing_grid + + fig, ax = plt.subplots() + _draw_region_growing_grid(ax) + plt.close(fig) + + def test_bright_pixels_in_region(self) -> None: + """Hit elif branch: masked pixel >= _BRIGHT_THRESHOLD.""" + from unittest.mock import patch + + from _q23_region_diy import _draw_region_growing_grid + + fig, ax = plt.subplots() + with patch("_q23_region_diy._BRIGHT_THRESHOLD", 0): + _draw_region_growing_grid(ax) + plt.close(fig) + + +class TestDrawBfsExpansion: + """_draw_bfs_expansion from _q23_region_diy.""" + + def test_runs(self) -> None: + from _q23_region_diy import _draw_bfs_expansion + + fig, ax = plt.subplots() + _draw_bfs_expansion(ax) + plt.close(fig) + + +class TestGenerateRegionGrowing: + """generate_region_growing from _q23_region_diy.""" + + def test_runs(self) -> None: + from _q23_region_diy import generate_region_growing + + generate_region_growing() diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_q24_diagrams.py b/python_pkg/praca_magisterska_video/tests/test_gen_q24_diagrams.py new file mode 100644 index 0000000..782ee33 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_q24_diagrams.py @@ -0,0 +1,446 @@ +"""Tests for Q24 object-detection diagram modules (BATCH 3 / GROUP 2). + +Covers: + - generate_images/_q24_common.py (draw_box, draw_arrow, save_fig, + draw_table, constants) + - _q24_fpn_tasks_cnn.py (draw_fpn, draw_anchor_boxes, + draw_detection_tasks, draw_cnn_architecture) + - _q24_haar_integral_svm.py (draw_haar_features, _draw_haar_face_panel, + draw_integral_image, draw_svm_hyperplane) + - _q24_hog_classical.py (draw_hog_svm_pipeline, draw_hog_gradient_steps, + draw_viola_jones_cascade) + - _q24_iou_nms_detector.py (draw_iou_diagram, draw_nms_steps, + draw_detector_from_classifier) + - _q24_modern_pipelines.py (draw_two_vs_one_stage, draw_roi_pooling, + draw_detr_pipeline, draw_sliding_window) + - _q24_rcnn_yolo.py (draw_rcnn_evolution, draw_yolo_grid, + _draw_yolo_cell_prediction) + - generate_q24_diagrams.py (__all__, imports) +""" + +from __future__ import annotations + +import matplotlib as mpl + +mpl.use("Agg") +import matplotlib.pyplot as plt +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + + +# ── generate_images/_q24_common ────────────────────────────────────── +# NOTE: This is the generate_images-level _q24_common, NOT the top-level +# praca_magisterska_video/_q24_common (which is for moviepy videos). + + +class TestGenQ24CommonConstants: + """Module-level constants from generate_images/_q24_common.""" + + def test_dpi(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import DPI + + # The generate_images _q24_common has DPI=300 + assert DPI == 300 + + def test_output_dir(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + OUTPUT_DIR, + ) + + assert isinstance(OUTPUT_DIR, str) + + def test_bg_ln(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + BG, + LN, + ) + + assert BG == "white" + assert LN == "black" + + def test_font_sizes(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + ) + + assert FS_TITLE > FS_LABEL >= FS > FS_SMALL + + def test_gray_palette(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + ) + + grays = [GRAY1, GRAY2, GRAY3, GRAY4, GRAY5] + assert all(isinstance(g, str) and g.startswith("#") for g in grays) + + def test_threshold_constants(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + _DATA_BRIGHT_THRESH, + _DOTS_STAGE_IDX, + _GRADIENT_BRIGHT_THRESH, + _II_BRIGHT_THRESH, + _PIXEL_BRIGHT_THRESH, + ) + + assert _PIXEL_BRIGHT_THRESH == 127 + assert _GRADIENT_BRIGHT_THRESH == 100 + assert _DATA_BRIGHT_THRESH == 5 + assert _II_BRIGHT_THRESH == 25 + assert _DOTS_STAGE_IDX == 2 + + def test_rng(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import rng + + assert rng is not None + + +class TestGenQ24DrawBox: + """draw_box from generate_images/_q24_common.""" + + def test_rounded(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + draw_box, + ) + + fig, ax = plt.subplots() + draw_box(ax, 0, 0, 2, 1, "test") + plt.close(fig) + + def test_not_rounded(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + draw_box, + ) + + fig, ax = plt.subplots() + draw_box(ax, 0, 0, 2, 1, "test", rounded=False) + plt.close(fig) + + def test_custom_style(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + draw_box, + ) + + fig, ax = plt.subplots() + draw_box( + ax, + 0, + 0, + 2, + 1, + "styled", + fill="#CCC", + lw=2.0, + fontsize=12, + fontweight="bold", + ha="left", + va="top", + edgecolor="red", + linestyle="--", + ) + plt.close(fig) + + +class TestGenQ24DrawArrow: + """draw_arrow from generate_images/_q24_common.""" + + def test_default(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + draw_arrow, + ) + + fig, ax = plt.subplots() + draw_arrow(ax, 0, 0, 1, 1) + plt.close(fig) + + def test_custom(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + draw_arrow, + ) + + fig, ax = plt.subplots() + draw_arrow(ax, 0, 0, 1, 1, lw=2.5, style="<->", color="red") + plt.close(fig) + + +class TestGenQ24SaveFig: + """save_fig from generate_images/_q24_common.""" + + def test_save(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + save_fig, + ) + + fig, _ax = plt.subplots() + save_fig(fig, "test_q24_gen.png") + + +class TestGenQ24DrawTable: + """draw_table from generate_images/_q24_common.""" + + def test_basic(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + draw_table, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-5, 2) + draw_table(ax, ["A", "B"], [["1", "2"]], 0, 0, [2.0, 2.0]) + plt.close(fig) + + def test_custom_fills(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + draw_table, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-5, 2) + draw_table( + ax, + ["X"], + [["a"], ["b"], ["c"]], + 0, + 0, + [3.0], + row_h=0.5, + row_fills=["#EEE", "#DDD"], + header_fontsize=10, + ) + plt.close(fig) + + def test_row_fills_shorter_than_rows(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + draw_table, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-10, 2) + draw_table( + ax, + ["H"], + [["r1"], ["r2"], ["r3"], ["r4"]], + 0, + 0, + [3.0], + row_fills=["#AAA"], + ) + plt.close(fig) + + def test_no_row_fills(self) -> None: + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + draw_table, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-5, 2) + draw_table(ax, ["H"], [["r1"], ["r2"]], 0, 0, [3.0]) + plt.close(fig) + + def test_even_odd_alternation(self) -> None: + """Rows alternate fill based on even/odd index.""" + from python_pkg.praca_magisterska_video.generate_images._q24_common import ( + draw_table, + ) + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-10, 2) + draw_table( + ax, + ["H"], + [["r1"], ["r2"], ["r3"]], + 0, + 0, + [3.0], + ) + plt.close(fig) + + +# ── _q24_fpn_tasks_cnn ────────────────────────────────────────────── + + +class TestDrawFPN: + """draw_fpn from _q24_fpn_tasks_cnn.""" + + def test_runs(self) -> None: + from _q24_fpn_tasks_cnn import draw_fpn + + draw_fpn() + + +class TestDrawAnchorBoxes: + """draw_anchor_boxes from _q24_fpn_tasks_cnn.""" + + def test_runs(self) -> None: + from _q24_fpn_tasks_cnn import draw_anchor_boxes + + draw_anchor_boxes() + + +class TestDrawDetectionTasks: + """draw_detection_tasks from _q24_fpn_tasks_cnn.""" + + def test_runs(self) -> None: + from _q24_fpn_tasks_cnn import draw_detection_tasks + + draw_detection_tasks() + + +class TestDrawCNNArchitecture: + """draw_cnn_architecture from _q24_fpn_tasks_cnn.""" + + def test_runs(self) -> None: + from _q24_fpn_tasks_cnn import draw_cnn_architecture + + draw_cnn_architecture() + + +# ── _q24_haar_integral_svm ────────────────────────────────────────── + + +class TestDrawHaarFeatures: + """draw_haar_features from _q24_haar_integral_svm.""" + + def test_runs(self) -> None: + from _q24_haar_integral_svm import draw_haar_features + + draw_haar_features() + + +class TestDrawHaarFacePanel: + """_draw_haar_face_panel from _q24_haar_integral_svm.""" + + def test_runs(self) -> None: + from _q24_haar_integral_svm import _draw_haar_face_panel + + fig, ax = plt.subplots() + _draw_haar_face_panel(ax) + plt.close(fig) + + +class TestDrawIntegralImage: + """draw_integral_image from _q24_haar_integral_svm.""" + + def test_runs(self) -> None: + from _q24_haar_integral_svm import draw_integral_image + + draw_integral_image() + + +class TestDrawSVMHyperplane: + """draw_svm_hyperplane from _q24_haar_integral_svm.""" + + def test_runs(self) -> None: + from _q24_haar_integral_svm import draw_svm_hyperplane + + draw_svm_hyperplane() + + +# ── _q24_hog_classical ────────────────────────────────────────────── + + +class TestDrawHogSVMPipeline: + """draw_hog_svm_pipeline from _q24_hog_classical.""" + + def test_runs(self) -> None: + from _q24_hog_classical import draw_hog_svm_pipeline + + draw_hog_svm_pipeline() + + +class TestDrawHogGradientSteps: + """draw_hog_gradient_steps from _q24_hog_classical.""" + + def test_runs(self) -> None: + from _q24_hog_classical import draw_hog_gradient_steps + + draw_hog_gradient_steps() + + +class TestDrawViolaJonesCascade: + """draw_viola_jones_cascade from _q24_hog_classical.""" + + def test_runs(self) -> None: + from _q24_hog_classical import draw_viola_jones_cascade + + draw_viola_jones_cascade() + + +# ── _q24_iou_nms_detector ─────────────────────────────────────────── + + +class TestDrawIoUDiagram: + """draw_iou_diagram from _q24_iou_nms_detector.""" + + def test_runs(self) -> None: + from _q24_iou_nms_detector import draw_iou_diagram + + draw_iou_diagram() + + +class TestDrawNMSSteps: + """draw_nms_steps from _q24_iou_nms_detector.""" + + def test_runs(self) -> None: + from _q24_iou_nms_detector import draw_nms_steps + + draw_nms_steps() + + +class TestDrawDetectorFromClassifier: + """draw_detector_from_classifier from _q24_iou_nms_detector.""" + + def test_runs(self) -> None: + from _q24_iou_nms_detector import draw_detector_from_classifier + + draw_detector_from_classifier() + + +# ── _q24_modern_pipelines ─────────────────────────────────────────── + + +class TestDrawTwoVsOneStage: + """draw_two_vs_one_stage from _q24_modern_pipelines.""" + + def test_runs(self) -> None: + from _q24_modern_pipelines import draw_two_vs_one_stage + + draw_two_vs_one_stage() + + +class TestDrawROIPooling: + """draw_roi_pooling from _q24_modern_pipelines.""" + + def test_runs(self) -> None: + from _q24_modern_pipelines import draw_roi_pooling + + draw_roi_pooling() + + +class TestDrawDETRPipeline: + """draw_detr_pipeline from _q24_modern_pipelines.""" + + def test_runs(self) -> None: + from _q24_modern_pipelines import draw_detr_pipeline + + draw_detr_pipeline() + + +class TestDrawSlidingWindow: + """draw_sliding_window from _q24_modern_pipelines.""" + + def test_runs(self) -> None: + from _q24_modern_pipelines import draw_sliding_window + + draw_sliding_window() diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_q24_diagrams_part2.py b/python_pkg/praca_magisterska_video/tests/test_gen_q24_diagrams_part2.py new file mode 100644 index 0000000..0804769 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_q24_diagrams_part2.py @@ -0,0 +1,125 @@ +"""Tests for Q24 object-detection diagram modules - part 2 (rcnn/yolo, top-level). + +Covers: + - _q24_rcnn_yolo.py (draw_rcnn_evolution, draw_yolo_grid, + _draw_yolo_cell_prediction) + - generate_q24_diagrams.py (__all__, imports) +""" + +from __future__ import annotations + +import matplotlib.pyplot as plt +import pytest + +pytestmark = pytest.mark.usefixtures("_no_savefig") + + +# ── _q24_rcnn_yolo ────────────────────────────────────────────────── + + +class TestDrawRCNNEvolution: + """draw_rcnn_evolution from _q24_rcnn_yolo.""" + + def test_runs(self) -> None: + from _q24_rcnn_yolo import draw_rcnn_evolution + + draw_rcnn_evolution() + + +class TestDrawYoloGrid: + """draw_yolo_grid from _q24_rcnn_yolo.""" + + def test_runs(self) -> None: + from _q24_rcnn_yolo import draw_yolo_grid + + draw_yolo_grid() + + +class TestDrawYoloCellPrediction: + """_draw_yolo_cell_prediction from _q24_rcnn_yolo.""" + + def test_runs(self) -> None: + from _q24_rcnn_yolo import _draw_yolo_cell_prediction + + fig, ax = plt.subplots() + _draw_yolo_cell_prediction(ax) + plt.close(fig) + + +# ── generate_q24_diagrams ──────────────────────────────────────────── + + +class TestGenerateQ24DiagramsModule: + """generate_q24_diagrams top-level module.""" + + def test_all_exports(self) -> None: + import generate_q24_diagrams + + expected = { + "draw_anchor_boxes", + "draw_cnn_architecture", + "draw_detection_tasks", + "draw_detector_from_classifier", + "draw_detr_pipeline", + "draw_fpn", + "draw_haar_features", + "draw_hog_gradient_steps", + "draw_hog_svm_pipeline", + "draw_integral_image", + "draw_iou_diagram", + "draw_nms_steps", + "draw_rcnn_evolution", + "draw_roi_pooling", + "draw_sliding_window", + "draw_svm_hyperplane", + "draw_two_vs_one_stage", + "draw_viola_jones_cascade", + "draw_yolo_grid", + } + assert set(generate_q24_diagrams.__all__) == expected + + def test_imports_callable(self) -> None: + from generate_q24_diagrams import ( + draw_anchor_boxes, + draw_cnn_architecture, + draw_detection_tasks, + draw_detector_from_classifier, + draw_detr_pipeline, + draw_fpn, + draw_haar_features, + draw_hog_gradient_steps, + draw_hog_svm_pipeline, + draw_integral_image, + draw_iou_diagram, + draw_nms_steps, + draw_rcnn_evolution, + draw_roi_pooling, + draw_sliding_window, + draw_svm_hyperplane, + draw_two_vs_one_stage, + draw_viola_jones_cascade, + draw_yolo_grid, + ) + + fns = [ + draw_anchor_boxes, + draw_cnn_architecture, + draw_detection_tasks, + draw_detector_from_classifier, + draw_detr_pipeline, + draw_fpn, + draw_haar_features, + draw_hog_gradient_steps, + draw_hog_svm_pipeline, + draw_integral_image, + draw_iou_diagram, + draw_nms_steps, + draw_rcnn_evolution, + draw_roi_pooling, + draw_sliding_window, + draw_svm_hyperplane, + draw_two_vs_one_stage, + draw_viola_jones_cascade, + draw_yolo_grid, + ] + assert all(callable(f) for f in fns) diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_q31.py b/python_pkg/praca_magisterska_video/tests/test_gen_q31.py new file mode 100644 index 0000000..6865e30 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_q31.py @@ -0,0 +1,325 @@ +"""Tests for Q31 diagram generation (decision theory).""" + +from __future__ import annotations + +import matplotlib as mpl +import matplotlib.pyplot as plt +import pytest + + +@pytest.fixture(autouse=True) +def _patch_savefig(monkeypatch: pytest.MonkeyPatch) -> None: + """Prevent matplotlib from writing files to disk.""" + monkeypatch.setattr(mpl.figure.Figure, "savefig", lambda *_a, **_kw: None) + monkeypatch.setattr(plt, "savefig", lambda *_a, **_kw: None) + + +# ===================================================================== +# _q31_common +# ===================================================================== +class TestQ31Common: + """Tests for _q31_common constants and helpers.""" + + def test_constants_exist(self) -> None: + from _q31_common import ( + _DATA_STATE_COLS, + _REGRET_HEADER_COLS, + _WINNING_EV, + BG, + DPI, + FS, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + OUTPUT_DIR, + ) + + assert DPI == 300 + assert BG == "white" + assert LN == "black" + assert FS == 8 + assert FS_TITLE == 11 + assert _REGRET_HEADER_COLS == 4 + assert _DATA_STATE_COLS == 3 + assert _WINNING_EV == 95 + assert isinstance(GRAY1, str) + assert isinstance(GRAY2, str) + assert isinstance(GRAY3, str) + assert isinstance(GRAY4, str) + assert isinstance(GRAY5, str) + assert isinstance(OUTPUT_DIR, str) + + def test_draw_box_rounded(self) -> None: + from _q31_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 1.0, 2.0, 3.0, 1.0, "test", rounded=True) + assert len(ax.patches) == 1 + assert len(ax.texts) == 1 + plt.close(fig) + + def test_draw_box_not_rounded(self) -> None: + from _q31_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 0.0, 0.0, 2.0, 1.0, "rect", rounded=False) + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_box_custom_params(self) -> None: + from _q31_common import draw_box + + fig, ax = plt.subplots() + draw_box( + ax, + 0.0, + 0.0, + 2.0, + 1.0, + "custom", + fill="#FF0000", + lw=2.0, + fontsize=12, + fontweight="bold", + ha="left", + va="top", + rounded=True, + ) + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_arrow(self) -> None: + from _q31_common import draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, 0.0, 0.0, 1.0, 1.0) + plt.close(fig) + + def test_draw_arrow_custom_params(self) -> None: + from _q31_common import draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, 0.0, 0.0, 1.0, 1.0, lw=2.0, style="->", color="red") + plt.close(fig) + + +# ===================================================================== +# _q31_criteria_comparison +# ===================================================================== +class TestQ31CriteriaComparison: + """Tests for criteria comparison diagram.""" + + def test_draw_payoff_table(self) -> None: + from _q31_criteria_comparison import _draw_payoff_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 6) + ax.set_ylim(0, 6) + _draw_payoff_table(ax) + assert len(ax.patches) > 0 + assert len(ax.texts) > 0 + plt.close(fig) + + def test_draw_criteria_bars(self) -> None: + from _q31_criteria_comparison import _draw_criteria_bars + + fig, ax = plt.subplots() + _draw_criteria_bars(ax) + assert len(ax.texts) > 0 + plt.close(fig) + + def test_draw_criteria_comparison(self) -> None: + from _q31_criteria_comparison import draw_criteria_comparison + + draw_criteria_comparison() + + def test_payoff_table_negative_fill(self) -> None: + """Verify negative values get special fill.""" + from _q31_criteria_comparison import _draw_payoff_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 6) + ax.set_ylim(0, 6) + _draw_payoff_table(ax) + # Has patches for header + 3 data rows + probability row + assert len(ax.patches) >= 4 + plt.close(fig) + + def test_criteria_bars_winners(self) -> None: + """Verify star markers are placed for winners.""" + from _q31_criteria_comparison import _draw_criteria_bars + + fig, ax = plt.subplots() + _draw_criteria_bars(ax) + # Check star markers exist in texts + star_texts = [t for t in ax.texts if "★" in t.get_text()] + assert len(star_texts) > 0 + plt.close(fig) + + +# ===================================================================== +# _q31_ev_spectrum +# ===================================================================== +class TestQ31EvSpectrum: + """Tests for expected value and conditions spectrum.""" + + def test_draw_expected_value(self) -> None: + from _q31_ev_spectrum import draw_expected_value + + draw_expected_value() + + def test_draw_conditions_spectrum(self) -> None: + from _q31_ev_spectrum import draw_conditions_spectrum + + draw_conditions_spectrum() + + def test_expected_value_star_on_winner(self) -> None: + """The winning EV=95 alternative should get a star marker.""" + from _q31_ev_spectrum import draw_expected_value + + draw_expected_value() + + def test_conditions_spectrum_gradient(self) -> None: + """The gradient bar with 50 steps should be rendered.""" + from _q31_ev_spectrum import draw_conditions_spectrum + + draw_conditions_spectrum() + + +# ===================================================================== +# _q31_hurwicz_mnemonic +# ===================================================================== +class TestQ31HurwiczMnemonic: + """Tests for Hurwicz interpolation and criteria mnemonic.""" + + def test_draw_hurwicz_interpolation(self) -> None: + from _q31_hurwicz_mnemonic import draw_hurwicz_interpolation + + draw_hurwicz_interpolation() + + def test_draw_criteria_mnemonic(self) -> None: + from _q31_hurwicz_mnemonic import draw_criteria_mnemonic + + draw_criteria_mnemonic() + + def test_mnemonic_criteria_boxes(self) -> None: + """Exercise _draw_mnemonic_criteria_boxes with both if-branches.""" + from _q31_hurwicz_mnemonic import _draw_mnemonic_criteria_boxes + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 8) + _draw_mnemonic_criteria_boxes(ax) + # 6 criteria boxes + 6 arrows + labels + assert len(ax.patches) >= 6 + plt.close(fig) + + +# ===================================================================== +# _q31_regret_matrix +# ===================================================================== +class TestQ31RegretMatrix: + """Tests for regret matrix diagram.""" + + def test_draw_original_payoff(self) -> None: + from _q31_regret_matrix import _draw_original_payoff + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 7) + _draw_original_payoff(ax, 5.5, 0.55) + assert len(ax.patches) > 0 + plt.close(fig) + + def test_draw_regret_table(self) -> None: + from _q31_regret_matrix import _draw_regret_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 7) + _draw_regret_table(ax, 5.5, 0.55) + assert len(ax.patches) > 0 + plt.close(fig) + + def test_draw_regret_matrix(self) -> None: + from _q31_regret_matrix import draw_regret_matrix + + draw_regret_matrix() + + def test_regret_table_winner_highlight(self) -> None: + """The winner row (min max regret) gets special styling.""" + from _q31_regret_matrix import _draw_regret_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 7) + _draw_regret_table(ax, 5.5, 0.55) + # check star marker exists + star_texts = [t for t in ax.texts if "★" in t.get_text()] + assert len(star_texts) == 1 + plt.close(fig) + + def test_regret_table_max_regret_highlighting(self) -> None: + """Cells equal to max regret for a row get bold and gray fill.""" + from _q31_regret_matrix import _draw_regret_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 7) + _draw_regret_table(ax, 5.5, 0.55) + # Check that bold text exists for non-winner cells too + bold_texts = [ + t + for t in ax.texts + if t.get_fontweight() == "bold" and "★" not in t.get_text() + ] + assert len(bold_texts) > 0 + plt.close(fig) + + +# ===================================================================== +# generate_q31_diagrams +# ===================================================================== +class TestGenerateQ31Diagrams: + """Tests for the Q31 diagram generation entrypoint.""" + + def test_module_exports(self) -> None: + from generate_q31_diagrams import __all__ + + expected = [ + "draw_conditions_spectrum", + "draw_criteria_comparison", + "draw_criteria_mnemonic", + "draw_expected_value", + "draw_hurwicz_interpolation", + "draw_regret_matrix", + ] + assert sorted(__all__) == sorted(expected) + + def test_all_functions_callable(self) -> None: + import generate_q31_diagrams as mod + + for name in mod.__all__: + assert callable(getattr(mod, name)) + + def test_main_block(self) -> None: + """Exercise the __main__ block by re-running functions.""" + from generate_q31_diagrams import ( + draw_conditions_spectrum, + draw_criteria_comparison, + draw_criteria_mnemonic, + draw_expected_value, + draw_hurwicz_interpolation, + draw_regret_matrix, + ) + + draw_criteria_comparison() + draw_regret_matrix() + draw_hurwicz_interpolation() + draw_criteria_mnemonic() + draw_expected_value() + draw_conditions_spectrum() diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_q9.py b/python_pkg/praca_magisterska_video/tests/test_gen_q9.py new file mode 100644 index 0000000..40382b7 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_q9.py @@ -0,0 +1,343 @@ +"""Tests for Q9 diagram generation (concurrency: processes & threads).""" + +from __future__ import annotations + +import matplotlib as mpl +import matplotlib.pyplot as plt +import pytest + + +@pytest.fixture(autouse=True) +def _patch_savefig(monkeypatch: pytest.MonkeyPatch) -> None: + """Prevent matplotlib from writing files to disk.""" + monkeypatch.setattr(mpl.figure.Figure, "savefig", lambda *_a, **_kw: None) + monkeypatch.setattr(plt, "savefig", lambda *_a, **_kw: None) + + +# ===================================================================== +# _q9_common +# ===================================================================== +class TestQ9Common: + """Tests for _q9_common constants and helpers.""" + + def test_constants_exist(self) -> None: + from _q9_common import ( + BG, + DPI, + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + OCCUPIED_SLOTS, + OUTPUT_DIR, + ) + + assert DPI == 300 + assert BG == "white" + assert LN == "black" + assert FS == 8 + assert FS_TITLE == 11 + assert OCCUPIED_SLOTS == 2 + assert isinstance(FS_SMALL, float) + assert isinstance(FS_LABEL, int) + assert isinstance(GRAY1, str) + assert isinstance(GRAY2, str) + assert isinstance(GRAY3, str) + assert isinstance(GRAY4, str) + assert isinstance(GRAY5, str) + assert isinstance(OUTPUT_DIR, str) + + def test_draw_box_rounded(self) -> None: + from _q9_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 1.0, 2.0, 3.0, 1.0, "test") + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_box_not_rounded(self) -> None: + from _q9_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 0.0, 0.0, 2.0, 1.0, "rect", rounded=False) + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_box_custom_edgecolor_linestyle(self) -> None: + from _q9_common import draw_box + + fig, ax = plt.subplots() + draw_box( + ax, + 0.0, + 0.0, + 2.0, + 1.0, + "custom", + edgecolor="red", + linestyle="--", + rounded=True, + ) + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_box_not_rounded_custom_linestyle(self) -> None: + from _q9_common import draw_box + + fig, ax = plt.subplots() + draw_box( + ax, + 0.0, + 0.0, + 2.0, + 1.0, + "dashed", + edgecolor="blue", + linestyle="--", + rounded=False, + ) + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_arrow(self) -> None: + from _q9_common import draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, 0.0, 0.0, 1.0, 1.0) + plt.close(fig) + + def test_draw_double_arrow(self) -> None: + from _q9_common import draw_double_arrow + + fig, ax = plt.subplots() + draw_double_arrow(ax, 0.0, 0.0, 1.0, 1.0) + plt.close(fig) + + def test_save_fig(self) -> None: + from _q9_common import save_fig + + fig, _ax = plt.subplots() + save_fig(fig, "test_output.png") + + def test_draw_table(self) -> None: + from _q9_common import draw_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-5, 2) + headers = ["A", "B", "C"] + rows = [["1", "2", "3"], ["4", "5", "6"]] + draw_table(ax, headers, rows, 0, 1, [2.0, 3.0, 3.0], row_h=0.5) + assert len(ax.patches) > 0 + plt.close(fig) + + def test_draw_table_custom_fills(self) -> None: + from _q9_common import draw_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-5, 2) + headers = ["A", "B"] + rows = [["1", "2"], ["3", "4"]] + draw_table( + ax, + headers, + rows, + 0, + 1, + [2.0, 3.0], + row_fills=["#FF0000", "#00FF00"], + header_fontsize=10, + ) + plt.close(fig) + + def test_draw_table_no_header_fontsize(self) -> None: + from _q9_common import draw_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(-5, 2) + draw_table( + ax, + ["H1"], + [["V1"]], + 0, + 1, + [3.0], + header_fontsize=None, + ) + plt.close(fig) + + +# ===================================================================== +# _q9_basics +# ===================================================================== +class TestQ9Basics: + """Tests for the 6 basic process/thread diagrams.""" + + def test_gen_process_vs_thread(self) -> None: + from _q9_basics import gen_process_vs_thread + + gen_process_vs_thread() + + def test_gen_memory_layout(self) -> None: + from _q9_basics import gen_memory_layout + + gen_memory_layout() + + def test_gen_process_states(self) -> None: + from _q9_basics import gen_process_states + + gen_process_states() + + def test_gen_thread_structure(self) -> None: + from _q9_basics import gen_thread_structure + + gen_thread_structure() + + def test_gen_pcb_structure(self) -> None: + from _q9_basics import gen_pcb_structure + + gen_pcb_structure() + + def test_gen_speed_comparison(self) -> None: + from _q9_basics import gen_speed_comparison + + gen_speed_comparison() + + +# ===================================================================== +# _q9_classic_sync +# ===================================================================== +class TestQ9ClassicSync: + """Tests for classic sync problems.""" + + def test_draw_bounded_buffer_panel(self) -> None: + from _q9_classic_sync import _draw_bounded_buffer_panel + + fig, ax = plt.subplots() + _draw_bounded_buffer_panel(ax) + assert len(ax.patches) > 0 + plt.close(fig) + + def test_draw_readers_writers_panel(self) -> None: + from _q9_classic_sync import _draw_readers_writers_panel + + fig, ax = plt.subplots() + _draw_readers_writers_panel(ax) + assert len(ax.patches) > 0 + plt.close(fig) + + def test_draw_philosophers_panel(self) -> None: + from _q9_classic_sync import _draw_philosophers_panel + + fig, ax = plt.subplots() + _draw_philosophers_panel(ax) + assert len(ax.patches) > 0 + plt.close(fig) + + def test_gen_classic_problems(self) -> None: + from _q9_classic_sync import gen_classic_problems + + gen_classic_problems() + + def test_gen_sync_comparison(self) -> None: + from _q9_classic_sync import gen_sync_comparison + + gen_sync_comparison() + + def test_gen_semaphore_concept(self) -> None: + from _q9_classic_sync import gen_semaphore_concept + + gen_semaphore_concept() + + +# ===================================================================== +# _q9_ipc +# ===================================================================== +class TestQ9Ipc: + """Tests for IPC mechanism diagrams.""" + + def test_gen_scenario_table(self) -> None: + from _q9_ipc import gen_scenario_table + + gen_scenario_table() + + def test_gen_ipc_details(self) -> None: + from _q9_ipc import gen_ipc_details + + gen_ipc_details() + + def test_gen_ipc_table(self) -> None: + from _q9_ipc import gen_ipc_table + + gen_ipc_table() + + +# ===================================================================== +# _q9_race_deadlock +# ===================================================================== +class TestQ9RaceDeadlock: + """Tests for race condition, deadlock, and starvation diagrams.""" + + def test_gen_race_condition(self) -> None: + from _q9_race_deadlock import gen_race_condition + + gen_race_condition() + + def test_gen_deadlock_scenario(self) -> None: + from _q9_race_deadlock import gen_deadlock_scenario + + gen_deadlock_scenario() + + def test_gen_coffman_strategies(self) -> None: + from _q9_race_deadlock import gen_coffman_strategies + + gen_coffman_strategies() + + def test_gen_starvation_priority(self) -> None: + from _q9_race_deadlock import gen_starvation_priority + + gen_starvation_priority() + + +# ===================================================================== +# generate_q9_all_diagrams +# ===================================================================== +class TestGenerateQ9AllDiagrams: + """Tests for the Q9 diagram generation entrypoint.""" + + def test_module_exports(self) -> None: + from generate_q9_all_diagrams import __all__ + + expected = [ + "gen_classic_problems", + "gen_coffman_strategies", + "gen_deadlock_scenario", + "gen_ipc_details", + "gen_ipc_table", + "gen_memory_layout", + "gen_pcb_structure", + "gen_process_states", + "gen_process_vs_thread", + "gen_race_condition", + "gen_scenario_table", + "gen_semaphore_concept", + "gen_speed_comparison", + "gen_starvation_priority", + "gen_sync_comparison", + "gen_thread_structure", + ] + assert sorted(__all__) == sorted(expected) + + def test_all_functions_callable(self) -> None: + import generate_q9_all_diagrams as mod + + for name in mod.__all__: + assert callable(getattr(mod, name)) diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_q9q12.py b/python_pkg/praca_magisterska_video/tests/test_gen_q9q12.py new file mode 100644 index 0000000..34797f1 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_q9q12.py @@ -0,0 +1,295 @@ +"""Tests for Q9/Q12 diagram generation (networking/optimization).""" + +from __future__ import annotations + +import matplotlib as mpl +import matplotlib.pyplot as plt +import pytest + + +@pytest.fixture(autouse=True) +def _patch_savefig(monkeypatch: pytest.MonkeyPatch) -> None: + """Prevent matplotlib from writing files to disk.""" + monkeypatch.setattr(mpl.figure.Figure, "savefig", lambda *_a, **_kw: None) + monkeypatch.setattr(plt, "savefig", lambda *_a, **_kw: None) + + +# ===================================================================== +# _q9q12_common +# ===================================================================== +class TestQ9Q12Common: + """Tests for _q9q12_common constants and helpers.""" + + def test_constants_exist(self) -> None: + from _q9q12_common import ( + _CENTER_Y, + _LAST_CONDITION_INDEX, + BG, + DPI, + FS, + FS_EDGE, + FS_SMALL, + FS_TITLE, + GRAY1, + LIGHT_BLUE, + LIGHT_GREEN, + LIGHT_ORANGE, + LIGHT_RED, + LIGHT_YELLOW, + LN, + OUTPUT_DIR, + ) + + assert DPI == 300 + assert BG == "white" + assert LN == "black" + assert FS == 8 + assert FS_TITLE == 11 + assert _LAST_CONDITION_INDEX == 3 + assert _CENTER_Y == 2.5 + assert isinstance(FS_EDGE, int) + assert isinstance(FS_SMALL, float) + assert isinstance(GRAY1, str) + assert isinstance(LIGHT_GREEN, str) + assert isinstance(LIGHT_RED, str) + assert isinstance(LIGHT_BLUE, str) + assert isinstance(LIGHT_YELLOW, str) + assert isinstance(LIGHT_ORANGE, str) + assert isinstance(OUTPUT_DIR, str) + + def test_draw_box_rounded(self) -> None: + from _q9q12_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 1.0, 2.0, 3.0, 1.0, "test") + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_box_not_rounded(self) -> None: + from _q9q12_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 0.0, 0.0, 2.0, 1.0, "rect", rounded=False) + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_box_custom_edgecolor(self) -> None: + from _q9q12_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 0.0, 0.0, 2.0, 1.0, "custom", edgecolor="red") + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_arrow(self) -> None: + from _q9q12_common import draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, 0.0, 0.0, 1.0, 1.0) + plt.close(fig) + + def test_save_fig(self) -> None: + from _q9q12_common import save_fig + + fig, _ax = plt.subplots() + save_fig(fig, "test_q9q12.png") + + def test_draw_network_node(self) -> None: + from _q9q12_common import draw_network_node + + fig, ax = plt.subplots() + ax.set_xlim(0, 5) + ax.set_ylim(0, 5) + draw_network_node(ax, "A", (2.5, 2.5), color="white", fontsize=10, r=0.3) + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_network_edge_directed(self) -> None: + from _q9q12_common import draw_network_edge + + fig, ax = plt.subplots() + ax.set_xlim(0, 5) + ax.set_ylim(0, 5) + draw_network_edge( + ax, + (1.0, 1.0), + (4.0, 4.0), + label="10", + directed=True, + ) + plt.close(fig) + + def test_draw_network_edge_undirected(self) -> None: + from _q9q12_common import draw_network_edge + + fig, ax = plt.subplots() + ax.set_xlim(0, 5) + ax.set_ylim(0, 5) + draw_network_edge( + ax, + (1.0, 1.0), + (4.0, 4.0), + label="5", + directed=False, + ) + plt.close(fig) + + def test_draw_network_edge_no_label(self) -> None: + from _q9q12_common import draw_network_edge + + fig, ax = plt.subplots() + ax.set_xlim(0, 5) + ax.set_ylim(0, 5) + draw_network_edge(ax, (1.0, 1.0), (4.0, 4.0), label="") + plt.close(fig) + + def test_draw_network_edge_zero_length(self) -> None: + from _q9q12_common import draw_network_edge + + fig, ax = plt.subplots() + ax.set_xlim(0, 5) + ax.set_ylim(0, 5) + # Same start and end => length 0, should return early + draw_network_edge(ax, (2.0, 2.0), (2.0, 2.0), label="x") + plt.close(fig) + + def test_draw_network_edge_with_offset(self) -> None: + from _q9q12_common import draw_network_edge + + fig, ax = plt.subplots() + ax.set_xlim(0, 5) + ax.set_ylim(0, 5) + draw_network_edge( + ax, + (1.0, 1.0), + (4.0, 4.0), + label="off", + offset=0.5, + label_bg="#EEEEEE", + ) + plt.close(fig) + + +# ===================================================================== +# _q9q12_network_flow +# ===================================================================== +class TestQ9Q12NetworkFlow: + """Tests for network flow diagrams.""" + + def test_gen_ford_fulkerson(self) -> None: + from _q9q12_network_flow import gen_ford_fulkerson + + gen_ford_fulkerson() + + def test_gen_hungarian(self) -> None: + from _q9q12_network_flow import gen_hungarian + + gen_hungarian() + + def test_gen_min_cost_flow(self) -> None: + from _q9q12_network_flow import gen_min_cost_flow + + gen_min_cost_flow() + + +# ===================================================================== +# _q9q12_network_graph +# ===================================================================== +class TestQ9Q12NetworkGraph: + """Tests for network graph diagrams.""" + + def test_gen_cpm(self) -> None: + from _q9q12_network_graph import gen_cpm + + gen_cpm() + + def test_gen_kruskal(self) -> None: + from _q9q12_network_graph import gen_kruskal + + gen_kruskal() + + def test_gen_tsp(self) -> None: + from _q9q12_network_graph import gen_tsp + + gen_tsp() + + +# ===================================================================== +# _q9q12_processes +# ===================================================================== +class TestQ9Q12Processes: + """Tests for process diagrams (IPC, deadlock, producer-consumer).""" + + def test_gen_ipc_mechanisms(self) -> None: + from _q9q12_processes import gen_ipc_mechanisms + + gen_ipc_mechanisms() + + def test_gen_deadlock_illustration(self) -> None: + from _q9q12_processes import gen_deadlock_illustration + + gen_deadlock_illustration() + + def test_gen_producer_consumer(self) -> None: + from _q9q12_processes import gen_producer_consumer + + gen_producer_consumer() + + def test_deadlock_coffman_conditions(self) -> None: + """Verify all 4 Coffman conditions rendered, with last highlighted.""" + from _q9q12_processes import gen_deadlock_illustration + + gen_deadlock_illustration() + + +# ===================================================================== +# generate_q9_q12_diagrams +# ===================================================================== +class TestGenerateQ9Q12Diagrams: + """Tests for the Q9/Q12 diagram generation entrypoint.""" + + def test_imports_work(self) -> None: + from generate_q9_q12_diagrams import ( + gen_cpm, + gen_deadlock_illustration, + gen_ford_fulkerson, + gen_hungarian, + gen_ipc_mechanisms, + gen_kruskal, + gen_min_cost_flow, + gen_producer_consumer, + gen_tsp, + ) + + assert callable(gen_ford_fulkerson) + assert callable(gen_hungarian) + assert callable(gen_min_cost_flow) + assert callable(gen_cpm) + assert callable(gen_kruskal) + assert callable(gen_tsp) + assert callable(gen_ipc_mechanisms) + assert callable(gen_deadlock_illustration) + assert callable(gen_producer_consumer) + + def test_all_generators_run(self) -> None: + from generate_q9_q12_diagrams import ( + gen_cpm, + gen_deadlock_illustration, + gen_ford_fulkerson, + gen_hungarian, + gen_ipc_mechanisms, + gen_kruskal, + gen_min_cost_flow, + gen_producer_consumer, + gen_tsp, + ) + + gen_ipc_mechanisms() + gen_deadlock_illustration() + gen_producer_consumer() + gen_ford_fulkerson() + gen_hungarian() + gen_cpm() + gen_kruskal() + gen_tsp() + gen_min_cost_flow() diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_robot.py b/python_pkg/praca_magisterska_video/tests/test_gen_robot.py new file mode 100644 index 0000000..101c48f --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_robot.py @@ -0,0 +1,174 @@ +"""Tests for robot language diagram generation.""" + +from __future__ import annotations + +import matplotlib as mpl +import matplotlib.pyplot as plt +import pytest + + +@pytest.fixture(autouse=True) +def _patch_savefig(monkeypatch: pytest.MonkeyPatch) -> None: + """Prevent matplotlib from writing files to disk.""" + monkeypatch.setattr(mpl.figure.Figure, "savefig", lambda *_a, **_kw: None) + monkeypatch.setattr(plt, "savefig", lambda *_a, **_kw: None) + + +# ===================================================================== +# generate_robot_lang_diagrams (common helpers + entrypoint) +# ===================================================================== +class TestRobotLangCommon: + """Tests for generate_robot_lang_diagrams constants and helpers.""" + + def test_constants_exist(self) -> None: + from generate_robot_lang_diagrams import ( + BG, + DPI, + FS, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + OUTPUT_DIR, + WHITE, + ) + + assert DPI == 300 + assert BG == "white" + assert LN == "black" + assert FS == 8 + assert FS_TITLE == 11 + assert WHITE == "white" + assert isinstance(GRAY1, str) + assert isinstance(GRAY2, str) + assert isinstance(GRAY3, str) + assert isinstance(GRAY4, str) + assert isinstance(GRAY5, str) + assert isinstance(OUTPUT_DIR, str) + + def test_draw_box_rounded(self) -> None: + from generate_robot_lang_diagrams import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 1.0, 2.0, 3.0, 1.0, "test") + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_box_not_rounded(self) -> None: + from generate_robot_lang_diagrams import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 0.0, 0.0, 2.0, 1.0, "rect", rounded=False) + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_box_custom_params(self) -> None: + from generate_robot_lang_diagrams import draw_box + + fig, ax = plt.subplots() + draw_box( + ax, + 0.0, + 0.0, + 2.0, + 1.0, + "custom", + fill="red", + lw=2.0, + fontsize=12, + fontweight="bold", + ha="left", + va="top", + rounded=True, + ) + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_arrow(self) -> None: + from generate_robot_lang_diagrams import draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, 0.0, 0.0, 1.0, 1.0) + plt.close(fig) + + def test_draw_arrow_custom(self) -> None: + from generate_robot_lang_diagrams import draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, 0.0, 0.0, 1.0, 1.0, lw=2.0, style="<->", color="red") + plt.close(fig) + + +# ===================================================================== +# _robot_movement_ros +# ===================================================================== +class TestRobotMovementRos: + """Tests for movement types and online/offline diagrams.""" + + def test_draw_ptp_subplot(self) -> None: + from _robot_movement_ros import _draw_ptp_subplot + + fig, ax = plt.subplots() + _draw_ptp_subplot(ax) + plt.close(fig) + + def test_draw_lin_subplot(self) -> None: + from _robot_movement_ros import _draw_lin_subplot + + fig, ax = plt.subplots() + _draw_lin_subplot(ax) + plt.close(fig) + + def test_draw_circ_subplot(self) -> None: + from _robot_movement_ros import _draw_circ_subplot + + fig, ax = plt.subplots() + _draw_circ_subplot(ax) + plt.close(fig) + + def test_draw_movement_types(self) -> None: + from _robot_movement_ros import draw_movement_types + + draw_movement_types() + + def test_draw_online_offline(self) -> None: + from _robot_movement_ros import draw_online_offline + + draw_online_offline() + + +# ===================================================================== +# _robot_pyramid_vendor +# ===================================================================== +class TestRobotPyramidVendor: + """Tests for TRMS pyramid and vendor comparison diagrams.""" + + def test_draw_trms_pyramid(self) -> None: + from _robot_pyramid_vendor import draw_trms_pyramid + + draw_trms_pyramid() + + def test_draw_vendor_comparison(self) -> None: + from _robot_pyramid_vendor import draw_vendor_comparison + + draw_vendor_comparison() + + +# ===================================================================== +# _robot_ros_rapid +# ===================================================================== +class TestRobotRosRapid: + """Tests for ROS architecture and RAPID structure diagrams.""" + + def test_draw_ros_architecture(self) -> None: + from _robot_ros_rapid import draw_ros_architecture + + draw_ros_architecture() + + def test_draw_rapid_structure(self) -> None: + from _robot_ros_rapid import draw_rapid_structure + + draw_rapid_structure() diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_sched.py b/python_pkg/praca_magisterska_video/tests/test_gen_sched.py new file mode 100644 index 0000000..39aa11c --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_sched.py @@ -0,0 +1,254 @@ +"""Tests for scheduling diagram generation.""" + +from __future__ import annotations + +import matplotlib as mpl +import matplotlib.pyplot as plt +import pytest + + +@pytest.fixture(autouse=True) +def _patch_savefig(monkeypatch: pytest.MonkeyPatch) -> None: + """Prevent matplotlib from writing files to disk.""" + monkeypatch.setattr(mpl.figure.Figure, "savefig", lambda *_a, **_kw: None) + monkeypatch.setattr(plt, "savefig", lambda *_a, **_kw: None) + + +# ===================================================================== +# _sched_common +# ===================================================================== +class TestSchedCommon: + """Tests for scheduling common constants and helpers.""" + + def test_constants_exist(self) -> None: + from _sched_common import ( + BG, + DPI, + FONTWEIGHT_THRESHOLD, + FS, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + MIN_COLUMN_INDEX, + OUTPUT_DIR, + ) + + assert DPI == 300 + assert BG == "white" + assert LN == "black" + assert FS == 8 + assert FS_TITLE == 11 + assert MIN_COLUMN_INDEX == 3 + assert FONTWEIGHT_THRESHOLD == 3 + assert isinstance(GRAY1, str) + assert isinstance(GRAY2, str) + assert isinstance(GRAY3, str) + assert isinstance(GRAY4, str) + assert isinstance(GRAY5, str) + assert isinstance(OUTPUT_DIR, str) + + def test_draw_box_rounded(self) -> None: + from _sched_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 1.0, 2.0, 3.0, 1.0, "test") + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_box_not_rounded(self) -> None: + from _sched_common import draw_box + + fig, ax = plt.subplots() + draw_box(ax, 0.0, 0.0, 2.0, 1.0, "rect", rounded=False) + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_box_custom_params(self) -> None: + from _sched_common import draw_box + + fig, ax = plt.subplots() + draw_box( + ax, + 0.0, + 0.0, + 2.0, + 1.0, + "custom", + fill="red", + lw=2.0, + fontsize=12, + fontweight="bold", + ha="left", + va="top", + rounded=True, + ) + assert len(ax.patches) == 1 + plt.close(fig) + + def test_draw_arrow(self) -> None: + from _sched_common import draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, 0.0, 0.0, 1.0, 1.0) + plt.close(fig) + + def test_draw_arrow_custom(self) -> None: + from _sched_common import draw_arrow + + fig, ax = plt.subplots() + draw_arrow(ax, 0.0, 0.0, 1.0, 1.0, lw=2.0, style="<->", color="red") + plt.close(fig) + + +# ===================================================================== +# _sched_complexity_edd +# ===================================================================== +class TestSchedComplexityEdd: + """Tests for complexity map and EDD example.""" + + def test_draw_complexity_map(self) -> None: + from _sched_complexity_edd import draw_complexity_map + + draw_complexity_map() + + def test_draw_edd_example(self) -> None: + from _sched_complexity_edd import draw_edd_example + + draw_edd_example() + + +# ===================================================================== +# _sched_graham +# ===================================================================== +class TestSchedGraham: + """Tests for Graham notation diagram.""" + + def test_draw_graham_notation(self) -> None: + from _sched_graham import draw_graham_notation + + draw_graham_notation() + + def test_draw_graham_formula_bar(self) -> None: + from _sched_graham import _draw_graham_formula_bar + + fig, ax = plt.subplots() + _draw_graham_formula_bar(ax) + assert len(ax.patches) >= 3 + plt.close(fig) + + def test_draw_graham_alpha_beta(self) -> None: + from _sched_graham import _draw_graham_alpha_beta + + fig, ax = plt.subplots() + _draw_graham_alpha_beta(ax) + assert len(ax.patches) >= 7 + plt.close(fig) + + def test_draw_graham_lower(self) -> None: + from _sched_graham import _draw_graham_lower + + fig, ax = plt.subplots() + _draw_graham_lower(ax) + assert len(ax.patches) >= 6 + plt.close(fig) + + +# ===================================================================== +# _sched_johnson +# ===================================================================== +class TestSchedJohnson: + """Tests for Johnson Gantt chart diagram.""" + + def test_draw_johnson_gantt(self) -> None: + from _sched_johnson import draw_johnson_gantt + + draw_johnson_gantt() + + def test_draw_johnson_decision_table(self) -> None: + from _sched_johnson import _draw_johnson_decision_table + + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 5) + _draw_johnson_decision_table(ax) + assert len(ax.patches) >= 6 + plt.close(fig) + + def test_draw_johnson_gantt_chart(self) -> None: + from _sched_johnson import _draw_johnson_gantt_chart + + fig, ax = plt.subplots() + ax.set_xlim(-1, 24) + ax.set_ylim(-1, 4) + _draw_johnson_gantt_chart(ax) + assert len(ax.patches) >= 10 + plt.close(fig) + + +# ===================================================================== +# _sched_spt_flow_job +# ===================================================================== +class TestSchedSptFlowJob: + """Tests for SPT comparison and flow vs job shop diagrams.""" + + def test_draw_spt_comparison(self) -> None: + from _sched_spt_flow_job import draw_spt_comparison + + draw_spt_comparison() + + def test_draw_flow_vs_job(self) -> None: + from _sched_spt_flow_job import draw_flow_vs_job + + draw_flow_vs_job() + + def test_draw_flow_shop(self) -> None: + from _sched_spt_flow_job import _draw_flow_shop + + fig, ax = plt.subplots() + ax.set_xlim(0, 6) + ax.set_ylim(0, 6) + _draw_flow_shop(ax) + assert len(ax.patches) >= 3 + plt.close(fig) + + def test_draw_job_shop(self) -> None: + from _sched_spt_flow_job import _draw_job_shop + + fig, ax = plt.subplots() + ax.set_xlim(0, 6) + ax.set_ylim(0, 6) + _draw_job_shop(ax) + assert len(ax.patches) >= 3 + plt.close(fig) + + +# ===================================================================== +class TestGenerateSchedulingDiagrams: + """Tests for generate_scheduling_diagrams entrypoint.""" + + def test_all_exports(self) -> None: + import generate_scheduling_diagrams as mod + + for name in mod.__all__: + assert hasattr(mod, name) + + def test_reexported_constants(self) -> None: + import generate_scheduling_diagrams as mod + + assert mod.DPI == 300 + assert mod.MIN_COLUMN_INDEX == 3 + assert mod.FONTWEIGHT_THRESHOLD == 3 + + def test_reexported_generators_callable(self) -> None: + import generate_scheduling_diagrams as mod + + assert callable(mod.draw_complexity_map) + assert callable(mod.draw_edd_example) + assert callable(mod.draw_graham_notation) + assert callable(mod.draw_johnson_gantt) + assert callable(mod.draw_spt_comparison) + assert callable(mod.draw_flow_vs_job) diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_shortest_path.py b/python_pkg/praca_magisterska_video/tests/test_gen_shortest_path.py new file mode 100644 index 0000000..ed477d9 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_shortest_path.py @@ -0,0 +1,161 @@ +"""Tests for shortest path diagram generators.""" + +from __future__ import annotations + +import matplotlib as mpl +import matplotlib.pyplot as plt +import pytest + + +@pytest.fixture(autouse=True) +def _patch_savefig(monkeypatch: pytest.MonkeyPatch) -> None: + """Prevent matplotlib from writing files to disk.""" + monkeypatch.setattr(mpl.figure.Figure, "savefig", lambda *_a, **_kw: None) + monkeypatch.setattr(plt, "savefig", lambda *_a, **_kw: None) + + +# ===================================================================== +class TestShortestPathDiagrams: + """Tests for generate_shortest_path_diagrams constants and helpers.""" + + def test_constants_exist(self) -> None: + from generate_shortest_path_diagrams import ( + BG, + DPI, + EDGES, + FS, + FS_EDGE, + FS_TITLE, + GRAY3, + GRAY4, + LIGHT_BLUE, + LIGHT_GREEN, + LIGHT_YELLOW, + LN, + NODE_POS, + ) + + assert DPI == 300 + assert BG == "white" + assert LN == "black" + assert FS == 8 + assert FS_TITLE == 11 + assert FS_EDGE == 9 + assert isinstance(GRAY3, str) + assert isinstance(GRAY4, str) + assert isinstance(LIGHT_GREEN, str) + assert isinstance(LIGHT_BLUE, str) + assert isinstance(LIGHT_YELLOW, str) + assert isinstance(NODE_POS, dict) + assert isinstance(EDGES, list) + assert len(NODE_POS) == 4 + assert len(EDGES) == 4 + + def test_draw_graph_node_default(self) -> None: + from generate_shortest_path_diagrams import draw_graph_node + + _fig, ax = plt.subplots() + draw_graph_node(ax, "A", (1.0, 2.0)) + plt.close() + + def test_draw_graph_node_current(self) -> None: + from generate_shortest_path_diagrams import draw_graph_node + + _fig, ax = plt.subplots() + draw_graph_node(ax, "B", (1.0, 2.0), current=True, dist_label="5") + plt.close() + + def test_draw_graph_node_visited(self) -> None: + from generate_shortest_path_diagrams import draw_graph_node + + _fig, ax = plt.subplots() + draw_graph_node(ax, "C", (1.0, 2.0), visited=True, dist_label="∞") + plt.close() + + def test_draw_graph_node_custom_color(self) -> None: + from generate_shortest_path_diagrams import draw_graph_node + + _fig, ax = plt.subplots() + draw_graph_node(ax, "D", (3.0, 1.0), color="#FF0000", fontsize=10) + plt.close() + + def test_draw_graph_edge_default(self) -> None: + from generate_shortest_path_diagrams import draw_graph_edge + + _fig, ax = plt.subplots() + draw_graph_edge(ax, (0.0, 0.0), (3.0, 4.0), 5) + plt.close() + + def test_draw_graph_edge_highlighted(self) -> None: + from generate_shortest_path_diagrams import draw_graph_edge + + _fig, ax = plt.subplots() + draw_graph_edge(ax, (0.0, 0.0), (3.0, 4.0), 5, highlighted=True) + plt.close() + + def test_draw_graph_edge_relaxed(self) -> None: + from generate_shortest_path_diagrams import draw_graph_edge + + _fig, ax = plt.subplots() + draw_graph_edge(ax, (0.0, 0.0), (3.0, 4.0), 5, relaxed=True) + plt.close() + + def test_draw_full_graph_defaults(self) -> None: + from generate_shortest_path_diagrams import draw_full_graph + + _fig, ax = plt.subplots() + draw_full_graph(ax) + plt.close() + + def test_draw_full_graph_with_state(self) -> None: + from generate_shortest_path_diagrams import draw_full_graph + + _fig, ax = plt.subplots() + draw_full_graph( + ax, + title="Test", + dist={"A": "0", "B": "2"}, + current="A", + visited={"A"}, + highlighted_edges={("A", "B")}, + relaxed_edges={("B", "D")}, + ) + plt.close() + + def test_draw_full_graph_reverse_edge(self) -> None: + from generate_shortest_path_diagrams import draw_full_graph + + _fig, ax = plt.subplots() + draw_full_graph( + ax, + highlighted_edges={("B", "A")}, + relaxed_edges={("D", "B")}, + ) + plt.close() + + +# ===================================================================== +# _shortest_path_traversals +# ===================================================================== +class TestShortestPathTraversals: + """Tests for _shortest_path_traversals diagram functions.""" + + def test_draw_graph_structure(self) -> None: + from _shortest_path_traversals import draw_graph_structure + + draw_graph_structure() + + def test_draw_dijkstra_traversal(self) -> None: + from _shortest_path_traversals import draw_dijkstra_traversal + + draw_dijkstra_traversal() + + def test_draw_bellman_ford_traversal(self) -> None: + from _shortest_path_traversals import draw_bellman_ford_traversal + + draw_bellman_ford_traversal() + + def test_draw_astar_traversal(self) -> None: + from _shortest_path_traversals import draw_astar_traversal + + draw_astar_traversal() diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_split_questions.py b/python_pkg/praca_magisterska_video/tests/test_gen_split_questions.py new file mode 100644 index 0000000..92473c9 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_split_questions.py @@ -0,0 +1,108 @@ +"""Tests for split_questions module.""" + +from __future__ import annotations + +import importlib +from pathlib import Path +import sys +from unittest.mock import patch + +from typing_extensions import Self + + +class TestSplitQuestions: + """Tests for split_questions module.""" + + def _import_split_questions( + self, + source_content: str, + ) -> dict[str, object]: + """Import split_questions with mocked file I/O. + + The module has top-level code so we must mock before import. + """ + # Remove cached module to force re-import + mod_name = "split_questions" + sys.modules.pop(mod_name, None) + + class FakeFile: + def __init__(self, content: str = "") -> None: + self._content = content + self._lines_written: list[str] = [] + + def read(self) -> str: + return self._content + + def readlines(self) -> list[str]: + return self._content.splitlines(keepends=True) + + def writelines(self, lines: list[str]) -> None: + self._lines_written.extend(lines) + + def __enter__(self) -> Self: + return self + + def __exit__(self, *a: object) -> None: + pass + + source_file = FakeFile(source_content) + written_files: dict[str, FakeFile] = {} + + def fake_open(self_path: Path, *args: object, **kwargs: object) -> FakeFile: + path_str = str(self_path) + if "OBRONA_MAGISTERSKA_ODPOWIEDZI" in path_str: + return source_file + # Output file + f = FakeFile() + written_files[path_str] = f + return f + + with ( + patch.object(Path, "open", fake_open), + patch.object(Path, "mkdir", lambda *a, **kw: None), + ): + importlib.import_module(mod_name) + + return written_files + + def test_single_question(self) -> None: + """Test splitting with a single question.""" + content = "## PYTANIE 1: Algorytmy\nContent of question 1.\nMore content.\n" + self._import_split_questions(content) + + def test_multiple_questions(self) -> None: + """Test splitting with multiple questions.""" + content = ( + "## PYTANIE 1: First question\n" + "Content 1.\n" + "\n" + "## PYTANIE 2: Second question\n" + "Content 2.\n" + ) + self._import_split_questions(content) + + def test_dual_numbered_question(self) -> None: + """Test question with dual number like 13/27.""" + content = "## PYTANIE 13/27: Dual numbered\nContent here.\n" + self._import_split_questions(content) + + def test_trailing_newpage_stripped(self) -> None: + r"""Test that trailing \\newpage and blanks are stripped.""" + content = "## PYTANIE 5: Question five\nContent.\n\n\\newpage\n\n" + self._import_split_questions(content) + + def test_no_questions_found(self) -> None: + """Test with no matching question headers.""" + content = "# Just a title\nSome text.\n" + self._import_split_questions(content) + + def test_zero_padded_filenames(self) -> None: + """Test that single digit numbers are zero-padded.""" + content = ( + "## PYTANIE 3: Question three\n" + "Body.\n" + "\n" + "## PYTANIE 12: Question twelve\n" + "Body.\n" + ) + self._import_split_questions(content) diff --git a/python_pkg/praca_magisterska_video/tests/test_gen_study.py b/python_pkg/praca_magisterska_video/tests/test_gen_study.py new file mode 100644 index 0000000..5aed998 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_gen_study.py @@ -0,0 +1,176 @@ +"""Tests for study diagram generators.""" + +from __future__ import annotations + +import matplotlib as mpl +import matplotlib.pyplot as plt +import pytest + +# _study_vision uses scipy.stats.norm.cdf - patch it in fixtures instead of +# polluting sys.modules (which breaks other packages that import scipy). + + +@pytest.fixture(autouse=True) +def _patch_savefig(monkeypatch: pytest.MonkeyPatch) -> None: + """Prevent matplotlib from writing files to disk.""" + monkeypatch.setattr(mpl.figure.Figure, "savefig", lambda *_a, **_kw: None) + monkeypatch.setattr(plt, "savefig", lambda *_a, **_kw: None) + + +# ===================================================================== +class TestStudyDiagrams: + """Tests for generate_study_diagrams constants and helpers.""" + + def test_constants_exist(self) -> None: + from generate_study_diagrams import ( + BG, + DPI, + FS, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + OUTPUT_DIR, + ) + + assert DPI == 300 + assert BG == "white" + assert LN == "black" + assert FS == 8 + assert FS_TITLE == 12 + assert isinstance(GRAY1, str) + assert isinstance(GRAY2, str) + assert isinstance(GRAY3, str) + assert isinstance(GRAY4, str) + assert isinstance(GRAY5, str) + assert isinstance(OUTPUT_DIR, str) + + def test_draw_box_rounded(self) -> None: + from generate_study_diagrams import draw_box + + _fig, ax = plt.subplots() + draw_box(ax, 1.0, 2.0, 3.0, 1.0, "test box") + plt.close() + + def test_draw_box_not_rounded(self) -> None: + from generate_study_diagrams import draw_box + + _fig, ax = plt.subplots() + draw_box(ax, 1.0, 2.0, 3.0, 1.0, "rect", rounded=False) + plt.close() + + def test_draw_box_custom_params(self) -> None: + from generate_study_diagrams import draw_box + + _fig, ax = plt.subplots() + draw_box( + ax, + 0.0, + 0.0, + 2.0, + 1.0, + "custom", + fill="#FF0000", + lw=2.0, + fontsize=10.0, + fontweight="bold", + ha="left", + va="top", + ) + plt.close() + + def test_draw_arrow(self) -> None: + from generate_study_diagrams import draw_arrow + + _fig, ax = plt.subplots() + draw_arrow(ax, 0.0, 0.0, 5.0, 3.0) + plt.close() + + def test_draw_arrow_custom(self) -> None: + from generate_study_diagrams import draw_arrow + + _fig, ax = plt.subplots() + draw_arrow(ax, 1.0, 1.0, 4.0, 2.0, lw=2.0, style="<->", color="#FF0000") + plt.close() + + +# ===================================================================== +# _study_consensus +# ===================================================================== +class TestStudyConsensus: + """Tests for _study_consensus diagram functions.""" + + def test_draw_linearizability_vs_sequential(self) -> None: + from _study_consensus import draw_linearizability_vs_sequential + + draw_linearizability_vs_sequential() + + def test_draw_paxos_flow(self) -> None: + from _study_consensus import draw_paxos_flow + + draw_paxos_flow() + + +# ===================================================================== +# _study_network +# ===================================================================== +class TestStudyNetwork: + """Tests for _study_network diagram functions.""" + + def test_draw_network_models(self) -> None: + from _study_network import draw_network_models + + draw_network_models() + + def test_draw_vector_clock_timeline(self) -> None: + from _study_network import draw_vector_clock_timeline + + draw_vector_clock_timeline() + + +# ===================================================================== +# _study_vision +# ===================================================================== +class TestStudyVision: + """Tests for _study_vision diagram functions.""" + + def test_draw_hog_pipeline(self) -> None: + from _study_vision import draw_hog_pipeline + + draw_hog_pipeline() + + def test_draw_rcnn_evolution(self) -> None: + from _study_vision import draw_rcnn_evolution + + draw_rcnn_evolution() + + def test_draw_segmentation_types(self) -> None: + from _study_vision import draw_segmentation_types + + draw_segmentation_types() + + def test_draw_fsd_ssd(self) -> None: + from _study_vision import draw_fsd_ssd + + draw_fsd_ssd() + + def test_draw_instance_panel(self) -> None: + from _study_vision import _draw_instance_panel + + _fig, ax = plt.subplots() + ax.set_xlim(0, 6) + ax.set_ylim(0, 6) + _draw_instance_panel(ax) + plt.close() + + def test_draw_panoptic_panel(self) -> None: + from _study_vision import _draw_panoptic_panel + + _fig, ax = plt.subplots() + ax.set_xlim(0, 6) + ax.set_ylim(0, 6) + _draw_panoptic_panel(ax) + plt.close() diff --git a/python_pkg/praca_magisterska_video/tests/test_generate_anki_final_part2.py b/python_pkg/praca_magisterska_video/tests/test_generate_anki_final_part2.py new file mode 100644 index 0000000..dbf97c7 --- /dev/null +++ b/python_pkg/praca_magisterska_video/tests/test_generate_anki_final_part2.py @@ -0,0 +1,384 @@ +"""Tests for generate_images/generate_anki_final.py (part 2): full coverage.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + +_PKG = "python_pkg.praca_magisterska_video.generate_images.generate_anki_final" + +_SAMPLE_MD = """\ +# Pytanie 01: Test Subject + +Przedmiot: Informatyka + +## Pytanie + +**"What is the main concept of CS?"** + +## 📚 Odpowiedź główna + +### 1. First Concept + +#### Definicja +Computer science is the study of computation and algorithms. + +- **Term1**: Description of term one here that is long +- **Term2**: Description of term two here that is long +- **Term3** + +### 2. Second Concept + +Some paragraph content long enough to be captured as a nice fallback. + +Another paragraph here with more content for extraction purposes. + +```python +code_block = "should be skipped" +``` + +| table | data | + +### Przykład heading +Example text. + +#### Złożoność czasowa +O(n log n) for merge sort algorithm + +### Definicja important concept +Some definition text content. + +### Co to jest algorithm? +Algorithm is a step-by-step procedure. + +### Charakterystyka of sorting +Sorting algorithms have specific properties. + +## Porównanie methods vs others +| **Aspekt** | **Wartość** | +| **Time** | O(n) | +| **Space** | O(1) | + +## 🎓 Pytania egzaminacyjne + +### Q1: "What is an algorithm?" +Odpowiedź: +An algorithm is a finite sequence of well-defined instructions. +It produces an output from given inputs. +Used in computer science. +""" + +_NO_QUESTION_MD = """\ +# Some document + +Just text here without question format. +""" + + +@pytest.fixture +def sample_file(tmp_path: Path) -> Path: + """Create a sample markdown file.""" + p = tmp_path / "01-test-subject.md" + p.write_text(_SAMPLE_MD, encoding="utf-8") + return p + + +def test_clean_text_empty() -> None: + """clean_text returns empty for empty input.""" + from python_pkg.praca_magisterska_video.generate_images.generate_anki_final import ( + clean_text, + ) + + assert clean_text("") == "" + + +def test_clean_text_formatting() -> None: + """clean_text converts markdown to HTML.""" + from python_pkg.praca_magisterska_video.generate_images.generate_anki_final import ( + clean_text, + ) + + result = clean_text('**bold** *italic* "quote"\ttab spaces') + assert "" in result + assert "" in result + assert """ in result + + +def test_format_list_unordered() -> None: + """format_list creates unordered list.""" + from python_pkg.praca_magisterska_video.generate_images.generate_anki_final import ( + format_list, + ) + + result = format_list(["a", "b"]) + assert "