diff --git a/stronglift_replacement/workout_app/lib/main.dart b/stronglift_replacement/workout_app/lib/main.dart index f97a5ac..bbc12c8 100644 --- a/stronglift_replacement/workout_app/lib/main.dart +++ b/stronglift_replacement/workout_app/lib/main.dart @@ -6,6 +6,10 @@ import 'package:workout_app/services/backup_service.dart'; import 'package:workout_app/services/http_server_service.dart'; import 'package:workout_app/services/storage_service.dart'; +// coverage:ignore-start +// App bootstrap: permission request, DB init, and a real listening socket — +// platform-channel work that can't run on the CI test host. The pieces it wires +// up are unit-tested individually; the entry point itself is not. void main() async { WidgetsFlutterBinding.ensureInitialized(); await BackupService.instance.requestStoragePermission(); @@ -14,6 +18,7 @@ void main() async { await HttpServerService.instance.start(); runApp(const WorkoutApp()); } +// coverage:ignore-end /// Root widget that bootstraps the app with Material 3 dark theming. /// diff --git a/stronglift_replacement/workout_app/lib/services/backup_service.dart b/stronglift_replacement/workout_app/lib/services/backup_service.dart index a71da5f..477eae4 100644 --- a/stronglift_replacement/workout_app/lib/services/backup_service.dart +++ b/stronglift_replacement/workout_app/lib/services/backup_service.dart @@ -55,6 +55,10 @@ class BackupService { // ── Permission ───────────────────────────────────────────────────────────── + // coverage:ignore-start + // Thin wrappers over the permission_handler platform channel — Android + // runtime permissions that can't be meaningfully exercised on the test host. + /// Returns true if the app has MANAGE_EXTERNAL_STORAGE. Future hasStoragePermission() async { return Permission.manageExternalStorage.isGranted; @@ -64,10 +68,10 @@ class BackupService { /// /// Returns true once granted. Future requestStoragePermission() async { - final status = - await Permission.manageExternalStorage.request(); + final status = await Permission.manageExternalStorage.request(); return status.isGranted; } + // coverage:ignore-end // ── Export ───────────────────────────────────────────────────────────────── @@ -133,8 +137,12 @@ class BackupService { if (!f.existsSync()) return null; final token = await f.readAsString(); return token.isEmpty ? null : token; + // coverage:ignore-start + // Defensive: a present, readable token file is the norm; a portable way + // to make readAsString throw here (unreadable file) isn't available. } on Exception { return null; } + // coverage:ignore-end } } diff --git a/stronglift_replacement/workout_app/lib/services/http_server_service.dart b/stronglift_replacement/workout_app/lib/services/http_server_service.dart index 66f41bc..9eb7cc0 100644 --- a/stronglift_replacement/workout_app/lib/services/http_server_service.dart +++ b/stronglift_replacement/workout_app/lib/services/http_server_service.dart @@ -10,6 +10,7 @@ library; import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:workout_app/services/sync_service.dart'; @@ -84,6 +85,9 @@ class HttpServerService { } for (final path in candidates) { final file = File(path); + // coverage:ignore-start + // Only reachable when a candidate file exists — i.e. on a real device + // with /sdcard (or Android external storage). Not present on the host. if (file.existsSync()) { try { _latestJson = await file.readAsString(); @@ -92,9 +96,15 @@ class HttpServerService { // Try next path. } } + // coverage:ignore-end } } + /// Clears the cached workout JSON (test seam so the "no data yet" HTTP + /// response can be exercised after other tests have set it). + @visibleForTesting + void resetForTesting() => _latestJson = null; + Future _serve() async { final server = _server; if (server == null) return; diff --git a/stronglift_replacement/workout_app/test/services/http_server_service_test.dart b/stronglift_replacement/workout_app/test/services/http_server_service_test.dart index 62cda23..a0483b8 100644 --- a/stronglift_replacement/workout_app/test/services/http_server_service_test.dart +++ b/stronglift_replacement/workout_app/test/services/http_server_service_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:workout_app/services/http_server_service.dart'; @@ -40,5 +42,34 @@ void main() { test('kWorkoutServerPort has expected value', () { expect(kWorkoutServerPort, 8765); }); + + test('start swallows a bind failure when the port is taken', () async { + final blocker = await ServerSocket.bind( + InternetAddress.anyIPv4, + kWorkoutServerPort, + ); + // Binding fails (SocketException) but start() must not throw. + await HttpServerService.instance.start(); + await blocker.close(); + }); + + test('serves the latest workout over HTTP', () async { + HttpServerService.instance.resetForTesting(); + await HttpServerService.instance.start(); + final client = HttpClient(); + + Future get(String path) async { + final req = await client.get('localhost', kWorkoutServerPort, path); + final resp = await req.close(); + await resp.drain(); + return resp.statusCode; + } + + expect(await get('/workout'), 404); // no data yet + HttpServerService.instance.latestWorkout = '{"workout_type":"A"}'; + expect(await get('/workout'), 200); + expect(await get('/other'), 404); // unknown path + client.close(); + }); }); } diff --git a/stronglift_replacement/workout_app/test/widget_test.dart b/stronglift_replacement/workout_app/test/widget_test.dart index d152f30..24638e1 100644 --- a/stronglift_replacement/workout_app/test/widget_test.dart +++ b/stronglift_replacement/workout_app/test/widget_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:workout_app/main.dart'; +import 'package:workout_app/services/http_server_service.dart'; import 'package:workout_app/services/storage_service.dart'; void main() { @@ -23,4 +24,24 @@ void main() { await tester.pump(); expect(find.text('Workout Tracker'), findsOneWidget); }); + + testWidgets('app-lifecycle changes drive the HTTP server', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const WorkoutApp()); + await Future.delayed(const Duration(milliseconds: 100)); + // A valid forward transition path that visits every handled state. + for (final state in const [ + AppLifecycleState.resumed, + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + AppLifecycleState.detached, + ]) { + tester.binding.handleAppLifecycleStateChanged(state); + await Future.delayed(const Duration(milliseconds: 5)); + } + await HttpServerService.instance.stop(); + }); + await tester.pump(); + }); }