testsAndMisc/python_pkg/cinema_planner/tests/test_cinema_scheduling.py
Krzysztof kuhy Rudnicki 2545d72710 test: achieve 100% branch coverage across all python_pkg packages
- Add comprehensive tests for all packages (3572 tests, 100% branch coverage)
- Split oversized test files to stay under 500-line limit
- Add per-file ruff ignores for test-appropriate suppressions
- Fix _cache_decks.py to properly convert JSON lists to tuples
- Add session-scoped conftest fixture for logging handler cleanup (Python 3.14)
- Update ruff pre-commit hook to v0.15.2
- Add codespell ignore words for test data
- Add generated output files to .gitignore
2026-03-21 17:51:36 +01:00

339 lines
11 KiB
Python

"""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