diff --git a/src/pyfantastat/championship.py b/src/pyfantastat/championship.py index 5b812af..41df743 100644 --- a/src/pyfantastat/championship.py +++ b/src/pyfantastat/championship.py @@ -279,6 +279,44 @@ def generate_ranking(self) -> None: self.teams[match[0]].add_goals(goals[0], goals[1]) self.teams[match[1]].add_goals(goals[1], goals[0]) + def ranking_at_matchday( + self, + matchday: int, + sort: bool = False, + ) -> "np.ndarray | tuple[np.ndarray, np.ndarray]": + """Return league points at a specific matchday without mutating object state. + + :param matchday: Zero-based index of the matchday to evaluate (inclusive). + Pass ``0`` to get the state before any match is played. + :param sort: If ``True``, also return a sorted-indices array (descending + by points). For full tiebreaker resolution use + ``set_current_matchday`` + ``generate_ranking`` + ``sort_ranking``. + :returns: ``ranking`` array when *sort* is ``False``; ``(ranking, + sorted_indices)`` when *sort* is ``True``. + :raises ValueError: If calendar is not set or *matchday* is out of range. + """ + if not self.calendar: + raise ValueError("Calendar must be set before generating ranking.") + if matchday < 0 or matchday > len(self.calendar): + raise ValueError(f"Matchday index {matchday} is out of range.") + + ranking = np.zeros(len(self.teams)) + for day_idx, day in enumerate(self.calendar[:matchday]): + for match in day: + res, _ = self.match_result(match, day_idx) + if res == 1: + ranking[match[0]] += 3 + elif res == 2: + ranking[match[1]] += 3 + else: + ranking[match[0]] += 1 + ranking[match[1]] += 1 + + if sort: + sorted_indices = np.argsort(-ranking, kind="stable") + return ranking, sorted_indices + return ranking + # ------------------------------------------------------------------ # Ranking sorting / tiebreakers # ------------------------------------------------------------------ diff --git a/tests/test_championship.py b/tests/test_championship.py index 7afb7ad..6941fe6 100644 --- a/tests/test_championship.py +++ b/tests/test_championship.py @@ -84,3 +84,77 @@ def test_generate_ranking_idempotent(simple_championship): assert list(simple_championship.ranking) == list(ranking_first) assert goals_scored_first == goals_scored_second + + +# --------------------------------------------------------------------------- +# ranking_at_matchday tests +# --------------------------------------------------------------------------- + + +def test_ranking_at_matchday_zero(simple_championship): + """Before any match, all teams have 0 points.""" + result = simple_championship.ranking_at_matchday(0) + assert np.all(result == 0) + assert result.shape == (4,) + + +def test_ranking_at_matchday_partial(simple_championship): + """Points after matchday 1 match a manual re-run up to that point.""" + result = simple_championship.ranking_at_matchday(1) + # round 0: T0(80) vs T1(70) → T0 wins; T2(75) vs T3(66) → T2 wins + assert result[0] == 3 + assert result[2] == 3 + assert result[1] == 0 + assert result[3] == 0 + + +def test_ranking_at_matchday_full(simple_championship): + """At current_matchday, result matches self.ranking after generate_ranking().""" + simple_championship.set_current_matchday(3) + simple_championship.generate_ranking() + expected = simple_championship.ranking.copy() + + result = simple_championship.ranking_at_matchday(3) + np.testing.assert_array_equal(result, expected) + + +def test_ranking_at_matchday_does_not_mutate(simple_championship): + """ranking_at_matchday must not modify self.ranking or team goals.""" + simple_championship.generate_ranking() + ranking_before = simple_championship.ranking.copy() + goals_before = [t.goals_scored for t in simple_championship.teams] + + simple_championship.ranking_at_matchday(2) + + np.testing.assert_array_equal(simple_championship.ranking, ranking_before) + assert [t.goals_scored for t in simple_championship.teams] == goals_before + + +def test_ranking_at_matchday_sort_flag(simple_championship): + """sort=True returns (ranking, sorted_indices) with descending order.""" + ranking, sorted_indices = simple_championship.ranking_at_matchday(3, sort=True) + assert isinstance(ranking, np.ndarray) + assert isinstance(sorted_indices, np.ndarray) + # sorted_indices must produce a non-increasing sequence of points + assert all( + ranking[sorted_indices[i]] >= ranking[sorted_indices[i + 1]] + for i in range(len(sorted_indices) - 1) + ) + # first entry is the team with the most points + assert ranking[sorted_indices[0]] == ranking.max() + + +def test_ranking_at_matchday_out_of_range(simple_championship): + """Negative or too-large matchday raises ValueError.""" + with pytest.raises(ValueError): + simple_championship.ranking_at_matchday(-1) + with pytest.raises(ValueError): + simple_championship.ranking_at_matchday(999) + + +def test_ranking_at_matchday_no_calendar(): + """Raises ValueError when calendar is not set.""" + teams = [Team(f"T{i}", f"U{i}") for i in range(4)] + ch = Championship(teams) + with pytest.raises(ValueError): + ch.ranking_at_matchday(0)