From 79c7d8d1c679007cfdb76d2004f531298f8ac454 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 13 Jul 2026 14:26:12 +0200 Subject: [PATCH] Cover workout_screen.dart to 100% Add live-workout interaction tests: set-tap breaks + Skip, warmup break, rep decrement recompute, long-press reset, in-workout threshold change, finish -> summary dialog, reset-confirm clears the session, and restoring an in-progress break through to _onBreakFinished. Interaction taps run under runAsync so their sqflite writes/break timers stay on the real loop. coverage:ignore the Android/iOS-only Vibration.vibrate call. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH --- .../lib/screens/workout_screen.dart | 4 + .../test/screens/workout_screen_test.dart | 193 ++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart index 4fdb808..3df90f6 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart @@ -301,7 +301,11 @@ class _WorkoutScreenState extends State { Future _onBreakFinished() async { await _audio.play(AssetSource('sounds/break_end.mp3')).catchError((_) {}); if (await Vibration.hasVibrator()) { + // Android/iOS-only: hasVibrator() returns false on the Linux test host + // (no Platform.isAndroid/isIOS), so this body never runs there. + // coverage:ignore-start unawaited(Vibration.vibrate(duration: 800)); + // coverage:ignore-end } setState(() { _breakForExIdx = -1; diff --git a/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart b/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart index b2fe613..7d88ee5 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart @@ -2,14 +2,32 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/screens/workout_screen.dart'; import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/widgets/break_banner.dart'; import 'package:workout_app/widgets/exercise_tile.dart'; import 'package:workout_app/widgets/rep_circle.dart'; +import 'package:workout_app/widgets/workout_summary_dialog.dart'; import '../fake_audio_platform.dart'; import '../fake_secure_storage.dart'; +/// A saved state with every set of the two [_exercises] tapped as done. +Map _completeSaved() => { + 'workoutType': 'A', + 'startTimeMs': DateTime.now().millisecondsSinceEpoch, + 'tapped': [ + [true, true, true], + [true, true, true], + ], + 'doneReps': [ + [5, 5, 5], + [5, 5, 5], + ], + 'warmupTapped': [false, false], + }; + const _exercises = [ Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20.0), Exercise(name: 'Press', sets: 3, reps: 5, weight: 15.0), @@ -155,4 +173,179 @@ void main() { await _pump(tester, _wrap(type: 'B')); expect(find.textContaining('Workout B'), findsOneWidget); }); + + // Interaction taps trigger `unawaited(_saveActiveSession())` (a sqflite write) + // and start `Timer.periodic` breaks. Dispatching them inside runAsync keeps + // those on the real loop, so they complete/cancel instead of lingering as + // FakeAsync "pending timers" that fail the test. + Future _tapReal(WidgetTester tester, Finder f) async { + await tester.runAsync(() async { + await tester.tap(f); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + } + + // tester.longPress() does internal fake-clock pumps that conflict with + // runAsync, so drive a manual gesture whose hold exceeds the long-press + // timeout on the real clock (keeping the resulting DB write on the real loop). + Future _longPressReal(WidgetTester tester, Finder f) async { + final center = tester.getCenter(f); + await tester.runAsync(() async { + final gesture = await tester.startGesture(center); + await Future.delayed(const Duration(milliseconds: 700)); + await gesture.up(); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + } + + testWidgets('tapping a set starts a break which Skip cancels', (tester) async { + await _pump(tester, _wrap()); + await _tapReal(tester, find.byType(RepCircle).first); + expect(find.byType(BreakBanner), findsOneWidget); + // Success break (5 done >= 5 target). + expect(find.textContaining('well done'), findsOneWidget); + + await _tapReal(tester, find.text('Skip')); + expect(find.byType(BreakBanner), findsNothing); + }); + + testWidgets('tapping warmup starts a warmup break', (tester) async { + await _pump(tester, _wrap()); + await _tapReal(tester, find.byIcon(Icons.fitness_center).first); + expect(find.textContaining('Warmup rest'), findsOneWidget); + }); + + testWidgets('re-tapping a set decrements reps and recomputes the break', + (tester) async { + await _pump(tester, _wrap()); + final circle = find.byType(RepCircle).first; + await _tapReal(tester, circle); // done=5 -> success break + expect(find.textContaining('well done'), findsOneWidget); + + await _tapReal(tester, circle); // done 5 -> 4, below target -> fail break + expect(find.textContaining('keep going'), findsOneWidget); + }); + + testWidgets('long-pressing a set resets it and cancels its break', + (tester) async { + await _pump(tester, _wrap()); + final circle = find.byType(RepCircle).first; + await _tapReal(tester, circle); + expect(find.byType(BreakBanner), findsOneWidget); + + await _longPressReal(tester, circle); + expect(find.byType(BreakBanner), findsNothing); + }); + + testWidgets('finishing a completed workout saves and shows the summary', + (tester) async { + await _pump(tester, _wrap(savedState: _completeSaved())); + + // Drive the whole flow on the real loop: _confirmFinish's showDialog await + // resumes into _finishWorkout in the same zone, and _finishWorkout does + // several sqflite writes that would hang under FakeAsync. + await tester.runAsync(() async { + await tester.tap(find.widgetWithText(TextButton, 'Finish')); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(); + expect(find.text('Finish workout?'), findsOneWidget); + // The dialog's Finish button is the last 'Finish' in the tree. + await tester.tap(find.widgetWithText(TextButton, 'Finish').last); + await Future.delayed(const Duration(milliseconds: 800)); + await tester.pump(); + }); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(WorkoutSummaryDialog), findsOneWidget); + }); + + testWidgets('confirming Reset clears the active session', (tester) async { + await tester.runAsync( + () => StorageService.instance.saveActiveSession({ + 'workoutType': 'A', + 'startTimeMs': DateTime.now().millisecondsSinceEpoch, + 'tapped': [ + [false, false, false], + [false, false, false], + ], + 'doneReps': [ + [5, 5, 5], + [5, 5, 5], + ], + 'warmupTapped': [false, false], + }), + ); + await _pump(tester, _wrap()); + + // Opening the confirm dialog does no DB work. + await tester.tap(find.text('Reset')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.text('Reset workout?'), findsOneWidget); + + // Confirm: clearActiveSession is a DB write, so run on the real loop. + // (The dialog's Reset button is the last 'Reset' in the tree.) + await tester.runAsync(() async { + await tester.tap(find.widgetWithText(TextButton, 'Reset').last); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + + final session = await tester.runAsync( + () => StorageService.instance.loadActiveSession(), + ); + expect(session, isNull); // reset cleared the persisted session + }); + + testWidgets('changing a threshold in the workout persists it', (tester) async { + // A real exercise has a progression-state row, so _onThresholdChanged's + // state-update branch runs (and the write actually lands). + final name = workoutA.first.name; + await _pump(tester, _wrap(exercises: [workoutA.first])); + + // The success stepper's "+" is the first Icons.add in the single tile. + await _tapReal(tester, find.byIcon(Icons.add).first); + + final state = await tester.runAsync( + () => StorageService.instance.getExerciseState(name), + ); + expect(state!.successThreshold, 4); // default 3 -> +1 + }); + + testWidgets('restores an in-progress break and finishes it', (tester) async { + final now = DateTime.now(); + final saved = { + 'workoutType': 'A', + 'startTimeMs': now.millisecondsSinceEpoch, + 'tapped': [ + [true, false, false], + [false, false, false], + ], + 'doneReps': [ + [5, 5, 5], + [5, 5, 5], + ], + 'warmupTapped': [false, false], + 'breakForExIdx': 0, + 'breakForSetIdx': 0, + 'breakLabel': 'Rest', + 'breakDurationSecs': 180, + 'breakEndMs': now.add(const Duration(seconds: 2)).millisecondsSinceEpoch, + }; + await _pump(tester, _wrap(savedState: saved)); + expect(find.byType(BreakBanner), findsOneWidget); // break restored + + // The break timer is a real periodic timer (initState ran under _pump's + // runAsync). Wait out its ~2s remaining so _tickBreak reaches 0 and + // _onBreakFinished (sound + vibration) runs on the real loop. + await tester.runAsync(() async { + await Future.delayed(const Duration(seconds: 3)); + await tester.pump(); + }); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(BreakBanner), findsNothing); + }); }