diff --git a/docs/api/types.md b/docs/api/types.md index 81f71b9..24a7f8d 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -88,7 +88,7 @@ ::: mcsrranked.types.live.LiveMatch -::: mcsrranked.types.live.LiveMatchPlayer +::: mcsrranked.types.live.LivePlayerData ::: mcsrranked.types.live.LivePlayerTimeline diff --git a/pyproject.toml b/pyproject.toml index 6f70f5d..16d23af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcsrranked" -version = "0.1.1" +version = "0.2.0" description = "Python SDK for the MCSR Ranked API" readme = "README.md" license = "MIT" diff --git a/src/mcsrranked/__init__.py b/src/mcsrranked/__init__.py index 2df2262..f50365e 100644 --- a/src/mcsrranked/__init__.py +++ b/src/mcsrranked/__init__.py @@ -44,7 +44,7 @@ LeaderboardUser, LiveData, LiveMatch, - LiveMatchPlayer, + LivePlayerData, LivePlayerTimeline, MatchInfo, MatchRank, @@ -141,7 +141,7 @@ # Models - live "LiveData", "LiveMatch", - "LiveMatchPlayer", + "LivePlayerData", "LivePlayerTimeline", "UserLiveMatch", # Models - weekly_race diff --git a/src/mcsrranked/types/__init__.py b/src/mcsrranked/types/__init__.py index 02ea5bb..9546ffe 100644 --- a/src/mcsrranked/types/__init__.py +++ b/src/mcsrranked/types/__init__.py @@ -10,7 +10,7 @@ from mcsrranked.types.live import ( LiveData, LiveMatch, - LiveMatchPlayer, + LivePlayerData, LivePlayerTimeline, UserLiveMatch, ) @@ -85,7 +85,7 @@ # live "LiveData", "LiveMatch", - "LiveMatchPlayer", + "LivePlayerData", "LivePlayerTimeline", "UserLiveMatch", # weekly_race diff --git a/src/mcsrranked/types/live.py b/src/mcsrranked/types/live.py index 43f6245..0516e19 100644 --- a/src/mcsrranked/types/live.py +++ b/src/mcsrranked/types/live.py @@ -8,7 +8,7 @@ __all__ = [ "LiveData", "LiveMatch", - "LiveMatchPlayer", + "LivePlayerData", "LivePlayerTimeline", "UserLiveMatch", ] @@ -55,16 +55,6 @@ class LiveMatch(BaseModel): model_config = {"populate_by_name": True} -class LiveMatchPlayer(UserProfile): - """Player in a live match with stream data.""" - - live_url: str | None = Field( - default=None, alias="liveUrl", description="Live stream URL" - ) - - model_config = {"populate_by_name": True} - - class LiveData(BaseModel): """Live data response.""" diff --git a/src/mcsrranked/types/match.py b/src/mcsrranked/types/match.py index 1ef2223..3b5a714 100644 --- a/src/mcsrranked/types/match.py +++ b/src/mcsrranked/types/match.py @@ -69,6 +69,18 @@ class MatchInfo(BaseModel): default_factory=list, description="Match spectators" ) seed: MatchSeed | None = Field(default=None, description="Seed information") + seed_type: str | None = Field( + default=None, alias="seedType", description="Seed type" + ) + bastion_type: str | None = Field( + default=None, alias="bastionType", description="Bastion type" + ) + game_mode: str | None = Field( + default=None, alias="gameMode", description="Game mode" + ) + bot_source: str | None = Field( + default=None, alias="botSource", description="Bot source if applicable" + ) result: MatchResult | None = Field(default=None, description="Match result") forfeited: bool = Field( default=False, description="Whether match had no completions" diff --git a/src/mcsrranked/types/user.py b/src/mcsrranked/types/user.py index 74b5cd6..2f004db 100644 --- a/src/mcsrranked/types/user.py +++ b/src/mcsrranked/types/user.py @@ -2,7 +2,7 @@ from typing import Any -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, computed_field, model_validator from mcsrranked.types.shared import Achievement @@ -28,25 +28,31 @@ class MatchTypeStats(BaseModel): ) wins: int = Field(default=0, description="Total wins") losses: int = Field(default=0, description="Total losses") - draws: int = Field(default=0, description="Total draws") forfeits: int = Field(default=0, description="Total forfeits") highest_winstreak: int = Field( - default=0, alias="highestWinstreak", description="Highest win streak achieved" + default=0, alias="highestWinStreak", description="Highest win streak achieved" ) current_winstreak: int = Field( - default=0, alias="currentWinstreak", description="Current win streak" + default=0, alias="currentWinStreak", description="Current win streak" ) playtime: int = Field(default=0, description="Total playtime in milliseconds") + completion_time: int = Field( + default=0, + alias="completionTime", + description="Total time spent on completions in milliseconds", + ) best_time: int | None = Field( default=None, alias="bestTime", description="Best completion time in milliseconds", ) - best_time_id: int | None = Field( - default=None, alias="bestTimeId", description="Match ID of best time" - ) completions: int = Field(default=0, description="Total completions") + @computed_field(description="Total draws") # type: ignore[prop-decorator] + @property + def draws(self) -> int: + return self.played_matches - self.wins - self.losses + model_config = {"populate_by_name": True} @@ -74,12 +80,11 @@ def _pivot_stats(data: dict[str, Any]) -> dict[str, Any]: # API key -> model field name "wins": "wins", "loses": "losses", # Note: API uses 'loses' not 'losses' - "draws": "draws", "forfeits": "forfeits", "completions": "completions", "playtime": "playtime", + "completionTime": "completion_time", "bestTime": "best_time", - "bestTimeId": "best_time_id", "playedMatches": "played_matches", "currentWinStreak": "current_winstreak", "highestWinStreak": "highest_winstreak", diff --git a/src/mcsrranked/types/weekly_race.py b/src/mcsrranked/types/weekly_race.py index 87e6576..ea716b4 100644 --- a/src/mcsrranked/types/weekly_race.py +++ b/src/mcsrranked/types/weekly_race.py @@ -18,6 +18,7 @@ class WeeklyRaceSeed(BaseModel): nether: str | None = Field(default=None, description="Nether seed") the_end: str | None = Field(default=None, alias="theEnd", description="End seed") rng: str | None = Field(default=None, description="RNG seed") + flags: list[str] | None = Field(default=None, description="Seed flags") model_config = {"populate_by_name": True} diff --git a/tests/test_field_coverage.py b/tests/test_field_coverage.py index bb30347..adc30f9 100644 --- a/tests/test_field_coverage.py +++ b/tests/test_field_coverage.py @@ -16,6 +16,7 @@ LiveMatch, LivePlayerData, LivePlayerTimeline, + UserLiveMatch, ) from mcsrranked.types.match import ( Completion, @@ -111,13 +112,13 @@ def test_match_type_stats_all_fields(self, user_fixture): assert isinstance(mts.played_matches, int) assert isinstance(mts.wins, int) assert isinstance(mts.losses, int) - assert isinstance(mts.draws, int) + assert isinstance(mts.draws, int) # computed field assert isinstance(mts.forfeits, int) assert isinstance(mts.highest_winstreak, int) assert isinstance(mts.current_winstreak, int) assert isinstance(mts.playtime, int) + assert isinstance(mts.completion_time, int) assert mts.best_time is None or isinstance(mts.best_time, int) - assert mts.best_time_id is None or isinstance(mts.best_time_id, int) assert isinstance(mts.completions, int) def test_season_result_all_fields(self, user_fixture): @@ -224,6 +225,10 @@ def test_match_info_all_fields(self, match_detail_fixture): assert isinstance(match.players, list) assert isinstance(match.spectators, list) assert match.seed is None or isinstance(match.seed, MatchSeed) + assert match.seed_type is None or isinstance(match.seed_type, str) + assert match.bastion_type is None or isinstance(match.bastion_type, str) + assert match.game_mode is None or isinstance(match.game_mode, str) + assert match.bot_source is None or isinstance(match.bot_source, str) assert match.result is None or isinstance(match.result, MatchResult) assert isinstance(match.forfeited, bool) assert isinstance(match.decayed, bool) @@ -467,6 +472,33 @@ def test_live_player_timeline_all_fields(self, live_fixture): # If no timeline found, that's OK - it's nullable +class TestUserLiveMatchFieldCoverage: + """Test UserLiveMatch fields.""" + + def test_user_live_match_structure(self): + """Test UserLiveMatch can be constructed with all fields.""" + # User live endpoint data is not in fixtures, so test the model directly + live_match = UserLiveMatch( + last_id=123, + type=2, + status="running", + time=180000, + players=[], + spectators=[], + timelines=[], + completions=[], + ) + + assert live_match.last_id is None or isinstance(live_match.last_id, int) + assert isinstance(live_match.type, int) + assert isinstance(live_match.status, str) + assert isinstance(live_match.time, int) + assert isinstance(live_match.players, list) + assert isinstance(live_match.spectators, list) + assert isinstance(live_match.timelines, list) + assert isinstance(live_match.completions, list) + + class TestWeeklyRaceFieldCoverage: """Test every field in WeeklyRace types.""" @@ -488,6 +520,7 @@ def test_weekly_race_seed_all_fields(self, weekly_race_fixture): assert race.seed.nether is None or isinstance(race.seed.nether, str) assert race.seed.the_end is None or isinstance(race.seed.the_end, str) assert race.seed.rng is None or isinstance(race.seed.rng, str) + assert race.seed.flags is None or isinstance(race.seed.flags, list) def test_race_leaderboard_entry_all_fields(self, weekly_race_fixture): """Verify all RaceLeaderboardEntry fields.""" diff --git a/tests/test_fixture_parity.py b/tests/test_fixture_parity.py new file mode 100644 index 0000000..fd10864 --- /dev/null +++ b/tests/test_fixture_parity.py @@ -0,0 +1,513 @@ +# mypy: disable-error-code="no-untyped-def" +"""Tests to ensure model fields match API fixture fields.""" + +import pytest +from pydantic import BaseModel + +from mcsrranked.types.leaderboard import ( + EloLeaderboard, + LeaderboardSeasonResult, + LeaderboardUser, + PhaseInfo, + PhaseLeaderboard, + PhaseLeaderboardUser, + RecordEntry, + SeasonInfo, +) +from mcsrranked.types.live import ( + LiveData, + LiveMatch, + LivePlayerData, + LivePlayerTimeline, + UserLiveMatch, +) +from mcsrranked.types.match import ( + Completion, + MatchInfo, + MatchRank, + MatchResult, + Timeline, + VersusResults, + VersusStats, +) +from mcsrranked.types.shared import ( + Achievement, + EloChange, + MatchSeed, + UserProfile, + VodInfo, +) +from mcsrranked.types.user import ( + Connection, + LastSeasonState, + MatchTypeStats, + PhaseResult, + SeasonResult, + SeasonResultEntry, + User, + UserConnections, + UserSeasons, + UserStatistics, + UserTimestamps, + WeeklyRaceResult, +) +from mcsrranked.types.weekly_race import ( + RaceLeaderboardEntry, + WeeklyRace, + WeeklyRaceSeed, +) + + +def get_model_aliases(model: type[BaseModel]) -> set[str]: + """Get all field aliases for a model.""" + aliases = set() + for name, field in model.model_fields.items(): + alias = field.alias or name + aliases.add(alias) + return aliases + + +def check_parity( + model: type[BaseModel], + fixture_data: dict[str, object], + optional_fields: set[str] | None = None, +) -> tuple[set[str], set[str]]: + """Check field parity between model and fixture. + + Returns: + (missing_from_model, not_in_fixture) + """ + optional_fields = optional_fields or set() + model_aliases = get_model_aliases(model) + api_keys = set(fixture_data.keys()) + + missing_from_model = api_keys - model_aliases + not_in_fixture = model_aliases - api_keys - optional_fields + + return missing_from_model, not_in_fixture + + +class TestUserFixtureParity: + """Verify User models match user.json fixture.""" + + def test_user_parity(self, user_fixture): + missing, extra = check_parity(User, user_fixture) + assert not missing, f"Fields in API but not in User model: {missing}" + assert not extra, f"Fields in User model but not in API: {extra}" + + def test_user_timestamps_parity(self, user_fixture): + missing, extra = check_parity(UserTimestamps, user_fixture["timestamp"]) + assert not missing, f"Missing from UserTimestamps: {missing}" + assert not extra, f"Extra in UserTimestamps: {extra}" + + def test_user_connections_parity(self, user_fixture): + missing, extra = check_parity(UserConnections, user_fixture["connections"]) + assert not missing, f"Missing from UserConnections: {missing}" + assert not extra, f"Extra in UserConnections: {extra}" + + def test_connection_parity(self, user_fixture): + missing, extra = check_parity( + Connection, user_fixture["connections"]["discord"] + ) + assert not missing, f"Missing from Connection: {missing}" + assert not extra, f"Extra in Connection: {extra}" + + def test_season_result_parity(self, user_fixture): + missing, extra = check_parity(SeasonResult, user_fixture["seasonResult"]) + assert not missing, f"Missing from SeasonResult: {missing}" + assert not extra, f"Extra in SeasonResult: {extra}" + + def test_last_season_state_parity(self, user_fixture): + missing, extra = check_parity( + LastSeasonState, user_fixture["seasonResult"]["last"] + ) + assert not missing, f"Missing from LastSeasonState: {missing}" + assert not extra, f"Extra in LastSeasonState: {extra}" + + def test_achievement_parity(self, user_fixture): + missing, extra = check_parity( + Achievement, user_fixture["achievements"]["display"][0] + ) + assert not missing, f"Missing from Achievement: {missing}" + assert not extra, f"Extra in Achievement: {extra}" + + +class TestUserSeasonsFixtureParity: + """Verify UserSeasons models match user_seasons.json fixture.""" + + def test_user_seasons_parity(self, user_seasons_fixture): + missing, extra = check_parity(UserSeasons, user_seasons_fixture) + assert not missing, f"Missing from UserSeasons: {missing}" + assert not extra, f"Extra in UserSeasons: {extra}" + + def test_season_result_entry_parity(self, user_seasons_fixture): + # Get first season result entry + for entry in user_seasons_fixture["seasonResults"].values(): + missing, extra = check_parity(SeasonResultEntry, entry) + assert not missing, f"Missing from SeasonResultEntry: {missing}" + assert not extra, f"Extra in SeasonResultEntry: {extra}" + break + + def test_phase_result_parity(self, user_seasons_fixture): + # Find a season with phases + for entry in user_seasons_fixture["seasonResults"].values(): + if entry.get("phases"): + missing, extra = check_parity(PhaseResult, entry["phases"][0]) + assert not missing, f"Missing from PhaseResult: {missing}" + assert not extra, f"Extra in PhaseResult: {extra}" + return + pytest.skip("No phases in fixture") + + +class TestMatchTypeStatsParity: + """Verify MatchTypeStats pivot mapping matches API.""" + + def test_pivot_mapping_covers_all_api_fields(self, user_fixture): + """Ensure _pivot_stats mapping includes all API statistics fields.""" + # Collect all unique stat keys from API + api_stats_keys = set() + for section in ["season", "total"]: + for key in user_fixture["statistics"][section]: + api_stats_keys.add(key) + + # Current mapping in _pivot_stats (must match user.py) + mapped_keys = { + "wins", + "loses", + "forfeits", + "completions", + "playtime", + "completionTime", + "bestTime", + "playedMatches", + "currentWinStreak", + "highestWinStreak", + } + + missing = api_stats_keys - mapped_keys + assert not missing, f"API stats fields not in _pivot_stats mapping: {missing}" + + def test_match_type_stats_model_fields(self, user_fixture): + """Ensure MatchTypeStats model can parse pivoted stats.""" + user = User.model_validate(user_fixture) + mts = user.statistics.season.ranked + + # Verify all model fields are accessible (not hallucinated) + model_aliases = get_model_aliases(MatchTypeStats) + # These are the expected aliases after pivot transformation + expected_aliases = { + "playedMatches", + "wins", + "losses", # Note: API 'loses' maps to model 'losses' + "forfeits", + "highestWinStreak", + "currentWinStreak", + "playtime", + "completionTime", + "bestTime", + "completions", + } + + extra = model_aliases - expected_aliases + assert not extra, f"MatchTypeStats has unmapped fields: {extra}" + + # Verify the model actually parsed the data + assert isinstance(mts.played_matches, int) + assert isinstance(mts.wins, int) + assert isinstance(mts.losses, int) + assert isinstance(mts.completion_time, int) + + +class TestMatchFixtureParity: + """Verify Match models match match_detail.json fixture.""" + + def test_match_info_parity(self, match_detail_fixture): + # completions, timelines, replayExist are detail-only (optional in list endpoints) + missing, extra = check_parity( + MatchInfo, + match_detail_fixture, + optional_fields={"completions", "timelines", "replayExist"}, + ) + assert not missing, f"Missing from MatchInfo: {missing}" + assert not extra, f"Extra in MatchInfo: {extra}" + + def test_match_result_parity(self, match_detail_fixture): + missing, extra = check_parity(MatchResult, match_detail_fixture["result"]) + assert not missing, f"Missing from MatchResult: {missing}" + assert not extra, f"Extra in MatchResult: {extra}" + + def test_match_seed_parity(self, match_detail_fixture): + seed = match_detail_fixture.get("info", {}).get("seed") + if seed: + missing, extra = check_parity(MatchSeed, seed) + assert not missing, f"Missing from MatchSeed: {missing}" + assert not extra, f"Extra in MatchSeed: {extra}" + + def test_match_rank_parity(self, match_detail_fixture): + rank = match_detail_fixture.get("rank") + if rank: + missing, extra = check_parity(MatchRank, rank) + assert not missing, f"Missing from MatchRank: {missing}" + assert not extra, f"Extra in MatchRank: {extra}" + + def test_timeline_parity(self, match_detail_fixture): + timelines = match_detail_fixture.get("timelines", []) + if timelines: + missing, extra = check_parity(Timeline, timelines[0]) + assert not missing, f"Missing from Timeline: {missing}" + assert not extra, f"Extra in Timeline: {extra}" + + def test_elo_change_parity(self, match_detail_fixture): + changes = match_detail_fixture.get("changes", []) + if changes: + missing, extra = check_parity(EloChange, changes[0]) + assert not missing, f"Missing from EloChange: {missing}" + assert not extra, f"Extra in EloChange: {extra}" + + def test_completion_parity(self, match_detail_fixture): + completions = match_detail_fixture.get("completions", []) + if completions: + missing, extra = check_parity(Completion, completions[0]) + assert not missing, f"Missing from Completion: {missing}" + assert not extra, f"Extra in Completion: {extra}" + + +class TestLeaderboardFixtureParity: + """Verify Leaderboard models match leaderboard.json fixture.""" + + def test_elo_leaderboard_parity(self, leaderboard_fixture): + missing, extra = check_parity(EloLeaderboard, leaderboard_fixture) + assert not missing, f"Missing from EloLeaderboard: {missing}" + assert not extra, f"Extra in EloLeaderboard: {extra}" + + def test_season_info_parity(self, leaderboard_fixture): + missing, extra = check_parity(SeasonInfo, leaderboard_fixture["season"]) + assert not missing, f"Missing from SeasonInfo: {missing}" + assert not extra, f"Extra in SeasonInfo: {extra}" + + def test_leaderboard_user_parity(self, leaderboard_fixture): + if leaderboard_fixture["users"]: + missing, extra = check_parity( + LeaderboardUser, leaderboard_fixture["users"][0] + ) + assert not missing, f"Missing from LeaderboardUser: {missing}" + assert not extra, f"Extra in LeaderboardUser: {extra}" + + def test_leaderboard_season_result_parity(self, leaderboard_fixture): + if leaderboard_fixture["users"]: + missing, extra = check_parity( + LeaderboardSeasonResult, + leaderboard_fixture["users"][0]["seasonResult"], + ) + assert not missing, f"Missing from LeaderboardSeasonResult: {missing}" + assert not extra, f"Extra in LeaderboardSeasonResult: {extra}" + + +class TestPhaseLeaderboardFixtureParity: + """Verify PhaseLeaderboard models match phase_leaderboard.json fixture.""" + + def test_phase_leaderboard_parity(self, phase_leaderboard_fixture): + missing, extra = check_parity(PhaseLeaderboard, phase_leaderboard_fixture) + assert not missing, f"Missing from PhaseLeaderboard: {missing}" + assert not extra, f"Extra in PhaseLeaderboard: {extra}" + + def test_phase_info_parity(self, phase_leaderboard_fixture): + missing, extra = check_parity(PhaseInfo, phase_leaderboard_fixture["phase"]) + assert not missing, f"Missing from PhaseInfo: {missing}" + assert not extra, f"Extra in PhaseInfo: {extra}" + + def test_phase_leaderboard_user_parity(self, phase_leaderboard_fixture): + if phase_leaderboard_fixture["users"]: + # predPhasePoint may not appear if no predictions yet + missing, extra = check_parity( + PhaseLeaderboardUser, + phase_leaderboard_fixture["users"][0], + optional_fields={"predPhasePoint"}, + ) + assert not missing, f"Missing from PhaseLeaderboardUser: {missing}" + assert not extra, f"Extra in PhaseLeaderboardUser: {extra}" + + +class TestRecordLeaderboardFixtureParity: + """Verify RecordEntry model matches record_leaderboard.json fixture.""" + + def test_record_entry_parity(self, record_leaderboard_fixture): + if record_leaderboard_fixture: + missing, extra = check_parity(RecordEntry, record_leaderboard_fixture[0]) + assert not missing, f"Missing from RecordEntry: {missing}" + assert not extra, f"Extra in RecordEntry: {extra}" + + def test_record_seed_parity(self, record_leaderboard_fixture): + if record_leaderboard_fixture and record_leaderboard_fixture[0].get("seed"): + missing, extra = check_parity( + MatchSeed, record_leaderboard_fixture[0]["seed"] + ) + assert not missing, f"Missing from MatchSeed (record): {missing}" + assert not extra, f"Extra in MatchSeed (record): {extra}" + + +class TestLiveFixtureParity: + """Verify Live models match live.json fixture.""" + + def test_live_data_parity(self, live_fixture): + missing, extra = check_parity(LiveData, live_fixture) + assert not missing, f"Missing from LiveData: {missing}" + assert not extra, f"Extra in LiveData: {extra}" + + def test_live_match_parity(self, live_fixture): + if live_fixture["liveMatches"]: + missing, extra = check_parity(LiveMatch, live_fixture["liveMatches"][0]) + assert not missing, f"Missing from LiveMatch: {missing}" + assert not extra, f"Extra in LiveMatch: {extra}" + + def test_live_player_data_parity(self, live_fixture): + if live_fixture["liveMatches"]: + match = live_fixture["liveMatches"][0] + if match["data"]: + player_data = next(iter(match["data"].values())) + missing, extra = check_parity(LivePlayerData, player_data) + assert not missing, f"Missing from LivePlayerData: {missing}" + assert not extra, f"Extra in LivePlayerData: {extra}" + + def test_live_player_timeline_parity(self, live_fixture): + for match in live_fixture.get("liveMatches", []): + for player_data in match.get("data", {}).values(): + if player_data.get("timeline"): + missing, extra = check_parity( + LivePlayerTimeline, player_data["timeline"] + ) + assert not missing, f"Missing from LivePlayerTimeline: {missing}" + assert not extra, f"Extra in LivePlayerTimeline: {extra}" + return + pytest.skip("No timeline in live fixture") + + +class TestWeeklyRaceFixtureParity: + """Verify WeeklyRace models match weekly_race.json fixture.""" + + def test_weekly_race_parity(self, weekly_race_fixture): + missing, extra = check_parity(WeeklyRace, weekly_race_fixture) + assert not missing, f"Missing from WeeklyRace: {missing}" + assert not extra, f"Extra in WeeklyRace: {extra}" + + def test_weekly_race_seed_parity(self, weekly_race_fixture): + missing, extra = check_parity(WeeklyRaceSeed, weekly_race_fixture["seed"]) + assert not missing, f"Missing from WeeklyRaceSeed: {missing}" + assert not extra, f"Extra in WeeklyRaceSeed: {extra}" + + def test_race_leaderboard_entry_parity(self, weekly_race_fixture): + if weekly_race_fixture["leaderboard"]: + missing, extra = check_parity( + RaceLeaderboardEntry, weekly_race_fixture["leaderboard"][0] + ) + assert not missing, f"Missing from RaceLeaderboardEntry: {missing}" + assert not extra, f"Extra in RaceLeaderboardEntry: {extra}" + + +class TestVersusFixtureParity: + """Verify Versus models match versus.json fixture.""" + + def test_versus_stats_parity(self, versus_fixture): + missing, extra = check_parity(VersusStats, versus_fixture) + assert not missing, f"Missing from VersusStats: {missing}" + assert not extra, f"Extra in VersusStats: {extra}" + + def test_versus_results_parity(self, versus_fixture): + missing, extra = check_parity(VersusResults, versus_fixture["results"]) + assert not missing, f"Missing from VersusResults: {missing}" + assert not extra, f"Extra in VersusResults: {extra}" + + +class TestUserProfileParity: + """Verify UserProfile matches across different fixtures.""" + + def test_user_profile_in_match(self, match_detail_fixture): + if match_detail_fixture["players"]: + # UserProfile in match has extra nested fields we skip + player = match_detail_fixture["players"][0] + profile_keys = { + k for k in player if k not in {"timeline", "completion", "eloChange"} + } + model_aliases = get_model_aliases(UserProfile) + + missing = profile_keys - model_aliases + extra = model_aliases - profile_keys + + assert not missing, f"Missing from UserProfile: {missing}" + assert not extra, f"Extra in UserProfile: {extra}" + + def test_user_profile_in_leaderboard(self, leaderboard_fixture): + if leaderboard_fixture["users"]: + # LeaderboardUser extends UserProfile, check base fields exist + profile_keys = { + "uuid", + "nickname", + "roleType", + "eloRate", + "eloRank", + "country", + } + model_aliases = get_model_aliases(UserProfile) + + missing = profile_keys - model_aliases + assert not missing, f"Missing from UserProfile: {missing}" + + +class TestUserStatisticsParity: + """Verify UserStatistics model structure.""" + + def test_user_statistics_contains_season_and_total(self, user_fixture): + """Ensure UserStatistics has expected nested structure.""" + user = User.model_validate(user_fixture) + stats = user.statistics + + assert isinstance(stats, UserStatistics) + # Verify the nested structure exists + assert hasattr(stats, "season") + assert hasattr(stats, "total") + + +class TestWeeklyRaceResultParity: + """Verify WeeklyRaceResult model structure.""" + + def test_weekly_race_result_fields(self): + """Ensure WeeklyRaceResult has all expected fields.""" + model_aliases = get_model_aliases(WeeklyRaceResult) + expected = {"id", "time", "rank"} + + assert ( + model_aliases == expected + ), f"WeeklyRaceResult aliases mismatch: {model_aliases}" + + +class TestVodInfoParity: + """Verify VodInfo model structure.""" + + def test_vod_info_fields(self): + """Ensure VodInfo has all expected fields.""" + model_aliases = get_model_aliases(VodInfo) + expected = {"uuid", "url", "startsAt"} + + assert model_aliases == expected, f"VodInfo aliases mismatch: {model_aliases}" + + +class TestUserLiveMatchParity: + """Verify UserLiveMatch model structure.""" + + def test_user_live_match_fields(self): + """Ensure UserLiveMatch has all expected fields.""" + model_aliases = get_model_aliases(UserLiveMatch) + expected = { + "lastId", + "type", + "status", + "time", + "players", + "spectators", + "timelines", + "completions", + } + + assert ( + model_aliases == expected + ), f"UserLiveMatch aliases mismatch: {model_aliases}"