From 25f385f4f5e3d65f97a4e861da0d5d78e28123f1 Mon Sep 17 00:00:00 2001 From: cappy-dev Date: Thu, 6 Aug 2026 05:58:40 +0000 Subject: [PATCH 1/2] test: cover list endpoints and fix match display name casing Adds the first tests for getEvents, getEventMatches, and getEventTeamsBasic, the three list endpoints that had no test coverage. getEvents and getEventMatches verify the request URL and the sort order (week then name, and comp level then match number). getEventTeamsBasic verifies parsing plus the best-effort empty-list fallback for both a non-transient HTTP error (403) and a malformed response body. Also fixes StatboticsMatch.displayName, which special-cased qm, sf, and f but left ef and qf to fall through to the default branch, producing lower-case 'ef2' and 'qf3' while siblings rendered as 'EF2' / 'QF3' / 'SF1' / 'F1'. All known FRC comp levels now render with their conventional two-letter abbreviation, and unknown levels upper-case themselves so a future level never silently renders lower case. Verification (Dart 3.12.2): dart format --output=none --set-exit-if-changed . -> clean dart analyze --fatal-infos -> no issues dart test -> 21 passed (was 13) On behalf of @project516 --- lib/src/statbotics_models.dart | 6 +- test/statbotics_client_test.dart | 221 +++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 1 deletion(-) diff --git a/lib/src/statbotics_models.dart b/lib/src/statbotics_models.dart index f514ec4..0183b82 100644 --- a/lib/src/statbotics_models.dart +++ b/lib/src/statbotics_models.dart @@ -90,12 +90,16 @@ class StatboticsMatch { switch (compLevel) { case 'qm': return 'Q$matchNumber'; + case 'ef': + return 'EF$matchNumber'; + case 'qf': + return 'QF$matchNumber'; case 'sf': return 'SF$matchNumber'; case 'f': return 'F$matchNumber'; default: - return '$compLevel$matchNumber'; + return '${compLevel.toUpperCase()}$matchNumber'; } } diff --git a/test/statbotics_client_test.dart b/test/statbotics_client_test.dart index a898e6d..21fa116 100644 --- a/test/statbotics_client_test.dart +++ b/test/statbotics_client_test.dart @@ -5,6 +5,35 @@ import 'package:http/http.dart' as http; import 'package:statbotics_client/statbotics_client.dart'; import 'package:http/testing.dart'; +/// Builds the JSON object Statbotics returns for a single match, with the +/// [team_keys] form the v3 API actually uses for alliances. +Map _matchJson( + String key, + String compLevel, + int matchNumber, + List redTeams, + List blueTeams, +) { + return { + 'key': key, + 'event': '2026mrcmp', + 'match_number': matchNumber, + 'comp_level': compLevel, + 'alliances': { + 'red': { + 'team_keys': redTeams, + 'surrogate_team_keys': const [], + 'dq_team_keys': const [], + }, + 'blue': { + 'team_keys': blueTeams, + 'surrogate_team_keys': const [], + 'dq_team_keys': const [], + }, + }, + }; +} + void main() { group('StatboticsClient', () { test('getEvent parses event fields', () async { @@ -185,6 +214,198 @@ void main() { expect(match.displayName, 'Q1'); }); + test('StatboticsMatch.displayName formats every comp level consistently', + () { + // Every known FRC comp level uses its conventional two-letter + // abbreviation; qualification matches collapse to "Q". + // Unknown levels fall through to "" in upper case so a + // future level never silently renders lower case. + matchOf(String level, int n) => + StatboticsMatch.fromJson({ + 'key': '2026x_$level$n', + 'event': '2026x', + 'match_number': n, + 'comp_level': level, + 'alliances': const {}, + }); + expect(matchOf('qm', 12).displayName, 'Q12'); + expect(matchOf('ef', 2).displayName, 'EF2'); + expect(matchOf('qf', 3).displayName, 'QF3'); + expect(matchOf('sf', 1).displayName, 'SF1'); + expect(matchOf('f', 2).displayName, 'F2'); + expect(matchOf('xx', 5).displayName, 'XX5'); + expect(matchOf('qm', 1).isQualification, isTrue); + }); + + test('getEvents parses list, filters by year, and sorts by week then name', + () async { + final mockClient = MockClient((request) async { + expect( + request.url.toString(), + 'https://api.statbotics.io/v3/events?year=2026&limit=500', + ); + return http.Response( + jsonEncode(>[ + // Deliberately out of order: a later-week event first, then a + // null-week event, then an earlier-week event sharing a week with + // another entry to exercise the secondary name sort. + { + 'key': '2026txhou', + 'name': 'Houston District', + 'year': 2026, + 'week': 4, + }, + { + 'key': '2026off', + 'name': 'Off-Season Demo', + 'year': 2026, + // week omitted -> null, must sort after every numbered week. + }, + { + 'key': '2026nyfl', + 'name': 'Finger Lakes Regional', + 'year': 2026, + 'week': 2, + }, + { + 'key': '2026azpx', + 'name': 'A high-desert Event', + 'year': 2026, + 'week': 2, + }, + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final client = StatboticsClient(httpClient: mockClient); + final events = await client.getEvents(2026); + + expect(events.length, 4); + // Sorted by week ascending, then name; null week sorts last. + expect(events[0].key, '2026azpx'); // week 2, "A high-desert Event" + expect(events[1].key, '2026nyfl'); // week 2, "Finger Lakes Regional" + expect(events[2].key, '2026txhou'); // week 4 + expect(events[3].key, '2026off'); // null week -> end + expect(events[3].week, isNull); + }); + + test('getEvents returns empty list on 404', () async { + final client = StatboticsClient( + httpClient: MockClient((_) async => http.Response('', 404)), + ); + final events = await client.getEvents(2026); + expect(events, isEmpty); + }); + + test('getEventMatches parses list and sorts by comp level then number', + () async { + final mockClient = MockClient((request) async { + expect( + request.url.toString(), + 'https://api.statbotics.io/v3/matches?event=2026mrcmp&limit=200', + ); + expect(request.url.queryParameters['event'], '2026mrcmp'); + return http.Response( + jsonEncode(>[ + _matchJson('2026mrcmp_qf2', 'qf', 2, [1, 2, 3], [4, 5, 6]), + _matchJson('2026mrcmp_qm75', 'qm', 75, [7, 8, 9], [10, 11, 12]), + _matchJson('2026mrcmp_f1', 'f', 1, [13, 14, 15], [16, 17, 18]), + _matchJson('2026mrcmp_qf1', 'qf', 1, [19, 20, 21], [22, 23, 24]), + _matchJson('2026mrcmp_sf1', 'sf', 1, [25, 26, 27], [28, 29, 30]), + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final client = StatboticsClient(httpClient: mockClient); + final matches = await client.getEventMatches('2026mrcmp'); + + expect(matches.length, 5); + // qm < ef < qf < sf < f, then by match number within a level. + expect(matches[0].key, '2026mrcmp_qm75'); + expect(matches[1].key, '2026mrcmp_qf1'); + expect(matches[2].key, '2026mrcmp_qf2'); + expect(matches[3].key, '2026mrcmp_sf1'); + expect(matches[4].key, '2026mrcmp_f1'); + expect(matches.first.redTeams, [7, 8, 9]); + expect(matches.last.isQualification, isFalse); + }); + + test('getEventMatches returns empty list on 404', () async { + final client = StatboticsClient( + httpClient: MockClient((_) async => http.Response('', 404)), + ); + final matches = await client.getEventMatches('9999xxx'); + expect(matches, isEmpty); + }); + + test('getEventTeamsBasic parses the basic team list', () async { + final mockClient = MockClient((request) async { + expect( + request.url.toString(), + 'https://api.statbotics.io/v3/teams?event=2026mrcmp&limit=100', + ); + expect(request.url.queryParameters['event'], '2026mrcmp'); + return http.Response( + jsonEncode(>[ + { + 'team': 2714, + 'name': 'Mech Tech', + 'country': 'USA', + }, + { + 'team': 1234, + 'name': 'Example Robotics', + }, + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final client = StatboticsClient(httpClient: mockClient); + final teams = await client.getEventTeamsBasic('2026mrcmp'); + + expect(teams.length, 2); + expect(teams[0].team, 2714); + expect(teams[0].nickname, 'Mech Tech'); + expect(teams[1].nickname, 'Example Robotics'); + }); + + test('getEventTeamsBasic returns an empty list when the endpoint fails', + () async { + // The basic-team endpoint is best-effort: a non-transient error from the + // API must not bubble up, it must degrade to an empty list. + var calls = 0; + final mockClient = MockClient((_) async { + calls++; + return http.Response('forbidden', 403); + }); + final client = StatboticsClient( + httpClient: mockClient, + sleep: (_) async {}, + ); + final teams = await client.getEventTeamsBasic('2026mrcmp'); + expect(teams, isEmpty); + // 403 is not transient, so the request is attempted exactly once. + expect(calls, 1); + }); + + test('getEventTeamsBasic swallows a malformed response with an empty list', + () async { + // A truncated/garbled body would throw during jsonDecode; the handler + // catches that and reports no teams rather than crashing callers. + final mockClient = MockClient( + (_) async => http.Response('not json at all', 200), + ); + final client = StatboticsClient(httpClient: mockClient); + final teams = await client.getEventTeamsBasic('2026mrcmp'); + expect(teams, isEmpty); + }); + test('retries a transient 500 then succeeds (#496)', () async { var calls = 0; final mockClient = MockClient((request) async { From 30931732752c5b811af85d07e93b977597b8949a Mon Sep 17 00:00:00 2001 From: project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:26:23 -0500 Subject: [PATCH 2/2] test: cover the ef level and drop the year-filter claim --- test/statbotics_client_test.dart | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/statbotics_client_test.dart b/test/statbotics_client_test.dart index 21fa116..8b5bd46 100644 --- a/test/statbotics_client_test.dart +++ b/test/statbotics_client_test.dart @@ -237,8 +237,10 @@ void main() { expect(matchOf('qm', 1).isQualification, isTrue); }); - test('getEvents parses list, filters by year, and sorts by week then name', - () async { + // The year is a server-side query parameter, not a client-side filter, so + // the assertions here are the request URL and the ordering of what comes + // back. + test('getEvents requests the year and sorts by week then name', () async { final mockClient = MockClient((request) async { expect( request.url.toString(), @@ -314,6 +316,7 @@ void main() { _matchJson('2026mrcmp_f1', 'f', 1, [13, 14, 15], [16, 17, 18]), _matchJson('2026mrcmp_qf1', 'qf', 1, [19, 20, 21], [22, 23, 24]), _matchJson('2026mrcmp_sf1', 'sf', 1, [25, 26, 27], [28, 29, 30]), + _matchJson('2026mrcmp_ef1', 'ef', 1, [31, 32, 33], [34, 35, 36]), ]), 200, headers: {'content-type': 'application/json'}, @@ -323,13 +326,14 @@ void main() { final client = StatboticsClient(httpClient: mockClient); final matches = await client.getEventMatches('2026mrcmp'); - expect(matches.length, 5); + expect(matches.length, 6); // qm < ef < qf < sf < f, then by match number within a level. expect(matches[0].key, '2026mrcmp_qm75'); - expect(matches[1].key, '2026mrcmp_qf1'); - expect(matches[2].key, '2026mrcmp_qf2'); - expect(matches[3].key, '2026mrcmp_sf1'); - expect(matches[4].key, '2026mrcmp_f1'); + expect(matches[1].key, '2026mrcmp_ef1'); + expect(matches[2].key, '2026mrcmp_qf1'); + expect(matches[3].key, '2026mrcmp_qf2'); + expect(matches[4].key, '2026mrcmp_sf1'); + expect(matches[5].key, '2026mrcmp_f1'); expect(matches.first.redTeams, [7, 8, 9]); expect(matches.last.isQualification, isFalse); });