diff --git a/waydowntown_app/lib/games/collector_game.dart b/waydowntown_app/lib/games/collector_game.dart index 45260ddd..dc4602ec 100644 --- a/waydowntown_app/lib/games/collector_game.dart +++ b/waydowntown_app/lib/games/collector_game.dart @@ -327,8 +327,14 @@ class CollectorGameState extends State List<_TimelineItem> _buildTimelineItems(BuildContext context) { final items = <_TimelineItem>[]; final teamSubmissions = _visibleTeamSubmissions(); - final teamSubmissionValues = - teamSubmissions.map((submission) => submission.submission).toSet(); + final visibleDetectedValues = detectedItems + .where( + (item) => showIncorrectSubmissions || item.state != SubmissionState.incorrect) + .where((item) => + item.state == SubmissionState.correct || + item.state == SubmissionState.incorrect) + .map((item) => item.value) + .toSet(); for (final hint in hints) { items.add(_TimelineItem( @@ -341,11 +347,6 @@ class CollectorGameState extends State if (!showIncorrectSubmissions && item.state == SubmissionState.incorrect) { continue; } - if ((item.state == SubmissionState.correct || - item.state == SubmissionState.incorrect) && - teamSubmissionValues.contains(item.value)) { - continue; - } items.add(_TimelineItem( timestamp: item.submittedAt, @@ -354,13 +355,26 @@ class CollectorGameState extends State } for (final submission in teamSubmissions) { + if (visibleDetectedValues.contains(submission.submission)) { + continue; + } items.add(_TimelineItem( timestamp: submission.insertedAt, widget: _buildSubmissionTile(submission), )); } - items.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final insertionOrder = <_TimelineItem, int>{ + for (var i = 0; i < items.length; i++) items[i]: i, + }; + + items.sort((a, b) { + final timeCompare = b.timestamp.compareTo(a.timestamp); + if (timeCompare != 0) { + return timeCompare; + } + return insertionOrder[b]!.compareTo(insertionOrder[a]!); + }); return items; } diff --git a/waydowntown_app/lib/games/food_court_frenzy.dart b/waydowntown_app/lib/games/food_court_frenzy.dart index fa73dd4f..5b34bcd9 100644 --- a/waydowntown_app/lib/games/food_court_frenzy.dart +++ b/waydowntown_app/lib/games/food_court_frenzy.dart @@ -105,7 +105,7 @@ class FoodCourtFrenzyGameState extends State try { final answerData = await requestHint(answer.answer.id); setState(() { - answer.hint = answerData?.label; + answer.hint = answerData?.hint; answer.isLoadingHint = false; }); } catch (e) { diff --git a/waydowntown_app/lib/models/answer.dart b/waydowntown_app/lib/models/answer.dart index bf947fdc..fb1dc618 100644 --- a/waydowntown_app/lib/models/answer.dart +++ b/waydowntown_app/lib/models/answer.dart @@ -15,12 +15,13 @@ class Answer { }); factory Answer.fromJson(Map json) { + final attributes = json['attributes'] as Map?; return Answer( id: json['id'], - label: json['attributes']['label'], - order: json['attributes']['order'], - hint: json['attributes']['hint'], - hasHint: json['attributes']['has_hint'], + label: attributes?['label'], + order: attributes?['order'], + hint: attributes?['hint'], + hasHint: attributes?['has_hint'] == true, ); } } diff --git a/waydowntown_app/lib/routes/request_run_route.dart b/waydowntown_app/lib/routes/request_run_route.dart index 39bdec37..277280ec 100644 --- a/waydowntown_app/lib/routes/request_run_route.dart +++ b/waydowntown_app/lib/routes/request_run_route.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:phoenix_socket/phoenix_socket.dart'; import 'package:sentry/sentry.dart'; import 'package:waydowntown/app.dart'; import 'package:waydowntown/models/run.dart'; @@ -10,6 +11,7 @@ class RequestRunRoute extends StatefulWidget { final String? concept; final String? specificationId; final String? position; + final PhoenixSocket? testSocket; const RequestRunRoute({ super.key, @@ -17,6 +19,7 @@ class RequestRunRoute extends StatefulWidget { this.concept, this.specificationId, this.position, + this.testSocket, }); @override @@ -91,7 +94,11 @@ class RequestRunRouteState extends State { appBar: AppBar(title: const Text('Game')), body: const Center(child: CircularProgressIndicator())); } else { - return RunLaunchRoute(run: run!, dio: widget.dio); + return RunLaunchRoute( + run: run!, + dio: widget.dio, + testSocket: widget.testSocket, + ); } } } diff --git a/waydowntown_app/lib/widgets/edit_specification_widget.dart b/waydowntown_app/lib/widgets/edit_specification_widget.dart index f9ecf7f5..871c0852 100644 --- a/waydowntown_app/lib/widgets/edit_specification_widget.dart +++ b/waydowntown_app/lib/widgets/edit_specification_widget.dart @@ -197,6 +197,7 @@ class EditSpecificationWidgetState extends State { const SizedBox(width: 8), _buildSortButton( context: context, + key: const Key('region-sort-alpha'), icon: const Icon(Icons.sort_by_alpha), isActive: !_sortByDistance, onPressed: () { @@ -209,6 +210,7 @@ class EditSpecificationWidgetState extends State { const SizedBox(width: 8), _buildSortButton( context: context, + key: const Key('region-sort-nearest'), icon: const Icon(Icons.near_me), isActive: _sortByDistance, onPressed: _loadNearestRegions, @@ -216,6 +218,7 @@ class EditSpecificationWidgetState extends State { const SizedBox(width: 8), _buildSortButton( context: context, + key: const Key('region-sort-add'), icon: const Icon(Icons.add), isActive: false, onPressed: _createNewRegion, @@ -407,11 +410,13 @@ class EditSpecificationWidgetState extends State { Widget _buildSortButton({ required BuildContext context, + Key? key, required Icon icon, required bool isActive, required VoidCallback onPressed, }) { return IconButton( + key: key, onPressed: onPressed, icon: icon, style: IconButton.styleFrom( diff --git a/waydowntown_app/test/games/bluetooth_collector_test.dart b/waydowntown_app/test/games/bluetooth_collector_test.dart index a5909ef1..51f5eff1 100644 --- a/waydowntown_app/test/games/bluetooth_collector_test.dart +++ b/waydowntown_app/test/games/bluetooth_collector_test.dart @@ -55,9 +55,10 @@ void main() { when(device3.remoteId).thenReturn(const DeviceIdentifier("3")); }); - setUp(() { + setUp(() async { mockFlutterBluePlus = MockFlutterBluePlusMockable(); dotenv.testLoad(fileInput: File('.env').readAsStringSync()); + await TestHelpers.setMockUser(); dio = Dio(BaseOptions(baseUrl: dotenv.env['API_ROOT']!)); dio.interceptors.add(PrettyDioLogger()); diff --git a/waydowntown_app/test/games/cardinal_memory_test.dart b/waydowntown_app/test/games/cardinal_memory_test.dart index 8f7a6a72..612aea20 100644 --- a/waydowntown_app/test/games/cardinal_memory_test.dart +++ b/waydowntown_app/test/games/cardinal_memory_test.dart @@ -32,8 +32,9 @@ void main() { late MockPhoenixChannel mockChannel; - setUp(() { + setUp(() async { mockMotionSensors = MockMotionSensors(); + await TestHelpers.setMockUser(); dio = Dio(BaseOptions(baseUrl: dotenv.env['API_ROOT']!)); dio.interceptors.add(PrettyDioLogger()); dioAdapter = DioAdapter(dio: dio); diff --git a/waydowntown_app/test/games/code_collector_test.dart b/waydowntown_app/test/games/code_collector_test.dart index f4c42df8..03e92c01 100644 --- a/waydowntown_app/test/games/code_collector_test.dart +++ b/waydowntown_app/test/games/code_collector_test.dart @@ -29,9 +29,10 @@ void main() { late MockMobileScannerController mockController; - setUp(() { + setUp(() async { mockController = MockMobileScannerController(); dotenv.testLoad(fileInput: File('.env').readAsStringSync()); + await TestHelpers.setMockUser(); dio = Dio(BaseOptions(baseUrl: dotenv.env['API_ROOT']!)); dio.interceptors.add(PrettyDioLogger()); diff --git a/waydowntown_app/test/games/food_court_frenzy_test.dart b/waydowntown_app/test/games/food_court_frenzy_test.dart index 369b03ec..4fcc5953 100644 --- a/waydowntown_app/test/games/food_court_frenzy_test.dart +++ b/waydowntown_app/test/games/food_court_frenzy_test.dart @@ -23,7 +23,8 @@ void main() { late Run run; late MockPhoenixChannel mockChannel; - setUp(() { + setUp(() async { + await TestHelpers.setMockUser(); dio = Dio(BaseOptions(baseUrl: dotenv.env['API_ROOT']!)); dio.interceptors.add(PrettyDioLogger()); dioAdapter = DioAdapter(dio: dio); diff --git a/waydowntown_app/test/games/orientation_memory_test.dart b/waydowntown_app/test/games/orientation_memory_test.dart index 57c1921e..b237ccb6 100644 --- a/waydowntown_app/test/games/orientation_memory_test.dart +++ b/waydowntown_app/test/games/orientation_memory_test.dart @@ -31,8 +31,9 @@ void main() { late MockMotionSensors mockMotionSensors; late MockPhoenixChannel mockChannel; - setUp(() { + setUp(() async { mockMotionSensors = MockMotionSensors(); + await TestHelpers.setMockUser(); dio = Dio(BaseOptions(baseUrl: dotenv.env['API_ROOT']!)); dio.interceptors.add(PrettyDioLogger()); dioAdapter = DioAdapter(dio: dio); diff --git a/waydowntown_app/test/games/single_string_input_game_test.dart b/waydowntown_app/test/games/single_string_input_game_test.dart index f1f48c08..71ad8b22 100644 --- a/waydowntown_app/test/games/single_string_input_game_test.dart +++ b/waydowntown_app/test/games/single_string_input_game_test.dart @@ -23,7 +23,8 @@ void main() { late Run run; late MockPhoenixChannel mockChannel; - setUp(() { + setUp(() async { + await TestHelpers.setMockUser(); dio = Dio(BaseOptions(baseUrl: dotenv.env['API_ROOT']!)); dio.interceptors.add(PrettyDioLogger()); dioAdapter = DioAdapter(dio: dio); diff --git a/waydowntown_app/test/games/string_collector_test.dart b/waydowntown_app/test/games/string_collector_test.dart index 4f62ff41..c0c259a9 100644 --- a/waydowntown_app/test/games/string_collector_test.dart +++ b/waydowntown_app/test/games/string_collector_test.dart @@ -23,7 +23,8 @@ void main() { late Run game; late MockPhoenixChannel mockChannel; - setUp(() { + setUp(() async { + await TestHelpers.setMockUser(); dio = Dio(BaseOptions(baseUrl: dotenv.env['API_ROOT']!)); dio.interceptors.add(PrettyDioLogger()); dioAdapter = DioAdapter(dio: dio); diff --git a/waydowntown_app/test/request_run_route_test.dart b/waydowntown_app/test/request_run_route_test.dart index a4f0494a..4a5bcdfc 100644 --- a/waydowntown_app/test/request_run_route_test.dart +++ b/waydowntown_app/test/request_run_route_test.dart @@ -2,25 +2,85 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:waydowntown/routes/request_run_route.dart'; import 'package:waydowntown/routes/run_launch_route.dart'; +import 'package:waydowntown/services/user_service.dart'; import './test_helpers.dart'; +import './test_helpers.mocks.dart'; + +class TestAssetBundle extends CachingAssetBundle { + final Map _assets = {}; + + void addAsset(String key, String value) { + _assets[key] = value; + } + + @override + void clear() { + _assets.clear(); + } + + @override + Future loadString(String key, {bool cache = true}) async { + if (_assets.containsKey(key)) { + if (_assets[key] is String) { + return _assets[key]!; + } + throw FlutterError('Asset is not a string: $key'); + } + throw FlutterError('Asset not found: $key'); + } + + @override + Future load(String key) async { + if (_assets.containsKey(key)) { + if (_assets[key] is String) { + return ByteData.view( + Uint8List.fromList(_assets[key]!.codeUnits).buffer); + } else if (_assets[key] is List) { + return ByteData.view(Uint8List.fromList(_assets[key]!).buffer); + } + } + throw FlutterError('Asset not found: $key'); + } +} void main() { late Dio dio; late DioAdapter dioAdapter; + late TestAssetBundle testAssetBundle; + late MockPhoenixSocket mockSocket; - setUp(() { + setUp(() async { dotenv.testLoad(fileInput: File('.env').readAsStringSync()); dio = Dio(BaseOptions(baseUrl: dotenv.env['API_ROOT']!)); dio.interceptors.add(PrettyDioLogger()); dioAdapter = DioAdapter(dio: dio); + + FlutterSecureStorage.setMockInitialValues({}); + await UserService.setUserData('user1', 'user1@example.com', false); + await UserService.setTokens('test_token', 'test_renewal_token'); + + testAssetBundle = TestAssetBundle(); + testAssetBundle.addAsset('assets/concepts.yaml', ''' +bluetooth_collector: + name: Bluetooth Collector + instructions: Collect Bluetooth devices +'''); + + (mockSocket, _, _) = TestHelpers.setupMockSocket(); + }); + + tearDown(() { + testAssetBundle.clear(); }); const requestRunRoute = '/waydowntown/runs'; @@ -32,7 +92,14 @@ void main() { TestHelpers.setupMockRunResponse(dioAdapter, route: requestRunRoute, run: mockGame); - await tester.pumpWidget(MaterialApp(home: RequestRunRoute(dio: dio))); + await tester.pumpWidget( + MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: RequestRunRoute(dio: dio, testSocket: mockSocket), + ), + ), + ); await tester.pumpAndSettle(); expect(find.byType(RunLaunchRoute), findsOneWidget); @@ -42,7 +109,14 @@ void main() { (WidgetTester tester) async { TestHelpers.setupMockErrorResponse(dioAdapter, requestRunRoute, data: {}); - await tester.pumpWidget(MaterialApp(home: RequestRunRoute(dio: dio))); + await tester.pumpWidget( + MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: RequestRunRoute(dio: dio, testSocket: mockSocket), + ), + ), + ); await tester.pumpAndSettle(); expect(find.text('Error fetching game'), findsOneWidget); diff --git a/waydowntown_app/test/test_helpers.dart b/waydowntown_app/test/test_helpers.dart index cb3dc4e9..0c836a16 100644 --- a/waydowntown_app/test/test_helpers.dart +++ b/waydowntown_app/test/test_helpers.dart @@ -2,12 +2,14 @@ import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:phoenix_socket/phoenix_socket.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:waydowntown/models/answer.dart'; import 'package:waydowntown/models/participation.dart'; import 'package:waydowntown/models/region.dart'; import 'package:waydowntown/models/run.dart'; import 'package:waydowntown/models/specification.dart'; +import 'package:waydowntown/services/user_service.dart'; @GenerateNiceMocks([ MockSpec(), @@ -17,6 +19,18 @@ import 'package:waydowntown/models/specification.dart'; import 'test_helpers.mocks.dart'; class TestHelpers { + static Future setMockUser({ + String userId = 'user1', + String email = 'user1@example.com', + bool isAdmin = false, + String accessToken = 'test_token', + String renewalToken = 'test_renewal_token', + }) async { + FlutterSecureStorage.setMockInitialValues({}); + await UserService.setUserData(userId, email, isAdmin); + await UserService.setTokens(accessToken, renewalToken); + } + static void setupMockRunResponse( DioAdapter dioAdapter, { required String route, @@ -85,7 +99,7 @@ class TestHelpers { final submissionId = setup.submissionId ?? "48cf441e-ab98-4da6-8980-69fba3b4417d"; - final responseJson = generateSubmissionResponseJson(SubmissionResponse( + final response = SubmissionResponse( submissionId: submissionId, submission: setup.submission, correct: setup.correct, @@ -93,14 +107,15 @@ class TestHelpers { correctSubmissions: setup.correctSubmissions, totalAnswers: setup.totalAnswers, isComplete: setup.isComplete, - )); + ); final requestJson = generateSubmissionRequestJson(setup.submission, runId, setup.answerId); dioAdapter.onPost( setup.route, - (server) => server.reply(201, responseJson), + (server) => + server.reply(201, generateSubmissionResponseJson(response)), data: requestJson, ); } @@ -136,6 +151,44 @@ class TestHelpers { static Map generateSubmissionResponseJson( SubmissionResponse response) { + final runAttributes = { + "correct_submissions": response.correctSubmissions, + "total_answers": response.totalAnswers, + "complete": response.isComplete, + if (response.isComplete) "winner_submission_id": response.submissionId, + }; + + final included = [ + { + "id": response.runId, + "type": "runs", + "attributes": runAttributes, + if (response.isComplete) + "relationships": { + "submissions": { + "data": [ + {"type": "submissions", "id": response.submissionId} + ] + } + } + }, + if (response.isComplete) + { + "id": response.submissionId, + "type": "submissions", + "attributes": { + "submission": response.submission, + "correct": response.correct, + "inserted_at": DateTime.now().toUtc().toIso8601String(), + }, + "relationships": { + "creator": { + "data": {"type": "users", "id": "user1"} + } + } + } + ]; + return { "data": { "id": response.submissionId, @@ -150,20 +203,14 @@ class TestHelpers { "type": "runs", "id": response.runId, } - } + }, + if (response.isComplete) + "creator": { + "data": {"type": "users", "id": "user1"} + } } }, - "included": [ - { - "id": response.runId, - "type": "runs", - "attributes": { - "correct_submissions": response.correctSubmissions, - "total_answers": response.totalAnswers, - "complete": response.isComplete, - } - } - ], + "included": included, "meta": {} }; } diff --git a/waydowntown_app/test/widgets/edit_specification_widget_test.dart b/waydowntown_app/test/widgets/edit_specification_widget_test.dart index dacc68fa..01db0c87 100644 --- a/waydowntown_app/test/widgets/edit_specification_widget_test.dart +++ b/waydowntown_app/test/widgets/edit_specification_widget_test.dart @@ -219,29 +219,26 @@ another_concept: await tester.pumpAndSettle(); // Alphabetic sort by default - final azButtonFinder = find.widgetWithText(ElevatedButton, 'A-Z'); - final nearestButtonFinder = find.widgetWithText(ElevatedButton, 'Nearest'); + final azButtonFinder = find.byKey(const Key('region-sort-alpha')); + final nearestButtonFinder = find.byKey(const Key('region-sort-nearest')); - ElevatedButton azButton = tester.widget(azButtonFinder); - ElevatedButton nearestButton = tester.widget(nearestButtonFinder); + IconButton azButton = tester.widget(azButtonFinder); + IconButton nearestButton = tester.widget(nearestButtonFinder); - expect(azButton.style?.backgroundColor?.resolve({WidgetState.pressed}), + expect(azButton.style?.backgroundColor?.resolve({}), equals(Theme.of(tester.element(azButtonFinder)).primaryColor)); - expect(nearestButton.style?.backgroundColor?.resolve({WidgetState.pressed}), - isNot(Theme.of(tester.element(nearestButtonFinder)).primaryColor)); + expect(nearestButton.style?.backgroundColor?.resolve({}), isNull); + await tester.ensureVisible(azButtonFinder); await tester.tap(azButtonFinder); await tester.pumpAndSettle(); azButton = tester.widget(azButtonFinder); nearestButton = tester.widget(nearestButtonFinder); - expect(azButton.style?.backgroundColor?.resolve({WidgetState.pressed}), + expect(azButton.style?.backgroundColor?.resolve({}), equals(Theme.of(tester.element(azButtonFinder)).primaryColor)); - expect( - nearestButton.style?.backgroundColor?.resolve({WidgetState.pressed}), - isNot(equals( - Theme.of(tester.element(nearestButtonFinder)).primaryColor))); + expect(nearestButton.style?.backgroundColor?.resolve({}), isNull); // Regions should be sorted case-insensitive @@ -258,18 +255,20 @@ another_concept: expect(find.text('2 km'), findsNothing); expect(find.text('3 km'), findsNothing); + // Close the dropdown before interacting with other controls. + await tester.tap(find.text('region 1').last); + await tester.pumpAndSettle(); + + await tester.ensureVisible(nearestButtonFinder); await tester.tap(nearestButtonFinder); await tester.pumpAndSettle(); azButton = tester.widget(azButtonFinder); nearestButton = tester.widget(nearestButtonFinder); - expect(azButton.style?.backgroundColor?.resolve({WidgetState.pressed}), - (equals(Theme.of(tester.element(azButtonFinder)).primaryColor))); - expect( - nearestButton.style?.backgroundColor?.resolve({WidgetState.pressed}), - isNot(equals( - Theme.of(tester.element(nearestButtonFinder)).primaryColor))); + expect(azButton.style?.backgroundColor?.resolve({}), isNull); + expect(nearestButton.style?.backgroundColor?.resolve({}), + equals(Theme.of(tester.element(nearestButtonFinder)).primaryColor)); await tester.tap(find.byKey(const Key('region-dropdown'))); await tester.pumpAndSettle(); @@ -297,12 +296,9 @@ another_concept: expect(find.text(specification.startDescription!), findsOneWidget); expect(find.text(specification.taskDescription!), findsOneWidget); expect(find.text(specification.duration.toString()), findsOneWidget); - expect( - find.descendant( - of: find.byType(MenuItemButton), - matching: find.text('region 1'), - ), - findsOneWidget); + final regionDropdownState = + tester.state>(find.byKey(const Key('region-dropdown'))); + expect(regionDropdownState.value, equals('region1')); await tester.tap(find.byType(DropdownButtonFormField).first); await tester.pumpAndSettle(); @@ -424,7 +420,9 @@ another_concept: await tester.pumpAndSettle(); - await tester.tap(find.text('New')); + final addRegionButton = find.byKey(const Key('region-sort-add')); + await tester.ensureVisible(addRegionButton); + await tester.tap(addRegionButton); await tester.pumpAndSettle(); expect(find.text('Create New Region'), findsOneWidget); @@ -437,11 +435,8 @@ another_concept: await tester.tap(find.text('Save Region')); await tester.pumpAndSettle(); - expect( - find.descendant( - of: find.byType(MenuItemButton), - matching: find.text('New Region'), - ), - findsOneWidget); + final regionDropdownState = + tester.state>(find.byKey(const Key('region-dropdown'))); + expect(regionDropdownState.value, equals('new_region')); }); }