Cover services + app bootstrap toward 100% flutter coverage

Pre-existing coverage gaps, unrelated to the manual-workout feature, blocking the
100% pre-push gate:

- widget_test: exercise WorkoutApp's app-lifecycle handler (paused/detached/
  resumed/inactive/hidden) via handleAppLifecycleStateChanged.
- http_server_service: add a resetForTesting seam and test the bind-in-use
  SocketException swallow plus the /workout HTTP endpoint (no-data 404, data 200,
  unknown-path 404). coverage:ignore the /sdcard-file-read branch (only reachable
  on a real device).
- backup_service: coverage:ignore the Android permission wrappers and the
  defensive readSyncToken catch.
- main(): coverage:ignore the platform-channel app bootstrap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
This commit is contained in:
Krzysztof Rudnicki 2026-07-13 13:27:18 +02:00
parent 5b3b15f73c
commit 0d0e6e4481
5 changed files with 77 additions and 2 deletions

View File

@ -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.
///

View File

@ -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<bool> hasStoragePermission() async {
return Permission.manageExternalStorage.isGranted;
@ -64,10 +68,10 @@ class BackupService {
///
/// Returns true once granted.
Future<bool> 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
}
}

View File

@ -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<void> _serve() async {
final server = _server;
if (server == null) return;

View File

@ -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<int> get(String path) async {
final req = await client.get('localhost', kWorkoutServerPort, path);
final resp = await req.close();
await resp.drain<void>();
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();
});
});
}

View File

@ -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<void>.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<void>.delayed(const Duration(milliseconds: 5));
}
await HttpServerService.instance.stop();
});
await tester.pump();
});
}