From 2a0c9c96d2f9abb49701064a93dcd850c9f7f204 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:34:28 -0500 Subject: [PATCH 1/4] fix!: decode the shape the live v3 API actually sends getEventTeams threw a TypeError on every real response: StatboticsEpa read epa.total_points as a {mean, sd} object when the API sends a bare number, and StatboticsTeamEvent read wins/rank as top-level fields when they live under record.total and record.qual. Rename the EPA fields to match the API, add unitless and norm, and drop totalPointsSd, which the API no longer reports. Add getTeamYears and StatboticsTeamYear for season-over-season history, completing issue #9. Model tests now assert against captured live bodies in test/fixtures/, so a shape change fails a test instead of reaching a consumer. The hand-written maps agreed with the models rather than the API, which is how this survived. --- CHANGELOG.md | 47 +- README.md | 23 +- lib/src/statbotics_client.dart | 37 +- lib/src/statbotics_models.dart | 207 +++++-- pubspec.yaml | 2 +- test/fixtures/event.json | 56 ++ test/fixtures/events_by_year.json | 114 ++++ test/fixtures/matches_by_event.json | 690 ++++++++++++++++++++++++ test/fixtures/team_events_by_event.json | 162 ++++++ test/fixtures/team_events_by_team.json | 162 ++++++ test/fixtures/team_years_by_team.json | 104 ++++ test/fixtures/teams.json | 48 ++ test/statbotics_client_test.dart | 291 ++++++++-- 13 files changed, 1830 insertions(+), 113 deletions(-) create mode 100644 test/fixtures/event.json create mode 100644 test/fixtures/events_by_year.json create mode 100644 test/fixtures/matches_by_event.json create mode 100644 test/fixtures/team_events_by_event.json create mode 100644 test/fixtures/team_events_by_team.json create mode 100644 test/fixtures/team_years_by_team.json create mode 100644 test/fixtures/teams.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 579bbba..d09ecc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,44 @@ # Changelog -## Unreleased - -- Added `getTeamEvents(team, {year})` for a team's full Statbotics history - across events (part of issue #9). It reuses the `/team_events` endpoint with - a `team` filter instead of `event`, and optionally a `year`, returning every - team-event row for that team sorted newest season first, then by event key. - List endpoints return an empty list on 404, so an unknown team or a team - with no recorded events answers with an empty list rather than throwing. +## 0.4.0 + +Breaking. The live v3 API does not send the shape this client's EPA and +team-event models were written against, so `getEventTeams` threw a +`TypeError` on every real response. Statbotics answered HTTP 500 on every +data endpoint from 2026-06-15 to around 2026-09-02, which is why nothing +caught it sooner: the models could not be exercised against a live body, and +the tests asserted against hand-written maps that agreed with the models +rather than with the API. + +- `StatboticsEpa` now decodes the shape the API sends. `epa.total_points` is + a bare number, not a `{mean, sd}` object, and the per-phase measures live + under `epa.breakdown`. Renamed to match: `totalPointsMean` -> + `totalPoints`, `autoPointsMean` -> `autoPoints`, `teleopPointsMean` -> + `teleopPoints`, `endgamePointsMean` -> `endgamePoints`. Added `unitless` + and `norm`, the cross-season comparable scales. Removed `totalPointsSd`, + which the API no longer reports anywhere. +- `StatboticsTeamEvent` reads its record from the nested `record` object: + `wins`/`losses`/`ties` from `record.total`, and `rank`/`numTeams` from + `record.qual`. They were previously read as top-level fields, which the + API has no such thing as, so every one decoded to 0 or null. +- Added `getTeamYears(team)` and `StatboticsTeamYear` for season-over-season + history, completing issue #9. One row per season with that season's EPA, + record, and worldwide EPA rank. Note `/team_years` reports its record flat, + unlike `/team_events`. +- `StatboticsEpa.fromJson` still reads the old `{mean, sd}` form for + `total_points`, so a last-good cache written by an earlier version loads + instead of throwing on upgrade. +- Model tests now assert against captured live response bodies in + `test/fixtures/`, so an API shape change fails a test. +- Corrected two doc comments: an unknown team number answers 200 with `[]`, + not 404. A team number of 100000 or more is rejected with HTTP 422. +- Includes `getTeamEvents(team, {year})`, merged but never released under + 0.3.2: a team's history across events, from `/team_events` with a `team` + filter, sorted newest season first then by event key. + +Migrating: rename the four EPA field reads. Nothing else in the public API +changed, and code that only reads `record`, `rank` or `numTeams` off a +`StatboticsTeamEvent` needs no edit, though it was reading zeros before. ## 0.3.1 diff --git a/README.md b/README.md index 5ae4c0d..6020ded 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,17 @@ call `close()` when you are done so the underlying HTTP client is released. | `getEvents(year)` | `GET /events?year={year}` | `List` | | `getEventTeams(eventKey)` | `GET /team_events?event={eventKey}` | `List` | | `getTeamEvents(team, {year})` | `GET /team_events?team={team}[&year={year}]` | `List` | +| `getTeamYears(team)` | `GET /team_years?team={team}` | `List` | | `getEventTeamsBasic(eventKey)` | `GET /teams?event={eventKey}` | `List` | | `getEventMatches(eventKey)` | `GET /matches?event={eventKey}` | `List` | - `getEventTeams` sorts results by rank ascending. - `getTeamEvents` sorts results newest season first, then by event key, so a team's most recent results lead. Pass `year` to narrow to one season. +- `getTeamYears` sorts results newest season first, one row per season the + team competed in. Compare seasons on `epa.unitless` or `epa.norm`, not + `epa.totalPoints`: point values belong to a season's game, so a 2002 total of + 16.6 and a 2025 total of 92.77 say nothing about which robot was better. - `getEvents` sorts results by week then name. - `getEventMatches` sorts results by comp level (`qm`, `ef`, `qf`, `sf`, `f`) then match number. @@ -42,9 +47,21 @@ call `close()` when you are done so the underlying HTTP client is released. an empty list if the endpoint is unavailable. The API is public and read-only, so models only decode the fields each endpoint -exposes (EPA means and standard deviations on `StatboticsTeamEvent`, alliance -teams on `StatboticsMatch`, dates and location on `StatboticsEvent`). Each model -also round-trips through `toJson` for caching. +exposes (EPA and the per-phase breakdown on `StatboticsTeamEvent` and +`StatboticsTeamYear`, alliance teams on `StatboticsMatch`, dates and location +on `StatboticsEvent`). Each model also round-trips through `toJson` for +caching. + +`StatboticsEpa.totalPoints` is the point estimate the API reports at +`epa.total_points`, not an average over matches. The per-phase measures +(`autoPoints`, `teleopPoints`, `endgamePoints`) come from `epa.breakdown` and +are null for seasons predating that game's scoring split, so treat every EPA +field as nullable. + +Model tests assert against captured live response bodies in `test/fixtures/`, +not only hand-written maps, so a change to the API's shape fails a test rather +than reaching a consumer. Refresh a fixture by re-running the `curl` in the +comment above `_fixture` in the test file. ## Retries and backoff diff --git a/lib/src/statbotics_client.dart b/lib/src/statbotics_client.dart index 1a1739d..16a5cfc 100644 --- a/lib/src/statbotics_client.dart +++ b/lib/src/statbotics_client.dart @@ -72,8 +72,8 @@ class StatboticsClient { /// just drops the `event` filter and adds `team` (and optionally `year`), /// answering with every row that mentions the team instead of every row for /// one event. Results are sorted by year descending, then event key - /// ascending. Returns an empty list on 404 (an unknown team number) or if - /// the team has no recorded events. + /// ascending. A team Statbotics has no events for answers 200 with `[]`, so + /// an unknown team number is an empty list rather than an error. /// /// The 1000-row cap is fixed rather than a parameter, the way the other /// list methods fix theirs: a team plays a handful of events a season, so @@ -105,6 +105,39 @@ class StatboticsClient { return results; } + /// `GET /v3/team_years?team={team}&limit=100` — returns one row per season + /// the team competed in, newest season first. + /// + /// The season-over-season view [getTeamEvents] cannot give: that one answers + /// per event within a season, this one answers per season. A team Statbotics + /// has no seasons for answers 200 with `[]`, so an unknown team number is an + /// empty list rather than an error. A [team] of 100000 or more is rejected + /// with HTTP 422 and throws [StatboticsApiException]: that is a malformed + /// request, not an unknown team. + /// + /// The 100-row cap is fixed the way the other list methods fix theirs. FRC + /// has run since 1992, so no team has more seasons than that. + Future> getTeamYears(int team) async { + final body = await _get( + '/team_years', + queryParameters: { + 'team': team.toString(), + 'limit': '100', + }, + ); + if (body == null) return const []; + final list = jsonDecode(body) as List; + final results = list + .map( + (json) => StatboticsTeamYear.fromJson( + (json as Map).cast(), + ), + ) + .toList(growable: true); + results.sort((a, b) => b.year.compareTo(a.year)); + return results; + } + /// `GET /v3/events?year={year}` — returns all events for the given year, /// sorted by week then name. Future> getEvents(int year) async { diff --git a/lib/src/statbotics_models.dart b/lib/src/statbotics_models.dart index c28b404..165a60b 100644 --- a/lib/src/statbotics_models.dart +++ b/lib/src/statbotics_models.dart @@ -175,39 +175,62 @@ class StatboticsEvent { } } -/// EPA (Expected Points Added) breakdown for a team at an event. +/// A number the API may report either bare or wrapped in a `{mean: ...}` +/// object. +/// +/// v3 reports EPA measures bare. The wrapped form only turns up in a +/// last-good cache written by an older client (#512), so reading both keeps an +/// upgrade from throwing on the first cache read. +double? _readMeasure(Object? raw) { + if (raw is num) return raw.toDouble(); + if (raw is Map) return (raw['mean'] as num?)?.toDouble(); + return null; +} + +/// EPA (Expected Points Added) for a team, as `/team_events` and +/// `/team_years` report it. +/// +/// [totalPoints] is the point estimate the API puts at `epa.total_points`, +/// not an average over matches -- `epa.stats.mean` is a different number and +/// this model does not carry it. The per-phase measures live under +/// `epa.breakdown` and are absent for seasons predating that game's scoring +/// split, so every field here is nullable. class StatboticsEpa { const StatboticsEpa({ - this.totalPointsMean, - this.totalPointsSd, - this.autoPointsMean, - this.teleopPointsMean, - this.endgamePointsMean, + this.totalPoints, + this.unitless, + this.norm, + this.autoPoints, + this.teleopPoints, + this.endgamePoints, }); factory StatboticsEpa.fromJson(Map json) { - final total = - (json['total_points'] as Map?)?.cast() ?? const {}; - final auto = - (json['auto_points'] as Map?)?.cast() ?? const {}; - final teleop = - (json['teleop_points'] as Map?)?.cast() ?? const {}; - final endgame = - (json['endgame_points'] as Map?)?.cast() ?? const {}; + final breakdown = + (json['breakdown'] as Map?)?.cast() ?? const {}; return StatboticsEpa( - totalPointsMean: (total['mean'] as num?)?.toDouble(), - totalPointsSd: (total['sd'] as num?)?.toDouble(), - autoPointsMean: (auto['mean'] as num?)?.toDouble(), - teleopPointsMean: (teleop['mean'] as num?)?.toDouble(), - endgamePointsMean: (endgame['mean'] as num?)?.toDouble(), + totalPoints: _readMeasure(json['total_points']), + unitless: (json['unitless'] as num?)?.toDouble(), + norm: (json['norm'] as num?)?.toDouble(), + autoPoints: _readMeasure(breakdown['auto_points']), + teleopPoints: _readMeasure(breakdown['teleop_points']), + endgamePoints: _readMeasure(breakdown['endgame_points']), ); } - final double? totalPointsMean; - final double? totalPointsSd; - final double? autoPointsMean; - final double? teleopPointsMean; - final double? endgamePointsMean; + /// The team's EPA in game points. + final double? totalPoints; + + /// The unitless EPA scale, comparable across seasons where [totalPoints] is + /// not, since a season's point values are its own. + final double? unitless; + + /// The normalized EPA scale, centred so 1500 is an average team. + final double? norm; + + final double? autoPoints; + final double? teleopPoints; + final double? endgamePoints; static const StatboticsEpa empty = StatboticsEpa(); @@ -215,13 +238,14 @@ class StatboticsEpa { /// last-good cache (#512). Map toJson() { return { - 'total_points': { - 'mean': totalPointsMean, - 'sd': totalPointsSd, + 'total_points': totalPoints, + 'unitless': unitless, + 'norm': norm, + 'breakdown': { + 'auto_points': autoPoints, + 'teleop_points': teleopPoints, + 'endgame_points': endgamePoints, }, - 'auto_points': {'mean': autoPointsMean}, - 'teleop_points': {'mean': teleopPointsMean}, - 'endgame_points': {'mean': endgamePointsMean}, }; } } @@ -244,17 +268,22 @@ class StatboticsTeamEvent { factory StatboticsTeamEvent.fromJson(Map json) { final rawEpa = (json['epa'] as Map?)?.cast(); + final record = + (json['record'] as Map?)?.cast() ?? const {}; + final total = + (record['total'] as Map?)?.cast() ?? const {}; + final qual = (record['qual'] as Map?)?.cast() ?? const {}; return StatboticsTeamEvent( team: (json['team'] as num?)?.toInt() ?? 0, event: (json['event'] as String?) ?? '', eventName: (json['event_name'] as String?) ?? '', teamName: (json['team_name'] as String?) ?? '', year: (json['year'] as num?)?.toInt() ?? 0, - wins: (json['wins'] as num?)?.toInt() ?? 0, - losses: (json['losses'] as num?)?.toInt() ?? 0, - ties: (json['ties'] as num?)?.toInt() ?? 0, - rank: (json['rank'] as num?)?.toInt(), - numTeams: (json['num_teams'] as num?)?.toInt(), + wins: (total['wins'] as num?)?.toInt() ?? 0, + losses: (total['losses'] as num?)?.toInt() ?? 0, + ties: (total['ties'] as num?)?.toInt() ?? 0, + rank: (qual['rank'] as num?)?.toInt(), + numTeams: (qual['num_teams'] as num?)?.toInt(), epa: rawEpa != null ? StatboticsEpa.fromJson(rawEpa) : StatboticsEpa.empty, ); @@ -272,9 +301,15 @@ class StatboticsTeamEvent { /// omits it. final String teamName; final int year; + + /// Won-lost-tied over the whole event, quals and elims together, from + /// `record.total`. final int wins; final int losses; final int ties; + + /// Qualification rank and field size, from `record.qual`. Null before the + /// event has ranked anyone, and for an event Statbotics has no record of. final int? rank; final int? numTeams; final StatboticsEpa epa; @@ -288,14 +323,106 @@ class StatboticsTeamEvent { 'event_name': eventName, 'team_name': teamName, 'year': year, - 'wins': wins, - 'losses': losses, - 'ties': ties, - 'rank': rank, - 'num_teams': numTeams, + 'record': { + 'qual': {'rank': rank, 'num_teams': numTeams}, + 'total': { + 'wins': wins, + 'losses': losses, + 'ties': ties, + }, + }, 'epa': epa.toJson(), }; } String get record => '$wins-$losses${ties > 0 ? '-$ties' : ''}'; } + +/// One team's season, as `/team_years` reports it. +/// +/// The season-over-season counterpart to [StatboticsTeamEvent]: one row per +/// year a team competed, carrying that season's EPA and record. +/// +/// Compare seasons on [StatboticsEpa.unitless] or [StatboticsEpa.norm] rather +/// than [StatboticsEpa.totalPoints]. Point values belong to a season's game, +/// so a 2002 total of 16.6 and a 2025 total of 92.77 say nothing about which +/// robot was better. +class StatboticsTeamYear { + StatboticsTeamYear({ + required this.team, + required this.year, + this.name = '', + required this.wins, + required this.losses, + required this.ties, + this.epaRank, + this.epaRankTeamCount, + required this.epa, + }); + + factory StatboticsTeamYear.fromJson(Map json) { + final rawEpa = (json['epa'] as Map?)?.cast(); + // Flat here, unlike `/team_events`, which splits the record into + // qual/elim/total. + final record = + (json['record'] as Map?)?.cast() ?? const {}; + final ranks = + (rawEpa?['ranks'] as Map?)?.cast() ?? const {}; + final total = (ranks['total'] as Map?)?.cast() ?? const {}; + return StatboticsTeamYear( + team: (json['team'] as num?)?.toInt() ?? 0, + year: (json['year'] as num?)?.toInt() ?? 0, + name: (json['name'] as String?) ?? '', + wins: (record['wins'] as num?)?.toInt() ?? 0, + losses: (record['losses'] as num?)?.toInt() ?? 0, + ties: (record['ties'] as num?)?.toInt() ?? 0, + epaRank: (total['rank'] as num?)?.toInt(), + epaRankTeamCount: (total['team_count'] as num?)?.toInt(), + epa: + rawEpa != null ? StatboticsEpa.fromJson(rawEpa) : StatboticsEpa.empty, + ); + } + + final int team; + final int year; + + /// The team's nickname for that season. + final String name; + + /// Won-lost-tied across the whole season. + final int wins; + final int losses; + final int ties; + + /// Where this season's EPA placed the team worldwide, from + /// `epa.ranks.total`, with the number of teams it was ranked against. + final int? epaRank; + final int? epaRankTeamCount; + + final StatboticsEpa epa; + + /// Round-trips through [StatboticsTeamYear.fromJson] for the on-device + /// last-good cache (#512). + Map toJson() { + final epaJson = epa.toJson(); + epaJson['ranks'] = { + 'total': { + 'rank': epaRank, + 'team_count': epaRankTeamCount, + }, + }; + return { + 'team': team, + 'year': year, + 'name': name, + 'record': { + 'wins': wins, + 'losses': losses, + 'ties': ties, + }, + 'epa': epaJson, + }; + } + + String get record => '$wins-$losses${ties > 0 ? '-$ties' : ''}'; +} diff --git a/pubspec.yaml b/pubspec.yaml index 5247b49..7f20177 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: statbotics_client description: A typed Dart client for the Statbotics API v3 (FRC EPA statistics, events, matches). Pure Dart, no Flutter dependency. -version: 0.3.1 +version: 0.4.0 repository: https://github.com/Project516/statbotics_client topics: diff --git a/test/fixtures/event.json b/test/fixtures/event.json new file mode 100644 index 0000000..47226a6 --- /dev/null +++ b/test/fixtures/event.json @@ -0,0 +1,56 @@ +{ + "key": "2025cabe", + "year": 2025, + "name": "East Bay Regional", + "time": 1743652800, + "country": "USA", + "state": "CA", + "district": null, + "start_date": "2025-04-03", + "end_date": "2025-04-06", + "type": "regional", + "week": 6, + "video": "https://www.twitch.tv/firstinspires1", + "status": "Completed", + "status_str": "Completed", + "num_teams": 55, + "current_match": 98, + "qual_matches": 83, + "epa": { + "max": 91.91, + "top_8": 55.82, + "top_24": 31.3, + "mean": 33.56, + "sd": 19.09 + }, + "metrics": { + "win_prob": { + "count": 98, + "conf": 0.7532, + "acc": 0.8367, + "mse": 0.123 + }, + "score_pred": { + "count": 196, + "rmse": 26.2204, + "error": -7.2394 + }, + "rp_pred": { + "count": 166, + "auto_rp": { + "error": -0.0824, + "acc": 0.7289 + }, + "coral_rp": { + "error": -0.0862, + "acc": 0.7651 + }, + "barge_rp": [ + { + "error": -0.0021, + "acc": 0.753 + } + ] + } + } +} diff --git a/test/fixtures/events_by_year.json b/test/fixtures/events_by_year.json new file mode 100644 index 0000000..add18f8 --- /dev/null +++ b/test/fixtures/events_by_year.json @@ -0,0 +1,114 @@ +[ + { + "key": "2025alhu", + "year": 2025, + "name": "Rocket City Regional", + "time": 1741752000, + "country": "USA", + "state": "AL", + "district": null, + "start_date": "2025-03-12", + "end_date": "2025-03-15", + "type": "regional", + "week": 3, + "video": "https://www.twitch.tv/firstinspires5", + "status": "Completed", + "status_str": "Completed", + "num_teams": 44, + "current_match": 96, + "qual_matches": 81, + "epa": { + "max": 65.62, + "top_8": 35.42, + "top_24": 18.9, + "mean": 24.72, + "sd": 14.48 + }, + "metrics": { + "win_prob": { + "count": 96, + "conf": 0.6675, + "acc": 0.7604, + "mse": 0.1556 + }, + "score_pred": { + "count": 192, + "rmse": 22.7338, + "error": -2.1215 + }, + "rp_pred": { + "count": 162, + "auto_rp": { + "error": -0.0681, + "acc": 0.6605 + }, + "coral_rp": { + "error": -0.0109, + "acc": 0.9259 + }, + "barge_rp": [ + { + "error": -0.0472, + "acc": 0.6296 + } + ] + } + } + }, + { + "key": "2025arc", + "year": 2025, + "name": "Archimedes Division", + "time": 1744776000, + "country": "USA", + "state": "TX", + "district": null, + "start_date": "2025-04-16", + "end_date": "2025-04-19", + "type": "champs_div", + "week": 8, + "video": "https://www.twitch.tv/firstinspires_archimedes", + "status": "Completed", + "status_str": "Completed", + "num_teams": 75, + "current_match": 140, + "qual_matches": 125, + "epa": { + "max": 101.05, + "top_8": 84.65, + "top_24": 71.8, + "mean": 59.92, + "sd": 19.85 + }, + "metrics": { + "win_prob": { + "count": 140, + "conf": 0.7556, + "acc": 0.8, + "mse": 0.1377 + }, + "score_pred": { + "count": 280, + "rmse": 23.7671, + "error": 1.1597 + }, + "rp_pred": { + "count": 250, + "auto_rp": { + "error": -0.0274, + "acc": 0.956 + }, + "coral_rp": { + "error": -0.0386, + "acc": 0.78 + }, + "barge_rp": [ + { + "error": 0.0081, + "acc": 0.788 + } + ] + } + } + } +] diff --git a/test/fixtures/matches_by_event.json b/test/fixtures/matches_by_event.json new file mode 100644 index 0000000..12b1486 --- /dev/null +++ b/test/fixtures/matches_by_event.json @@ -0,0 +1,690 @@ +[ + { + "key": "2025cabe_f1m1", + "year": 2025, + "event": "2025cabe", + "week": 6, + "elim": true, + "comp_level": "f", + "set_number": 1, + "match_number": 1, + "match_name": "Finals Match 1", + "time": 1743980040, + "predicted_time": 1743981273, + "status": "Completed", + "video": "rC6xTPwthSg", + "alliances": { + "red": { + "team_keys": [ + 254, + 2204, + 4270 + ], + "surrogate_team_keys": [], + "dq_team_keys": [] + }, + "blue": { + "team_keys": [ + 8033, + 972, + 8793 + ], + "surrogate_team_keys": [], + "dq_team_keys": [] + } + }, + "pred": { + "winner": "red", + "red_win_prob": 0.8297, + "red_score": 222.36, + "blue_score": 179.73, + "red_auto_rp": 0.9877, + "blue_auto_rp": 0.9862, + "red_coral_rp": 0.8694, + "blue_coral_rp": 0.5635, + "red_barge_rp": 0.969, + "blue_barge_rp": 0.9048, + "red_rp_1": 0.9877, + "blue_rp_1": 0.9862, + "red_rp_2": 0.8694, + "blue_rp_2": 0.5635, + "red_rp_3": 0.969, + "blue_rp_3": 0.9048 + }, + "result": { + "winner": "red", + "red_score": 239, + "blue_score": 220, + "red_no_foul": 239, + "blue_no_foul": 220, + "red_auto_points": 44, + "blue_auto_points": 47, + "red_teleop_points": 179, + "blue_teleop_points": 153, + "red_endgame_points": 16, + "blue_endgame_points": 20, + "red_auto_rp": false, + "blue_auto_rp": false, + "red_coral_rp": false, + "blue_coral_rp": false, + "red_barge_rp": false, + "blue_barge_rp": false, + "red_tiebreaker_points": 0, + "blue_tiebreaker_points": 0, + "red_auto_coral_points": 35.0, + "blue_auto_coral_points": 38.0, + "red_teleop_coral_points": 135.0, + "blue_teleop_coral_points": 141.0, + "red_coral_l1": 8.0, + "blue_coral_l1": 16.0, + "red_coral_l2": 12.0, + "blue_coral_l2": 12.0, + "red_coral_l3": 12.0, + "blue_coral_l3": 10.0, + "red_coral_l4": 12.0, + "blue_coral_l4": 12.0, + "red_processor_algae": 0.0, + "blue_processor_algae": 0.0, + "red_processor_algae_points": 0.0, + "blue_processor_algae_points": 0.0, + "red_net_algae_points": 44.0, + "blue_net_algae_points": 12.0, + "red_barge_points": 16.0, + "blue_barge_points": 20.0, + "red_rp_1": false, + "blue_rp_1": false, + "red_rp_2": false, + "blue_rp_2": false, + "red_rp_3": false, + "blue_rp_3": false + }, + "pre_epas": { + "2204": { + "auto_epa": 11.54, + "comp_0_epa": 8.52, + "comp_1_epa": 23.91, + "comp_2_epa": 0.37, + "comp_3_epa": 1.15, + "comp_4_epa": 1.48, + "comp_5_epa": 3.97, + "comp_6_epa": -0.03, + "comp_7_epa": -0.08, + "comp_8_epa": 1.47, + "comp_9_epa": 1.68, + "endgame_epa": 1.68, + "epa": 38.53, + "rp_1_epa": 0.5526, + "rp_2_epa": -0.1412, + "rp_3_epa": 0.0725, + "teleop_epa": 25.3, + "tiebreaker_epa": 0.02 + }, + "254": { + "auto_epa": 21.03, + "comp_0_epa": 17.55, + "comp_1_epa": 49.73, + "comp_2_epa": 2.03, + "comp_3_epa": 3.83, + "comp_4_epa": 4.63, + "comp_5_epa": 5.64, + "comp_6_epa": 0.32, + "comp_7_epa": 0.96, + "comp_8_epa": 7.93, + "comp_9_epa": 10.68, + "endgame_epa": 10.68, + "epa": 90.33, + "rp_1_epa": 0.582, + "rp_2_epa": 0.6194, + "rp_3_epa": 0.6968, + "teleop_epa": 58.62, + "tiebreaker_epa": 0.17 + }, + "4270": { + "auto_epa": 17.69, + "comp_0_epa": 14.68, + "comp_1_epa": 43.87, + "comp_2_epa": 1.88, + "comp_3_epa": 3.45, + "comp_4_epa": 4.41, + "comp_5_epa": 4.52, + "comp_6_epa": 0.69, + "comp_7_epa": 2.08, + "comp_8_epa": 4.32, + "comp_9_epa": 10.73, + "endgame_epa": 10.73, + "epa": 78.7, + "rp_1_epa": 0.4628, + "rp_2_epa": 0.4957, + "rp_3_epa": 0.5914, + "teleop_epa": 50.28, + "tiebreaker_epa": 0.13 + }, + "8033": { + "auto_epa": 20.85, + "comp_0_epa": 17.52, + "comp_1_epa": 39.43, + "comp_2_epa": 0.69, + "comp_3_epa": 2.77, + "comp_4_epa": 3.69, + "comp_5_epa": 5.5, + "comp_6_epa": 0.71, + "comp_7_epa": 2.14, + "comp_8_epa": 4.96, + "comp_9_epa": 8.07, + "endgame_epa": 8.07, + "epa": 75.46, + "rp_1_epa": 0.6001, + "rp_2_epa": 0.4814, + "rp_3_epa": 0.539, + "teleop_epa": 46.54, + "tiebreaker_epa": 0.24 + }, + "8793": { + "auto_epa": 11.34, + "comp_0_epa": 8.34, + "comp_1_epa": 20.88, + "comp_2_epa": 8.99, + "comp_3_epa": 0.06, + "comp_4_epa": 0.53, + "comp_5_epa": 1.26, + "comp_6_epa": -0.1, + "comp_7_epa": -0.31, + "comp_8_epa": -0.73, + "comp_9_epa": 4.26, + "endgame_epa": 4.26, + "epa": 35.44, + "rp_1_epa": 0.5157, + "rp_2_epa": -0.0507, + "rp_3_epa": -0.0054, + "teleop_epa": 19.84, + "tiebreaker_epa": -0.03 + }, + "972": { + "auto_epa": 10.86, + "comp_0_epa": 7.88, + "comp_1_epa": 33.69, + "comp_2_epa": 1.22, + "comp_3_epa": 2.19, + "comp_4_epa": 2.82, + "comp_5_epa": 3.8, + "comp_6_epa": -0.31, + "comp_7_epa": -0.93, + "comp_8_epa": 3.11, + "comp_9_epa": 9.39, + "endgame_epa": 9.39, + "epa": 56.13, + "rp_1_epa": 0.4511, + "rp_2_epa": 0.1332, + "rp_3_epa": 0.5294, + "teleop_epa": 35.88, + "tiebreaker_epa": -0.03 + } + }, + "epas": { + "2204": { + "auto_epa": 11.4, + "comp_0_epa": 8.39, + "comp_1_epa": 24.3, + "comp_2_epa": 0.46, + "comp_3_epa": 1.23, + "comp_4_epa": 1.52, + "comp_5_epa": 3.93, + "comp_6_epa": -0.05, + "comp_7_epa": -0.15, + "comp_8_epa": 2.13, + "comp_9_epa": 1.52, + "endgame_epa": 1.52, + "epa": 39.2, + "rp_1_epa": 0.5526, + "rp_2_epa": -0.1412, + "rp_3_epa": 0.0725, + "teleop_epa": 26.28, + "tiebreaker_epa": 0.01 + }, + "254": { + "auto_epa": 20.9, + "comp_0_epa": 17.42, + "comp_1_epa": 50.12, + "comp_2_epa": 2.12, + "comp_3_epa": 3.91, + "comp_4_epa": 4.66, + "comp_5_epa": 5.59, + "comp_6_epa": 0.3, + "comp_7_epa": 0.89, + "comp_8_epa": 8.58, + "comp_9_epa": 10.52, + "endgame_epa": 10.52, + "epa": 91.01, + "rp_1_epa": 0.582, + "rp_2_epa": 0.6194, + "rp_3_epa": 0.6968, + "teleop_epa": 59.59, + "tiebreaker_epa": 0.16 + }, + "4270": { + "auto_epa": 17.55, + "comp_0_epa": 14.55, + "comp_1_epa": 44.26, + "comp_2_epa": 1.96, + "comp_3_epa": 3.53, + "comp_4_epa": 4.44, + "comp_5_epa": 4.47, + "comp_6_epa": 0.67, + "comp_7_epa": 2.01, + "comp_8_epa": 4.98, + "comp_9_epa": 10.58, + "endgame_epa": 10.58, + "epa": 79.38, + "rp_1_epa": 0.4628, + "rp_2_epa": 0.4957, + "rp_3_epa": 0.5914, + "teleop_epa": 51.25, + "tiebreaker_epa": 0.12 + }, + "8033": { + "auto_epa": 20.94, + "comp_0_epa": 17.61, + "comp_1_epa": 40.48, + "comp_2_epa": 0.8, + "comp_3_epa": 2.93, + "comp_4_epa": 3.76, + "comp_5_epa": 5.54, + "comp_6_epa": 0.71, + "comp_7_epa": 2.12, + "comp_8_epa": 5.0, + "comp_9_epa": 8.03, + "endgame_epa": 8.03, + "epa": 76.57, + "rp_1_epa": 0.6001, + "rp_2_epa": 0.4814, + "rp_3_epa": 0.539, + "teleop_epa": 47.6, + "tiebreaker_epa": 0.23 + }, + "8793": { + "auto_epa": 11.43, + "comp_0_epa": 8.44, + "comp_1_epa": 21.92, + "comp_2_epa": 9.1, + "comp_3_epa": 0.21, + "comp_4_epa": 0.6, + "comp_5_epa": 1.3, + "comp_6_epa": -0.11, + "comp_7_epa": -0.33, + "comp_8_epa": -0.69, + "comp_9_epa": 4.22, + "endgame_epa": 4.22, + "epa": 36.55, + "rp_1_epa": 0.5157, + "rp_2_epa": -0.0507, + "rp_3_epa": -0.0054, + "teleop_epa": 20.9, + "tiebreaker_epa": -0.03 + }, + "972": { + "auto_epa": 10.95, + "comp_0_epa": 7.97, + "comp_1_epa": 34.74, + "comp_2_epa": 1.33, + "comp_3_epa": 2.35, + "comp_4_epa": 2.88, + "comp_5_epa": 3.83, + "comp_6_epa": -0.32, + "comp_7_epa": -0.95, + "comp_8_epa": 3.15, + "comp_9_epa": 9.35, + "endgame_epa": 9.35, + "epa": 57.24, + "rp_1_epa": 0.4511, + "rp_2_epa": 0.1332, + "rp_3_epa": 0.5294, + "teleop_epa": 36.94, + "tiebreaker_epa": -0.04 + } + } + }, + { + "key": "2025cabe_f1m2", + "year": 2025, + "event": "2025cabe", + "week": 6, + "elim": true, + "comp_level": "f", + "set_number": 1, + "match_number": 2, + "match_name": "Finals Match 2", + "time": 1743981300, + "predicted_time": 1743983671, + "status": "Completed", + "video": "LbKHvJjXNVw", + "alliances": { + "red": { + "team_keys": [ + 254, + 2204, + 4270 + ], + "surrogate_team_keys": [], + "dq_team_keys": [] + }, + "blue": { + "team_keys": [ + 8033, + 972, + 8793 + ], + "surrogate_team_keys": [], + "dq_team_keys": [] + } + }, + "pred": { + "winner": "red", + "red_win_prob": 0.8224, + "red_score": 224.23, + "blue_score": 182.97, + "red_auto_rp": 0.9877, + "blue_auto_rp": 0.9862, + "red_coral_rp": 0.8694, + "blue_coral_rp": 0.5635, + "red_barge_rp": 0.969, + "blue_barge_rp": 0.9048, + "red_rp_1": 0.9877, + "blue_rp_1": 0.9862, + "red_rp_2": 0.8694, + "blue_rp_2": 0.5635, + "red_rp_3": 0.969, + "blue_rp_3": 0.9048 + }, + "result": { + "winner": "red", + "red_score": 263, + "blue_score": 204, + "red_no_foul": 251, + "blue_no_foul": 204, + "red_auto_points": 58, + "blue_auto_points": 50, + "red_teleop_points": 167, + "blue_teleop_points": 134, + "red_endgame_points": 26, + "blue_endgame_points": 20, + "red_auto_rp": false, + "blue_auto_rp": false, + "red_coral_rp": false, + "blue_coral_rp": false, + "red_barge_rp": false, + "blue_barge_rp": false, + "red_tiebreaker_points": 0, + "blue_tiebreaker_points": 0, + "red_auto_coral_points": 49.0, + "blue_auto_coral_points": 41.0, + "red_teleop_coral_points": 131.0, + "blue_teleop_coral_points": 118.0, + "red_coral_l1": 11.0, + "blue_coral_l1": 13.0, + "red_coral_l2": 12.0, + "blue_coral_l2": 6.0, + "red_coral_l3": 12.0, + "blue_coral_l3": 12.0, + "red_coral_l4": 12.0, + "blue_coral_l4": 11.0, + "red_processor_algae": 0.0, + "blue_processor_algae": 0.0, + "red_processor_algae_points": 0.0, + "blue_processor_algae_points": 0.0, + "red_net_algae_points": 36.0, + "blue_net_algae_points": 16.0, + "red_barge_points": 26.0, + "blue_barge_points": 20.0, + "red_rp_1": false, + "blue_rp_1": false, + "red_rp_2": false, + "blue_rp_2": false, + "red_rp_3": false, + "blue_rp_3": false + }, + "pre_epas": { + "2204": { + "auto_epa": 11.4, + "comp_0_epa": 8.39, + "comp_1_epa": 24.3, + "comp_2_epa": 0.46, + "comp_3_epa": 1.23, + "comp_4_epa": 1.52, + "comp_5_epa": 3.93, + "comp_6_epa": -0.05, + "comp_7_epa": -0.15, + "comp_8_epa": 2.13, + "comp_9_epa": 1.52, + "endgame_epa": 1.52, + "epa": 39.2, + "rp_1_epa": 0.5526, + "rp_2_epa": -0.1412, + "rp_3_epa": 0.0725, + "teleop_epa": 26.28, + "tiebreaker_epa": 0.01 + }, + "254": { + "auto_epa": 20.9, + "comp_0_epa": 17.42, + "comp_1_epa": 50.12, + "comp_2_epa": 2.12, + "comp_3_epa": 3.91, + "comp_4_epa": 4.66, + "comp_5_epa": 5.59, + "comp_6_epa": 0.3, + "comp_7_epa": 0.89, + "comp_8_epa": 8.58, + "comp_9_epa": 10.52, + "endgame_epa": 10.52, + "epa": 91.01, + "rp_1_epa": 0.582, + "rp_2_epa": 0.6194, + "rp_3_epa": 0.6968, + "teleop_epa": 59.59, + "tiebreaker_epa": 0.16 + }, + "4270": { + "auto_epa": 17.55, + "comp_0_epa": 14.55, + "comp_1_epa": 44.26, + "comp_2_epa": 1.96, + "comp_3_epa": 3.53, + "comp_4_epa": 4.44, + "comp_5_epa": 4.47, + "comp_6_epa": 0.67, + "comp_7_epa": 2.01, + "comp_8_epa": 4.98, + "comp_9_epa": 10.58, + "endgame_epa": 10.58, + "epa": 79.38, + "rp_1_epa": 0.4628, + "rp_2_epa": 0.4957, + "rp_3_epa": 0.5914, + "teleop_epa": 51.25, + "tiebreaker_epa": 0.12 + }, + "8033": { + "auto_epa": 20.94, + "comp_0_epa": 17.61, + "comp_1_epa": 40.48, + "comp_2_epa": 0.8, + "comp_3_epa": 2.93, + "comp_4_epa": 3.76, + "comp_5_epa": 5.54, + "comp_6_epa": 0.71, + "comp_7_epa": 2.12, + "comp_8_epa": 5.0, + "comp_9_epa": 8.03, + "endgame_epa": 8.03, + "epa": 76.57, + "rp_1_epa": 0.6001, + "rp_2_epa": 0.4814, + "rp_3_epa": 0.539, + "teleop_epa": 47.6, + "tiebreaker_epa": 0.23 + }, + "8793": { + "auto_epa": 11.43, + "comp_0_epa": 8.44, + "comp_1_epa": 21.92, + "comp_2_epa": 9.1, + "comp_3_epa": 0.21, + "comp_4_epa": 0.6, + "comp_5_epa": 1.3, + "comp_6_epa": -0.11, + "comp_7_epa": -0.33, + "comp_8_epa": -0.69, + "comp_9_epa": 4.22, + "endgame_epa": 4.22, + "epa": 36.55, + "rp_1_epa": 0.5157, + "rp_2_epa": -0.0507, + "rp_3_epa": -0.0054, + "teleop_epa": 20.9, + "tiebreaker_epa": -0.03 + }, + "972": { + "auto_epa": 10.95, + "comp_0_epa": 7.97, + "comp_1_epa": 34.74, + "comp_2_epa": 1.33, + "comp_3_epa": 2.35, + "comp_4_epa": 2.88, + "comp_5_epa": 3.83, + "comp_6_epa": -0.32, + "comp_7_epa": -0.95, + "comp_8_epa": 3.15, + "comp_9_epa": 9.35, + "endgame_epa": 9.35, + "epa": 57.24, + "rp_1_epa": 0.4511, + "rp_2_epa": 0.1332, + "rp_3_epa": 0.5294, + "teleop_epa": 36.94, + "tiebreaker_epa": -0.04 + } + }, + "epas": { + "2204": { + "auto_epa": 11.58, + "comp_0_epa": 8.58, + "comp_1_epa": 24.57, + "comp_2_epa": 0.6, + "comp_3_epa": 1.3, + "comp_4_epa": 1.55, + "comp_5_epa": 3.88, + "comp_6_epa": -0.07, + "comp_7_epa": -0.21, + "comp_8_epa": 2.56, + "comp_9_epa": 1.6, + "endgame_epa": 1.6, + "epa": 40.11, + "rp_1_epa": 0.5526, + "rp_2_epa": -0.1412, + "rp_3_epa": 0.0725, + "teleop_epa": 26.93, + "tiebreaker_epa": 0.0 + }, + "254": { + "auto_epa": 21.08, + "comp_0_epa": 17.62, + "comp_1_epa": 50.39, + "comp_2_epa": 2.26, + "comp_3_epa": 3.99, + "comp_4_epa": 4.69, + "comp_5_epa": 5.55, + "comp_6_epa": 0.28, + "comp_7_epa": 0.83, + "comp_8_epa": 9.01, + "comp_9_epa": 10.59, + "endgame_epa": 10.59, + "epa": 91.91, + "rp_1_epa": 0.582, + "rp_2_epa": 0.6194, + "rp_3_epa": 0.6968, + "teleop_epa": 60.24, + "tiebreaker_epa": 0.15 + }, + "4270": { + "auto_epa": 17.73, + "comp_0_epa": 14.74, + "comp_1_epa": 44.54, + "comp_2_epa": 2.11, + "comp_3_epa": 3.6, + "comp_4_epa": 4.47, + "comp_5_epa": 4.43, + "comp_6_epa": 0.65, + "comp_7_epa": 1.95, + "comp_8_epa": 5.41, + "comp_9_epa": 10.65, + "endgame_epa": 10.65, + "epa": 80.28, + "rp_1_epa": 0.4628, + "rp_2_epa": 0.4957, + "rp_3_epa": 0.5914, + "teleop_epa": 51.9, + "tiebreaker_epa": 0.12 + }, + "8033": { + "auto_epa": 21.09, + "comp_0_epa": 17.77, + "comp_1_epa": 40.94, + "comp_2_epa": 0.84, + "comp_3_epa": 2.94, + "comp_4_epa": 3.86, + "comp_5_epa": 5.54, + "comp_6_epa": 0.7, + "comp_7_epa": 2.11, + "comp_8_epa": 5.13, + "comp_9_epa": 8.0, + "endgame_epa": 8.0, + "epa": 77.26, + "rp_1_epa": 0.6001, + "rp_2_epa": 0.4814, + "rp_3_epa": 0.539, + "teleop_epa": 48.17, + "tiebreaker_epa": 0.23 + }, + "8793": { + "auto_epa": 11.58, + "comp_0_epa": 8.59, + "comp_1_epa": 22.39, + "comp_2_epa": 9.14, + "comp_3_epa": 0.22, + "comp_4_epa": 0.7, + "comp_5_epa": 1.3, + "comp_6_epa": -0.12, + "comp_7_epa": -0.35, + "comp_8_epa": -0.56, + "comp_9_epa": 4.18, + "endgame_epa": 4.18, + "epa": 37.23, + "rp_1_epa": 0.5157, + "rp_2_epa": -0.0507, + "rp_3_epa": -0.0054, + "teleop_epa": 21.47, + "tiebreaker_epa": -0.04 + }, + "972": { + "auto_epa": 11.1, + "comp_0_epa": 8.13, + "comp_1_epa": 35.2, + "comp_2_epa": 1.37, + "comp_3_epa": 2.36, + "comp_4_epa": 2.99, + "comp_5_epa": 3.84, + "comp_6_epa": -0.32, + "comp_7_epa": -0.96, + "comp_8_epa": 3.28, + "comp_9_epa": 9.32, + "endgame_epa": 9.32, + "epa": 57.93, + "rp_1_epa": 0.4511, + "rp_2_epa": 0.1332, + "rp_3_epa": 0.5294, + "teleop_epa": 37.51, + "tiebreaker_epa": -0.04 + } + } + } +] diff --git a/test/fixtures/team_events_by_event.json b/test/fixtures/team_events_by_event.json new file mode 100644 index 0000000..883b1b1 --- /dev/null +++ b/test/fixtures/team_events_by_event.json @@ -0,0 +1,162 @@ +[ + { + "team": 199, + "year": 2025, + "event": "2025cabe", + "time": 1743652800, + "team_name": "Deep Blue", + "event_name": "East Bay Regional", + "country": "USA", + "state": "CA", + "district": null, + "type": "regional", + "week": 6, + "status": "Completed", + "first_event": false, + "epa": { + "total_points": 25.32, + "unitless": 1484.0, + "norm": 1494.0, + "breakdown": { + "total_points": 25.32, + "auto_points": 6.1, + "teleop_points": 18.23, + "endgame_points": 1.0, + "auto_rp": 0.1599, + "coral_rp": -0.0579, + "barge_rp": 0.0766, + "tiebreaker_points": 0.0, + "auto_coral_points": 3.35, + "teleop_coral_points": 15.14, + "coral_l1": 1.1, + "coral_l2": 0.84, + "coral_l3": 0.6, + "coral_l4": 2.08, + "processor_algae": 0.07, + "processor_algae_points": 0.21, + "net_algae_points": 2.88, + "barge_points": 1.0, + "rp_1": 0.1599, + "rp_2": -0.0579, + "rp_3": 0.0766, + "total_coral_points": 18.490000000000002, + "total_algae_points": 3.09 + }, + "stats": { + "start": 16.95, + "pre_elim": 26.84, + "mean": 23.11, + "max": 26.84 + } + }, + "record": { + "qual": { + "wins": 7, + "losses": 2, + "ties": 0, + "count": 9, + "winrate": 0.7778, + "rps": 37, + "rps_per_match": 4.1111, + "rank": 8, + "num_teams": 55 + }, + "elim": { + "wins": 0, + "losses": 1, + "ties": 0, + "count": 1, + "winrate": 0.0, + "alliance": null, + "is_captain": null + }, + "total": { + "wins": 7, + "losses": 3, + "ties": 0, + "count": 10, + "winrate": 0.7 + } + } + }, + { + "team": 253, + "year": 2025, + "event": "2025cabe", + "time": 1743652800, + "team_name": "Boba Bots", + "event_name": "East Bay Regional", + "country": "USA", + "state": "CA", + "district": null, + "type": "regional", + "week": 6, + "status": "Completed", + "first_event": false, + "epa": { + "total_points": 23.53, + "unitless": 1472.0, + "norm": 1485.0, + "breakdown": { + "total_points": 23.53, + "auto_points": 3.01, + "teleop_points": 17.9, + "endgame_points": 2.62, + "auto_rp": 0.1382, + "coral_rp": -0.022, + "barge_rp": 0.0387, + "tiebreaker_points": 0.12, + "auto_coral_points": 0.27, + "teleop_coral_points": 15.32, + "coral_l1": 0.35, + "coral_l2": 1.14, + "coral_l3": 1.2, + "coral_l4": 1.32, + "processor_algae": 0.26, + "processor_algae_points": 0.78, + "net_algae_points": 1.81, + "barge_points": 2.62, + "rp_1": 0.1382, + "rp_2": -0.022, + "rp_3": 0.0387, + "total_coral_points": 15.59, + "total_algae_points": 2.59 + }, + "stats": { + "start": 11.45, + "pre_elim": 21.95, + "mean": 16.26, + "max": 21.95 + } + }, + "record": { + "qual": { + "wins": 5, + "losses": 4, + "ties": 0, + "count": 9, + "winrate": 0.5556, + "rps": 28, + "rps_per_match": 3.1111, + "rank": 25, + "num_teams": 55 + }, + "elim": { + "wins": 0, + "losses": 0, + "ties": 0, + "count": 0, + "winrate": 0, + "alliance": null, + "is_captain": null + }, + "total": { + "wins": 5, + "losses": 4, + "ties": 0, + "count": 9, + "winrate": 0.5556 + } + } + } +] diff --git a/test/fixtures/team_events_by_team.json b/test/fixtures/team_events_by_team.json new file mode 100644 index 0000000..f62b2fb --- /dev/null +++ b/test/fixtures/team_events_by_team.json @@ -0,0 +1,162 @@ +[ + { + "team": 254, + "year": 2025, + "event": "2025cabe", + "time": 1743652800, + "team_name": "The Cheesy Poofs", + "event_name": "East Bay Regional", + "country": "USA", + "state": "CA", + "district": null, + "type": "regional", + "week": 6, + "status": "Completed", + "first_event": false, + "epa": { + "total_points": 91.91, + "unitless": 1936.0, + "norm": 1835.0, + "breakdown": { + "total_points": 91.91, + "auto_points": 21.08, + "teleop_points": 60.24, + "endgame_points": 10.59, + "auto_rp": 0.582, + "coral_rp": 0.6194, + "barge_rp": 0.6968, + "tiebreaker_points": 0.15, + "auto_coral_points": 17.62, + "teleop_coral_points": 50.39, + "coral_l1": 2.26, + "coral_l2": 3.99, + "coral_l3": 4.69, + "coral_l4": 5.55, + "processor_algae": 0.28, + "processor_algae_points": 0.83, + "net_algae_points": 9.01, + "barge_points": 10.59, + "rp_1": 0.582, + "rp_2": 0.6194, + "rp_3": 0.6968, + "total_coral_points": 68.01, + "total_algae_points": 9.84 + }, + "stats": { + "start": 82.52, + "pre_elim": 89.48, + "mean": 87.45, + "max": 91.01 + } + }, + "record": { + "qual": { + "wins": 9, + "losses": 0, + "ties": 0, + "count": 9, + "winrate": 1.0, + "rps": 53, + "rps_per_match": 5.8889, + "rank": 1, + "num_teams": 55 + }, + "elim": { + "wins": 5, + "losses": 0, + "ties": 0, + "count": 5, + "winrate": 1.0, + "alliance": null, + "is_captain": null + }, + "total": { + "wins": 14, + "losses": 0, + "ties": 0, + "count": 14, + "winrate": 1.0 + } + } + }, + { + "team": 254, + "year": 2025, + "event": "2025cada", + "time": 1741924800, + "team_name": "The Cheesy Poofs", + "event_name": "Sacramento Regional", + "country": "USA", + "state": "CA", + "district": null, + "type": "regional", + "week": 3, + "status": "Completed", + "first_event": true, + "epa": { + "total_points": 82.52, + "unitless": 1872.0, + "norm": 1776.0, + "breakdown": { + "total_points": 82.52, + "auto_points": 19.69, + "teleop_points": 51.86, + "endgame_points": 10.97, + "auto_rp": 0.5173, + "coral_rp": 0.349, + "barge_rp": 0.5737, + "tiebreaker_points": 0.15, + "auto_coral_points": 15.91, + "teleop_coral_points": 44.46, + "coral_l1": 0.83, + "coral_l2": 2.3, + "coral_l3": 4.05, + "coral_l4": 6.21, + "processor_algae": 0.2, + "processor_algae_points": 0.61, + "net_algae_points": 6.79, + "barge_points": 10.97, + "rp_1": 0.5173, + "rp_2": 0.349, + "rp_3": 0.5737, + "total_coral_points": 60.370000000000005, + "total_algae_points": 7.4 + }, + "stats": { + "start": 55.99, + "pre_elim": 79.59, + "mean": 72.31, + "max": 82.63 + } + }, + "record": { + "qual": { + "wins": 10, + "losses": 2, + "ties": 0, + "count": 12, + "winrate": 0.8333, + "rps": 58, + "rps_per_match": 4.8333, + "rank": 2, + "num_teams": 34 + }, + "elim": { + "wins": 5, + "losses": 0, + "ties": 0, + "count": 5, + "winrate": 1.0, + "alliance": null, + "is_captain": null + }, + "total": { + "wins": 15, + "losses": 2, + "ties": 0, + "count": 17, + "winrate": 0.8824 + } + } + } +] diff --git a/test/fixtures/team_years_by_team.json b/test/fixtures/team_years_by_team.json new file mode 100644 index 0000000..4df166c --- /dev/null +++ b/test/fixtures/team_years_by_team.json @@ -0,0 +1,104 @@ +[ + { + "team": 254, + "year": 2002, + "name": "The Cheesy Poofs", + "country": "USA", + "state": "CA", + "district": null, + "rookie_year": 1999, + "epa": { + "total_points": 16.6, + "unitless": 1657.0, + "norm": 1691.0, + "breakdown": { + "total_points": 16.6 + }, + "stats": { + "start": 11.96, + "pre_champs": 16.6, + "max": 16.6 + }, + "ranks": { + "total": { + "rank": 30, + "percentile": 0.9532, + "team_count": 641 + }, + "country": { + "rank": 30, + "percentile": 0.9511, + "team_count": 613 + }, + "state": { + "rank": 1, + "percentile": 0.9868, + "team_count": 76 + }, + "district": { + "rank": 30, + "percentile": 0.9532, + "team_count": 641 + } + } + }, + "record": { + "wins": 18, + "losses": 6, + "ties": 1, + "count": 25, + "winrate": 0.74 + } + }, + { + "team": 254, + "year": 2003, + "name": "The Cheesy Poofs", + "country": "USA", + "state": "CA", + "district": null, + "rookie_year": 1999, + "epa": { + "total_points": 40.22, + "unitless": 1692.0, + "norm": 1705.0, + "breakdown": { + "total_points": 40.22 + }, + "stats": { + "start": 30.63, + "pre_champs": 39.23, + "max": 44.93 + }, + "ranks": { + "total": { + "rank": 31, + "percentile": 0.9605, + "team_count": 785 + }, + "country": { + "rank": 31, + "percentile": 0.9582, + "team_count": 741 + }, + "state": { + "rank": 2, + "percentile": 0.9783, + "team_count": 92 + }, + "district": { + "rank": 31, + "percentile": 0.9605, + "team_count": 785 + } + } + }, + "record": { + "wins": 29, + "losses": 17, + "ties": 0, + "count": 46, + "winrate": 0.6304 + } + } +] diff --git a/test/fixtures/teams.json b/test/fixtures/teams.json new file mode 100644 index 0000000..75f9e4e --- /dev/null +++ b/test/fixtures/teams.json @@ -0,0 +1,48 @@ +[ + { + "team": 1, + "name": "The Juggernauts", + "country": "USA", + "state": "MI", + "district": "fim", + "rookie_year": 1997, + "active": true, + "last_active_year": 2026, + "record": { + "wins": 342, + "losses": 356, + "ties": 21, + "count": 719, + "winrate": 0.4903 + }, + "norm_epa": { + "current": 1432.0, + "recent": 1523.0, + "mean": 1552.0, + "max": 1686.0 + } + }, + { + "team": 4, + "name": "Team 4 ELEMENT", + "country": "USA", + "state": "CA", + "district": "ca", + "rookie_year": 1997, + "active": true, + "last_active_year": 2026, + "record": { + "wins": 243, + "losses": 270, + "ties": 15, + "count": 528, + "winrate": 0.4744 + }, + "norm_epa": { + "current": 1604.0, + "recent": 1564.0, + "mean": 1511.0, + "max": 1604.0 + } + } +] diff --git a/test/statbotics_client_test.dart b/test/statbotics_client_test.dart index 7e2cb66..80b5d7a 100644 --- a/test/statbotics_client_test.dart +++ b/test/statbotics_client_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:test/test.dart'; import 'package:http/http.dart' as http; @@ -98,13 +99,12 @@ void main() { 'event': '2026mrcmp', 'event_name': 'Mid-Atlantic Championship', 'year': 2026, - 'wins': 8, - 'losses': 4, - 'ties': 0, - 'rank': 2, - 'num_teams': 40, + 'record': { + 'qual': {'rank': 2, 'num_teams': 40}, + 'total': {'wins': 8, 'losses': 4, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 42.1, 'sd': 3.0}, + 'total_points': 42.1, }, }, { @@ -112,13 +112,12 @@ void main() { 'event': '2026mrcmp', 'event_name': 'Mid-Atlantic Championship', 'year': 2026, - 'wins': 10, - 'losses': 2, - 'ties': 0, - 'rank': 1, - 'num_teams': 40, + 'record': { + 'qual': {'rank': 1, 'num_teams': 40}, + 'total': {'wins': 10, 'losses': 2, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 48.5, 'sd': 2.8}, + 'total_points': 48.5, }, }, ]), @@ -134,7 +133,7 @@ void main() { // Sorted by rank ascending: rank 1 first. expect(teams[0].team, 1234); expect(teams[0].rank, 1); - expect(teams[0].epa.totalPointsMean, closeTo(48.5, 0.01)); + expect(teams[0].epa.totalPoints, closeTo(48.5, 0.01)); expect(teams[0].record, '10-2'); expect(teams[1].team, 2714); expect(teams[1].rank, 2); @@ -157,9 +156,9 @@ void main() { 'event_name': 'Houston', 'team_name': 'Spectrum', 'year': 2026, - 'wins': 1, - 'losses': 0, - 'ties': 0, + 'record': { + 'total': {'wins': 1, 'losses': 0, 'ties': 0}, + }, 'epa': {}, }); expect(te.teamName, 'Spectrum'); @@ -170,9 +169,9 @@ void main() { 'team': 3847, 'event': '2026txhou', 'year': 2026, - 'wins': 0, - 'losses': 0, - 'ties': 0, + 'record': { + 'total': {'wins': 0, 'losses': 0, 'ties': 0}, + }, 'epa': {}, }); expect(te.teamName, isEmpty); @@ -190,14 +189,15 @@ void main() { 'event_name': 'Houston', 'team_name': 'Spectrum', 'year': 2026, - 'wins': 1, - 'losses': 0, - 'ties': 0, - 'rank': 7, - 'num_teams': 42, + 'record': { + 'qual': {'rank': 7, 'num_teams': 42}, + 'total': {'wins': 1, 'losses': 0, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 41.2, 'sd': 2.9}, - 'auto_points': {'mean': 9.5}, + 'total_points': 41.2, + 'unitless': 1710.0, + 'norm': 1655.0, + 'breakdown': {'auto_points': 9.5}, }, }); final restored = StatboticsTeamEvent.fromJson(original.toJson()); @@ -211,8 +211,8 @@ void main() { expect(restored.ties, 0); expect(restored.rank, 7); expect(restored.numTeams, 42); - expect(restored.epa.totalPointsMean, closeTo(41.2, 0.01)); - expect(restored.epa.autoPointsMean, closeTo(9.5, 0.01)); + expect(restored.epa.totalPoints, closeTo(41.2, 0.01)); + expect(restored.epa.autoPoints, closeTo(9.5, 0.01)); }); test('StatboticsTeamEvent.toJson round-trips an empty team_name', () { @@ -221,9 +221,9 @@ void main() { 'event': '2026x', 'event_name': 'X', 'year': 2026, - 'wins': 0, - 'losses': 0, - 'ties': 0, + 'record': { + 'total': {'wins': 0, 'losses': 0, 'ties': 0}, + }, 'epa': {}, }); final restored = StatboticsTeamEvent.fromJson(original.toJson()); @@ -236,9 +236,9 @@ void main() { 'event': 'test', 'event_name': 'Test', 'year': 2026, - 'wins': 6, - 'losses': 3, - 'ties': 0, + 'record': { + 'total': {'wins': 6, 'losses': 3, 'ties': 0}, + }, 'epa': {}, }); expect(te.record, '6-3'); @@ -250,9 +250,9 @@ void main() { 'event': 'test', 'event_name': 'Test', 'year': 2026, - 'wins': 5, - 'losses': 3, - 'ties': 1, + 'record': { + 'total': {'wins': 5, 'losses': 3, 'ties': 1}, + }, 'epa': {}, }); expect(te.record, '5-3-1'); @@ -264,12 +264,12 @@ void main() { 'event': 'test', 'event_name': 'Test', 'year': 2026, - 'wins': 0, - 'losses': 0, - 'ties': 0, + 'record': { + 'total': {'wins': 0, 'losses': 0, 'ties': 0}, + }, 'epa': {}, }); - expect(te.epa.totalPointsMean, isNull); + expect(te.epa.totalPoints, isNull); }); test('StatboticsMatch parses team_keys from alliances', () { @@ -571,13 +571,12 @@ void main() { 'event_name': 'Cal Games', 'team_name': 'The Cheesy Poofs', 'year': 2024, - 'wins': 9, - 'losses': 1, - 'ties': 0, - 'rank': 1, - 'num_teams': 40, + 'record': { + 'qual': {'rank': 1, 'num_teams': 40}, + 'total': {'wins': 9, 'losses': 1, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 55.0, 'sd': 2.0}, + 'total_points': 55.0, }, }, { @@ -586,13 +585,12 @@ void main() { 'event_name': 'Cal Games', 'team_name': 'The Cheesy Poofs', 'year': 2023, - 'wins': 8, - 'losses': 2, - 'ties': 0, - 'rank': 2, - 'num_teams': 40, + 'record': { + 'qual': {'rank': 2, 'num_teams': 40}, + 'total': {'wins': 8, 'losses': 2, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 50.0, 'sd': 2.5}, + 'total_points': 50.0, }, }, { @@ -601,13 +599,12 @@ void main() { 'event_name': 'Austin', 'team_name': 'The Cheesy Poofs', 'year': 2024, - 'wins': 7, - 'losses': 3, - 'ties': 0, - 'rank': 3, - 'num_teams': 38, + 'record': { + 'qual': {'rank': 3, 'num_teams': 38}, + 'total': {'wins': 7, 'losses': 3, 'ties': 0}, + }, 'epa': { - 'total_points': {'mean': 52.0, 'sd': 2.2}, + 'total_points': 52.0, }, }, ]), @@ -657,4 +654,180 @@ void main() { expect(history, isEmpty); }); }); + + fixtureTests(); +} + +/// Decodes a captured response body from `test/fixtures/`. +/// +/// These are real `api.statbotics.io/v3` bodies, saved verbatim on +/// 2026-09-02. The hand-written maps above are readable but they are also +/// the author's belief about the API, and a model can satisfy every one of +/// them while failing on the real thing: that is how the client came to read +/// `epa.total_points` as a `{mean, sd}` object it has never been. Assert +/// against a captured body, and a shape change fails a test instead of +/// reaching a device. +/// +/// Refresh one with, for example: +/// `curl -s 'https://api.statbotics.io/v3/team_years?team=254&limit=2'` +Object _fixture(String name) { + return jsonDecode(File('test/fixtures/$name.json').readAsStringSync()); +} + +List> _fixtureList(String name) { + return (_fixture(name) as List) + .map((e) => (e as Map).cast()) + .toList(growable: false); +} + +/// Every model, run against a captured live body rather than a hand-written +/// one. +void fixtureTests() { + group('captured live responses', () { + test('StatboticsTeamEvent decodes a real /team_events row', () { + final rows = _fixtureList('team_events_by_event'); + final te = StatboticsTeamEvent.fromJson(rows.first); + + expect(te.team, greaterThan(0)); + expect(te.event, '2025cabe'); + expect(te.eventName, isNotEmpty); + expect(te.teamName, isNotEmpty); + expect(te.year, 2025); + // The regression this file exists to catch: every one of these reads a + // nested or bare field the client previously looked for in the wrong + // place, so each would be null or zero against a live body. + expect(te.epa.totalPoints, isNotNull); + expect(te.epa.autoPoints, isNotNull); + expect(te.epa.teleopPoints, isNotNull); + expect(te.epa.endgamePoints, isNotNull); + expect(te.rank, isNotNull); + expect(te.numTeams, isNotNull); + expect(te.wins + te.losses + te.ties, greaterThan(0)); + }); + + test('a real /team_events row survives the cache round-trip', () { + final original = StatboticsTeamEvent.fromJson( + _fixtureList('team_events_by_event').first, + ); + final restored = StatboticsTeamEvent.fromJson(original.toJson()); + + expect(restored.team, original.team); + expect(restored.teamName, original.teamName); + expect(restored.eventName, original.eventName); + expect(restored.record, original.record); + expect(restored.rank, original.rank); + expect(restored.numTeams, original.numTeams); + expect(restored.epa.totalPoints, original.epa.totalPoints); + expect(restored.epa.autoPoints, original.epa.autoPoints); + expect(restored.epa.teleopPoints, original.epa.teleopPoints); + expect(restored.epa.endgamePoints, original.epa.endgamePoints); + }); + + test('a team-filtered /team_events row decodes the same way', () { + final rows = _fixtureList('team_events_by_team'); + final te = StatboticsTeamEvent.fromJson(rows.first); + + expect(te.team, 254); + expect(te.epa.totalPoints, isNotNull); + expect(te.eventName, isNotEmpty); + }); + + test('StatboticsTeamYear decodes a real /team_years row', () { + final rows = _fixtureList('team_years_by_team'); + final ty = StatboticsTeamYear.fromJson(rows.first); + + expect(ty.team, 254); + expect(ty.year, greaterThan(1991)); + expect(ty.name, isNotEmpty); + // /team_years reports the record flat, unlike /team_events. + expect(ty.wins + ty.losses + ty.ties, greaterThan(0)); + expect(ty.epa.totalPoints, isNotNull); + expect(ty.epa.unitless, isNotNull); + expect(ty.epa.norm, isNotNull); + expect(ty.epaRank, isNotNull); + expect(ty.epaRankTeamCount, isNotNull); + }); + + test('a real /team_years row survives the cache round-trip', () { + final original = StatboticsTeamYear.fromJson( + _fixtureList('team_years_by_team').first, + ); + final restored = StatboticsTeamYear.fromJson(original.toJson()); + + expect(restored.team, original.team); + expect(restored.year, original.year); + expect(restored.name, original.name); + expect(restored.record, original.record); + expect(restored.epaRank, original.epaRank); + expect(restored.epaRankTeamCount, original.epaRankTeamCount); + expect(restored.epa.totalPoints, original.epa.totalPoints); + expect(restored.epa.unitless, original.epa.unitless); + expect(restored.epa.norm, original.epa.norm); + }); + + test('an early season has no per-phase breakdown and reads as null', () { + // 2002 predates the auto/teleop/endgame split, so breakdown carries + // total_points alone. Nullable fields, not zeros. + final early = _fixtureList('team_years_by_team') + .map(StatboticsTeamYear.fromJson) + .firstWhere((ty) => ty.year < 2010); + + expect(early.epa.totalPoints, isNotNull); + expect(early.epa.autoPoints, isNull); + expect(early.epa.teleopPoints, isNull); + expect(early.epa.endgamePoints, isNull); + }); + + test('StatboticsMatch decodes a real /matches row', () { + final rows = _fixtureList('matches_by_event'); + final match = StatboticsMatch.fromJson(rows.first); + + expect(match.key, startsWith('2025cabe_')); + expect(match.event, '2025cabe'); + expect(match.compLevel, isNotEmpty); + expect(match.redTeams, hasLength(3)); + expect(match.blueTeams, hasLength(3)); + expect(match.allTeams.every((t) => t > 0), isTrue); + }); + + test('StatboticsEvent decodes a real /events row', () { + final rows = _fixtureList('events_by_year'); + final event = StatboticsEvent.fromJson(rows.first); + + expect(event.key, isNotEmpty); + expect(event.name, isNotEmpty); + expect(event.year, 2025); + expect(event.week, isNotNull); + expect(event.startDate, isNotNull); + expect(event.endDate, isNotNull); + }); + + test('StatboticsEvent decodes a real /event/{key} body', () { + final event = StatboticsEvent.fromJson( + (_fixture('event') as Map).cast(), + ); + + expect(event.key, '2025cabe'); + expect(event.name, isNotEmpty); + expect(event.year, 2025); + }); + + test('StatboticsTeamBasic decodes a real /teams row', () { + final rows = _fixtureList('teams'); + final team = StatboticsTeamBasic.fromJson(rows.first); + + expect(team.team, greaterThan(0)); + expect(team.nickname, isNotEmpty); + }); + + test('an EPA object cached by an older client still reads', () { + // A last-good cache (#512) written before the shape fix carries + // total_points as {mean, sd}. Reading it must not throw on upgrade. + final legacy = StatboticsEpa.fromJson({ + 'total_points': {'mean': 41.2, 'sd': 2.9}, + }); + + expect(legacy.totalPoints, closeTo(41.2, 0.01)); + }); + }); } From c8fa0a50300ef48823979ac8a1be757709f4c584 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:47:55 -0500 Subject: [PATCH 2/4] refactor: drop the legacy EPA cache read that cannot be exercised The partial {mean, sd} fallback covered total_points but not the record or the per-phase breakdown, so a legacy cache would still have decoded to zeroed wins and null ranks. Completing it would guard a state that cannot exist: the cache writer requires a successful decode, decoding threw on every live response, and every release shipped inside the outage. Removed rather than finished, with the reasoning in the changelog. --- CHANGELOG.md | 9 ++++++--- lib/src/statbotics_models.dart | 20 ++++---------------- test/statbotics_client_test.dart | 10 ---------- 3 files changed, 10 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d09ecc2..eef8e9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,12 @@ rather than with the API. history, completing issue #9. One row per season with that season's EPA, record, and worldwide EPA rank. Note `/team_years` reports its record flat, unlike `/team_events`. -- `StatboticsEpa.fromJson` still reads the old `{mean, sd}` form for - `total_points`, so a last-good cache written by an earlier version loads - instead of throwing on upgrade. +- No cache migration, deliberately. An on-device last-good cache (#512) + cannot hold team-event data written by an earlier version: the cache writer + requires a successful decode, decoding threw on every live response, and + every release of this package (0.1.0 on 2026-08-05 onward) shipped inside + the outage. A consumer that reads an old record gets a decode miss its own + cache layer already treats as no cache. - Model tests now assert against captured live response bodies in `test/fixtures/`, so an API shape change fails a test. - Corrected two doc comments: an unknown team number answers 200 with `[]`, diff --git a/lib/src/statbotics_models.dart b/lib/src/statbotics_models.dart index 165a60b..6b032a9 100644 --- a/lib/src/statbotics_models.dart +++ b/lib/src/statbotics_models.dart @@ -175,18 +175,6 @@ class StatboticsEvent { } } -/// A number the API may report either bare or wrapped in a `{mean: ...}` -/// object. -/// -/// v3 reports EPA measures bare. The wrapped form only turns up in a -/// last-good cache written by an older client (#512), so reading both keeps an -/// upgrade from throwing on the first cache read. -double? _readMeasure(Object? raw) { - if (raw is num) return raw.toDouble(); - if (raw is Map) return (raw['mean'] as num?)?.toDouble(); - return null; -} - /// EPA (Expected Points Added) for a team, as `/team_events` and /// `/team_years` report it. /// @@ -209,12 +197,12 @@ class StatboticsEpa { final breakdown = (json['breakdown'] as Map?)?.cast() ?? const {}; return StatboticsEpa( - totalPoints: _readMeasure(json['total_points']), + totalPoints: (json['total_points'] as num?)?.toDouble(), unitless: (json['unitless'] as num?)?.toDouble(), norm: (json['norm'] as num?)?.toDouble(), - autoPoints: _readMeasure(breakdown['auto_points']), - teleopPoints: _readMeasure(breakdown['teleop_points']), - endgamePoints: _readMeasure(breakdown['endgame_points']), + autoPoints: (breakdown['auto_points'] as num?)?.toDouble(), + teleopPoints: (breakdown['teleop_points'] as num?)?.toDouble(), + endgamePoints: (breakdown['endgame_points'] as num?)?.toDouble(), ); } diff --git a/test/statbotics_client_test.dart b/test/statbotics_client_test.dart index 80b5d7a..b956681 100644 --- a/test/statbotics_client_test.dart +++ b/test/statbotics_client_test.dart @@ -819,15 +819,5 @@ void fixtureTests() { expect(team.team, greaterThan(0)); expect(team.nickname, isNotEmpty); }); - - test('an EPA object cached by an older client still reads', () { - // A last-good cache (#512) written before the shape fix carries - // total_points as {mean, sd}. Reading it must not throw on upgrade. - final legacy = StatboticsEpa.fromJson({ - 'total_points': {'mean': 41.2, 'sd': 2.9}, - }); - - expect(legacy.totalPoints, closeTo(41.2, 0.01)); - }); }); } From 4f2489826bff0889020848fbaabae58a262acb86 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:03:23 -0500 Subject: [PATCH 3/4] test: pin fixture assertions to the captured values isNotNull only catches a field decoding to null. A field read from the wrong but still populated place passes it, which is half the bug class these fixtures exist for. Pinning the values immediately caught that the raw /team_events body is not rank-ordered, so row 0 is team 199, not the rank-1 team getEventTeams sorts to the front. --- test/statbotics_client_test.dart | 53 ++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/test/statbotics_client_test.dart b/test/statbotics_client_test.dart index b956681..2654258 100644 --- a/test/statbotics_client_test.dart +++ b/test/statbotics_client_test.dart @@ -685,24 +685,33 @@ List> _fixtureList(String name) { void fixtureTests() { group('captured live responses', () { test('StatboticsTeamEvent decodes a real /team_events row', () { + // Row order is the API's own and is not by rank: getEventTeams applies + // that sort itself. final rows = _fixtureList('team_events_by_event'); final te = StatboticsTeamEvent.fromJson(rows.first); - expect(te.team, greaterThan(0)); + // Pinned to the values in the captured body, not just non-null: a + // field read from the wrong but still populated place passes an + // isNotNull check. Team 199 at the 2025 East Bay Regional. + expect(te.team, 199); expect(te.event, '2025cabe'); - expect(te.eventName, isNotEmpty); - expect(te.teamName, isNotEmpty); + expect(te.eventName, 'East Bay Regional'); + expect(te.teamName, 'Deep Blue'); expect(te.year, 2025); // The regression this file exists to catch: every one of these reads a // nested or bare field the client previously looked for in the wrong // place, so each would be null or zero against a live body. - expect(te.epa.totalPoints, isNotNull); - expect(te.epa.autoPoints, isNotNull); - expect(te.epa.teleopPoints, isNotNull); - expect(te.epa.endgamePoints, isNotNull); - expect(te.rank, isNotNull); - expect(te.numTeams, isNotNull); - expect(te.wins + te.losses + te.ties, greaterThan(0)); + expect(te.epa.totalPoints, closeTo(25.32, 0.01)); + expect(te.epa.autoPoints, closeTo(6.1, 0.01)); + expect(te.epa.teleopPoints, closeTo(18.23, 0.01)); + expect(te.epa.endgamePoints, closeTo(1.0, 0.01)); + expect(te.epa.unitless, closeTo(1484.0, 0.01)); + expect(te.epa.norm, closeTo(1494.0, 0.01)); + // record.qual, not top level. + expect(te.rank, 8); + expect(te.numTeams, 55); + // record.total, so elims count too: 7-3 overall. + expect(te.record, '7-3'); }); test('a real /team_events row survives the cache round-trip', () { @@ -728,24 +737,28 @@ void fixtureTests() { final te = StatboticsTeamEvent.fromJson(rows.first); expect(te.team, 254); - expect(te.epa.totalPoints, isNotNull); - expect(te.eventName, isNotEmpty); + expect(te.event, '2025cabe'); + expect(te.eventName, 'East Bay Regional'); + expect(te.epa.totalPoints, closeTo(91.91, 0.01)); + expect(te.rank, 1); }); test('StatboticsTeamYear decodes a real /team_years row', () { final rows = _fixtureList('team_years_by_team'); final ty = StatboticsTeamYear.fromJson(rows.first); + // 254's first season in the captured body, values pinned. expect(ty.team, 254); - expect(ty.year, greaterThan(1991)); - expect(ty.name, isNotEmpty); + expect(ty.year, 2002); + expect(ty.name, 'The Cheesy Poofs'); // /team_years reports the record flat, unlike /team_events. - expect(ty.wins + ty.losses + ty.ties, greaterThan(0)); - expect(ty.epa.totalPoints, isNotNull); - expect(ty.epa.unitless, isNotNull); - expect(ty.epa.norm, isNotNull); - expect(ty.epaRank, isNotNull); - expect(ty.epaRankTeamCount, isNotNull); + expect(ty.record, '18-6-1'); + expect(ty.epa.totalPoints, closeTo(16.6, 0.01)); + expect(ty.epa.unitless, closeTo(1657.0, 0.01)); + expect(ty.epa.norm, closeTo(1691.0, 0.01)); + // epa.ranks.total, two levels down. + expect(ty.epaRank, 30); + expect(ty.epaRankTeamCount, 641); }); test('a real /team_years row survives the cache round-trip', () { From 3bc485041ba78a9cd71e0859e13d7830dc03a283 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:14:05 -0500 Subject: [PATCH 4/4] docs: correct the migration note and two endpoint doc sentences The migration note claimed nothing else in the public API changed, which omitted getTeamYears, StatboticsTeamYear, getTeamEvents, unitless and norm. Additive surface still belongs in a migration note, if only to say no caller has to adopt it. Also reworded two sentences that had a team answering a request rather than the endpoint. --- CHANGELOG.md | 10 +++++++--- lib/src/statbotics_client.dart | 16 +++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eef8e9d..89e9f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,9 +39,13 @@ rather than with the API. 0.3.2: a team's history across events, from `/team_events` with a `team` filter, sorted newest season first then by event key. -Migrating: rename the four EPA field reads. Nothing else in the public API -changed, and code that only reads `record`, `rank` or `numTeams` off a -`StatboticsTeamEvent` needs no edit, though it was reading zeros before. +Migrating: rename the four EPA field reads, and drop any use of +`totalPointsSd`. That is the whole of it. Nothing else that already existed +moved or changed signature, so code reading `record`, `rank` or `numTeams` off +a `StatboticsTeamEvent` needs no edit, though it was reading zeros before +this. The rest of the release is additive: `getTeamYears`, +`StatboticsTeamYear`, `getTeamEvents`, and the `unitless` and `norm` fields +are all new surface no existing caller has to adopt. ## 0.3.1 diff --git a/lib/src/statbotics_client.dart b/lib/src/statbotics_client.dart index 16a5cfc..1c4075a 100644 --- a/lib/src/statbotics_client.dart +++ b/lib/src/statbotics_client.dart @@ -72,8 +72,9 @@ class StatboticsClient { /// just drops the `event` filter and adds `team` (and optionally `year`), /// answering with every row that mentions the team instead of every row for /// one event. Results are sorted by year descending, then event key - /// ascending. A team Statbotics has no events for answers 200 with `[]`, so - /// an unknown team number is an empty list rather than an error. + /// ascending. For a team Statbotics has no events for, the endpoint answers + /// 200 with `[]`, so an unknown team number comes back as an empty list + /// rather than an error. /// /// The 1000-row cap is fixed rather than a parameter, the way the other /// list methods fix theirs: a team plays a handful of events a season, so @@ -109,11 +110,12 @@ class StatboticsClient { /// the team competed in, newest season first. /// /// The season-over-season view [getTeamEvents] cannot give: that one answers - /// per event within a season, this one answers per season. A team Statbotics - /// has no seasons for answers 200 with `[]`, so an unknown team number is an - /// empty list rather than an error. A [team] of 100000 or more is rejected - /// with HTTP 422 and throws [StatboticsApiException]: that is a malformed - /// request, not an unknown team. + /// per event within a season, this one answers per season. For a team + /// Statbotics has no seasons for, the endpoint answers 200 with `[]`, so an + /// unknown team number comes back as an empty list rather than an error. A + /// [team] of 100000 or more is rejected with HTTP 422 and throws + /// [StatboticsApiException]: that is a malformed request, not an unknown + /// team. /// /// The 100-row cap is fixed the way the other list methods fix theirs. FRC /// has run since 1992, so no team has more seasons than that.