tobifinn's picture
Add sorting function for non-competitive teams in leaderboard
0488d7f
Raw
History Blame Contribute Delete
20.8 kB
# -*- coding: utf-8 -*-
r'''Tests for leaderboard_app/data.py.
Tests CSV loading, main leaderboard building with first-row-per-team logic,
team filtering, numeric rounding, and team history sorting.
'''
# System modules
from pathlib import Path
# External modules
import pandas as pd
import pytest
# Internal modules
from leaderboard_app.data import (
LEADERBOARD_COLUMNS,
HISTORY_COLUMNS,
_empty_display,
_empty_submissions,
_format_timestamps,
_normalize_submissions,
_round_numeric,
build_main_leaderboard,
build_team_history,
load_submissions,
)
CSV_HEADER = (
"team_name,score,mae_era5_region1,mae_era5_region2,"
"crps_aimip_region1,crps_aimip_region2,submitted_at\n"
)
# -----------------------------------------------------------
# Helper functions
# -----------------------------------------------------------
def _create_submissions_frame(
rows: list[dict[str, object]],
) -> pd.DataFrame:
r'''
Create a submissions DataFrame for testing.
Parameters
----------
rows : list[dict[str, object]]
List of row dictionaries.
Returns
-------
pandas.DataFrame
Submissions frame with required columns.
'''
frame = pd.DataFrame(rows)
frame["submitted_at"] = pd.to_datetime(frame["submitted_at"], utc=True)
frame["_source_order"] = range(len(frame))
return frame
# -----------------------------------------------------------
# Unit tests
# -----------------------------------------------------------
class TestLeaderboardDataUnittest:
r'''Isolated unit tests for leaderboard data helpers.'''
def test_empty_submissions_has_required_columns(self) -> None:
r'''Verifies _empty_submissions includes all required columns.'''
# Act
frame = _empty_submissions()
# Assert
assert "team_name" in frame.columns
assert "score" in frame.columns
assert "submitted_at" in frame.columns
assert "_source_order" in frame.columns
assert frame.empty
def test_empty_display_has_requested_columns(self) -> None:
r'''Verifies _empty_display creates frame with specified columns.'''
# Arrange
columns = ["team_name", "score"]
# Act
frame = _empty_display(columns)
# Assert
assert list(frame.columns) == columns
assert frame.empty
def test_round_numeric_rounds_to_three_decimals(self) -> None:
r'''Verifies _round_numeric rounds numeric columns to 3 decimals.'''
# Arrange
frame = pd.DataFrame(
{
"team_name": ["TeamA"],
"score": [0.123456789],
"mae_era5_region1": [1.987654321],
}
)
# Act
result = _round_numeric(frame)
# Assert
assert result.loc[0, "score"] == 0.123
assert result.loc[0, "mae_era5_region1"] == 1.988
def test_format_timestamps_converts_to_utc_strings(self) -> None:
r'''Verifies _format_timestamps converts datetime to UTC strings.'''
# Arrange
series = pd.Series(
pd.to_datetime(["2026-04-24T10:30:00Z"], utc=True)
)
# Act
result = _format_timestamps(series)
# Assert
assert result.iloc[0] == "2026-04-24 10:30:00 UTC"
def test_normalize_submissions_raises_on_missing_columns(self) -> None:
r'''Missing columns raise during normalization.'''
# Arrange
frame = pd.DataFrame({"team_name": ["TeamA"]})
# Act & Assert
with pytest.raises(ValueError, match="Missing required columns"):
_normalize_submissions(frame)
def test_normalize_submissions_adds_source_order(self) -> None:
r'''Verifies _normalize_submissions adds _source_order column.'''
# Arrange
frame = pd.DataFrame(
{
"team_name": ["TeamA", "TeamB"],
"score": [0.9, 0.8],
"mae_era5_region1": [1.0, 1.1],
"mae_era5_region2": [1.0, 1.1],
"crps_aimip_region1": [1.0, 1.1],
"crps_aimip_region2": [1.0, 1.1],
"submitted_at": ["2026-04-24", "2026-04-23"],
}
)
# Act
result = _normalize_submissions(frame)
# Assert
assert "_source_order" in result.columns
assert result.loc[0, "_source_order"] == 0
assert result.loc[1, "_source_order"] == 1
# -----------------------------------------------------------
# Functional tests
# -----------------------------------------------------------
class TestLeaderboardDataFunctional:
r'''End-to-end tests for leaderboard data building logic.'''
def test_build_main_leaderboard_takes_first_row_per_team(self) -> None:
r'''Main table keeps the first row for each team.'''
# Arrange
submissions = _create_submissions_frame(
[
{
"team_name": "TeamA",
"score": 0.95,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T10:00:00Z",
},
{
"team_name": "TeamA",
"score": 0.80,
"mae_era5_region1": 1.5,
"mae_era5_region2": 1.5,
"crps_aimip_region1": 1.5,
"crps_aimip_region2": 1.5,
"submitted_at": "2026-04-24T11:00:00Z",
},
{
"team_name": "TeamB",
"score": 0.85,
"mae_era5_region1": 1.2,
"mae_era5_region2": 1.2,
"crps_aimip_region1": 1.2,
"crps_aimip_region2": 1.2,
"submitted_at": "2026-04-24T09:00:00Z",
},
]
)
# Act
main = build_main_leaderboard(submissions)
# Assert
assert len(main) == 2
team_a_row = main.loc[main["team_name"] == "TeamA"]
assert team_a_row.iloc[0]["score"] == 0.95
def test_build_main_leaderboard_filters_by_team_query(self) -> None:
r'''Verifies main leaderboard restricts results by team name filter.'''
# Arrange
submissions = _create_submissions_frame(
[
{
"team_name": "TeamAlpha",
"score": 0.95,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T10:00:00Z",
},
{
"team_name": "TeamBeta",
"score": 0.85,
"mae_era5_region1": 1.2,
"mae_era5_region2": 1.2,
"crps_aimip_region1": 1.2,
"crps_aimip_region2": 1.2,
"submitted_at": "2026-04-24T09:00:00Z",
},
]
)
# Act
main = build_main_leaderboard(submissions, team_query="Alpha")
# Assert
assert len(main) == 1
assert main.iloc[0]["team_name"] == "TeamAlpha"
def test_build_main_leaderboard_rounds_numerics_to_three_decimals(
self,
) -> None:
r'''Verifies displayed numeric values are rounded to 3 decimals.'''
# Arrange
submissions = _create_submissions_frame(
[
{
"team_name": "TeamA",
"score": 0.123456789,
"mae_era5_region1": 1.987654321,
"mae_era5_region2": 2.123456789,
"crps_aimip_region1": 3.456789012,
"crps_aimip_region2": 4.567890123,
"submitted_at": "2026-04-24T10:00:00Z",
}
]
)
# Act
main = build_main_leaderboard(submissions)
# Assert
assert main.iloc[0]["score"] == 0.123
assert main.iloc[0]["mae_era5_region1"] == 1.988
assert main.iloc[0]["mae_era5_region2"] == 2.123
def test_build_main_leaderboard_labels_non_competitive_teams(
self,
) -> None:
r'''Non-competitive teams are labelled with (non-competitive).'''
# Arrange
submissions = _create_submissions_frame(
[
{
"team_name": "OrgTeam",
"score": 0.99,
"mae_era5_region1": 0.5,
"mae_era5_region2": 0.5,
"crps_aimip_region1": 0.5,
"crps_aimip_region2": 0.5,
"submitted_at": "2026-04-24T10:00:00Z",
},
{
"team_name": "CompetingTeam",
"score": 0.85,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T09:00:00Z",
},
]
)
non_competitive = frozenset({"OrgTeam"})
# Act
main = build_main_leaderboard(
submissions,
non_competitive_teams=non_competitive,
)
# Assert
org_row = main.loc[
main["team_name"].str.contains("OrgTeam")
]
assert len(org_row) == 1
assert org_row.iloc[0]["team_name"] == (
"* OrgTeam"
)
comp_row = main.loc[
main["team_name"] == "CompetingTeam"
]
assert len(comp_row) == 1
def test_build_main_leaderboard_non_competitive_pushed_to_end(
self,
) -> None:
r'''Non-competitive teams appear after all competitive teams.'''
# Arrange
submissions = _create_submissions_frame(
[
{
"team_name": "OrgTeam",
"score": 0.99,
"mae_era5_region1": 0.5,
"mae_era5_region2": 0.5,
"crps_aimip_region1": 0.5,
"crps_aimip_region2": 0.5,
"submitted_at": "2026-04-24T10:00:00Z",
},
{
"team_name": "CompetingTeam",
"score": 0.85,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T09:00:00Z",
},
]
)
non_competitive = frozenset({"OrgTeam"})
# Act
main = build_main_leaderboard(
submissions,
non_competitive_teams=non_competitive,
)
# Assert — competitive team first despite lower score
assert main.iloc[0]["team_name"] == "CompetingTeam"
assert main.iloc[1]["team_name"] == "* OrgTeam"
def test_build_main_leaderboard_non_competitive_sorted_by_score(
self,
) -> None:
r'''Multiple non-competitive teams are ordered by score desc.'''
# Arrange
submissions = _create_submissions_frame(
[
{
"team_name": "NC_Low",
"score": 0.60,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T08:00:00Z",
},
{
"team_name": "Competitor",
"score": 0.75,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T09:00:00Z",
},
{
"team_name": "NC_High",
"score": 0.90,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T10:00:00Z",
},
]
)
non_competitive = frozenset({"NC_Low", "NC_High"})
# Act
main = build_main_leaderboard(
submissions,
non_competitive_teams=non_competitive,
)
# Assert
assert main.iloc[0]["team_name"] == "Competitor"
assert main.iloc[1]["team_name"] == "* NC_High"
assert main.iloc[2]["team_name"] == "* NC_Low"
def test_build_team_history_returns_all_submissions_for_team(self) -> None:
r'''Team history keeps every row for that team.'''
# Arrange
submissions = _create_submissions_frame(
[
{
"team_name": "TeamA",
"score": 0.95,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T10:00:00Z",
},
{
"team_name": "TeamA",
"score": 0.80,
"mae_era5_region1": 1.5,
"mae_era5_region2": 1.5,
"crps_aimip_region1": 1.5,
"crps_aimip_region2": 1.5,
"submitted_at": "2026-04-24T11:00:00Z",
},
{
"team_name": "TeamB",
"score": 0.85,
"mae_era5_region1": 1.2,
"mae_era5_region2": 1.2,
"crps_aimip_region1": 1.2,
"crps_aimip_region2": 1.2,
"submitted_at": "2026-04-24T09:00:00Z",
},
]
)
# Act
history = build_team_history(submissions, "TeamA")
# Assert
assert len(history) == 2
assert all(history["submitted_at"].str.contains("2026-04-24"))
def test_build_team_history_sorted_by_submitted_at_descending(
self,
) -> None:
r'''Verifies team history is sorted by submitted_at descending.'''
# Arrange
submissions = _create_submissions_frame(
[
{
"team_name": "TeamA",
"score": 0.95,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T08:00:00Z",
},
{
"team_name": "TeamA",
"score": 0.80,
"mae_era5_region1": 1.5,
"mae_era5_region2": 1.5,
"crps_aimip_region1": 1.5,
"crps_aimip_region2": 1.5,
"submitted_at": "2026-04-24T12:00:00Z",
},
{
"team_name": "TeamA",
"score": 0.85,
"mae_era5_region1": 1.2,
"mae_era5_region2": 1.2,
"crps_aimip_region1": 1.2,
"crps_aimip_region2": 1.2,
"submitted_at": "2026-04-24T10:00:00Z",
},
]
)
# Act
history = build_team_history(submissions, "TeamA")
# Assert
assert len(history) == 3
assert history.iloc[0]["submitted_at"] == "2026-04-24 12:00:00 UTC"
assert history.iloc[1]["submitted_at"] == "2026-04-24 10:00:00 UTC"
assert history.iloc[2]["submitted_at"] == "2026-04-24 08:00:00 UTC"
def test_load_submissions_returns_empty_when_file_missing(
self,
tmp_path: Path,
) -> None:
r'''Missing CSV returns an empty snapshot.'''
# Arrange
missing_path = tmp_path / "missing.csv"
# Act
snapshot = load_submissions(missing_path)
# Assert
assert snapshot.submissions.empty
assert "not found" in snapshot.status_text
def test_load_submissions_normalizes_valid_csv(
self,
tmp_path: Path,
) -> None:
r'''Verifies load_submissions normalizes a valid CSV correctly.'''
# Arrange
csv_path = tmp_path / "submissions.csv"
csv_content = (
CSV_HEADER
+ "TeamA,0.95,1.0,1.0,1.0,1.0,2026-04-24T10:00:00Z\n"
+ "TeamB,0.85,1.2,1.2,1.2,1.2,2026-04-24T11:00:00Z\n"
)
csv_path.write_text(csv_content, encoding="utf-8")
# Act
snapshot = load_submissions(csv_path)
# Assert
assert len(snapshot.submissions) == 2
assert "Loaded 2 public submissions" in snapshot.status_text
# -----------------------------------------------------------
# Edge cases
# -----------------------------------------------------------
class TestLeaderboardDataEdgeCases:
r'''Boundary condition tests for leaderboard data helpers.'''
def test_build_main_leaderboard_handles_empty_submissions(self) -> None:
r'''Verifies main leaderboard handles empty submissions frame.'''
# Arrange
submissions = _empty_submissions()
# Act
main = build_main_leaderboard(submissions)
# Assert
assert main.empty
assert list(main.columns) == LEADERBOARD_COLUMNS
def test_build_main_leaderboard_no_label_when_no_non_competitive(
self,
) -> None:
r'''Team names are unmodified when non_competitive_teams is empty.'''
# Arrange
submissions = _create_submissions_frame(
[
{
"team_name": "TeamA",
"score": 0.9,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T10:00:00Z",
}
]
)
# Act
main = build_main_leaderboard(
submissions,
non_competitive_teams=frozenset(),
)
# Assert
assert main.iloc[0]["team_name"] == "TeamA"
def test_build_team_history_handles_empty_team_name(self) -> None:
r'''Verifies team history returns empty frame for empty team name.'''
# Arrange
submissions = _create_submissions_frame(
[
{
"team_name": "TeamA",
"score": 0.95,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T10:00:00Z",
}
]
)
# Act
history = build_team_history(submissions, "")
# Assert
assert history.empty
assert list(history.columns) == HISTORY_COLUMNS
def test_build_team_history_handles_nonexistent_team(self) -> None:
r'''Verifies team history returns empty frame for nonexistent team.'''
# Arrange
submissions = _create_submissions_frame(
[
{
"team_name": "TeamA",
"score": 0.95,
"mae_era5_region1": 1.0,
"mae_era5_region2": 1.0,
"crps_aimip_region1": 1.0,
"crps_aimip_region2": 1.0,
"submitted_at": "2026-04-24T10:00:00Z",
}
]
)
# Act
history = build_team_history(submissions, "TeamNonexistent")
# Assert
assert history.empty
def test_load_submissions_handles_malformed_csv(
self,
tmp_path: Path,
) -> None:
r'''Verifies load_submissions handles malformed CSV gracefully.'''
# Arrange
csv_path = tmp_path / "malformed.csv"
csv_path.write_text("invalid,csv,content\n1,2", encoding="utf-8")
# Act
snapshot = load_submissions(csv_path)
# Assert
assert snapshot.submissions.empty
assert "Could not load" in snapshot.status_text