Files
ZenXercise/test/player/session_runner_test.dart
CedricandClaude Opus 5.5 971ff64117 Stage 4: guided Player for both Modes and the Session summary
- buildSegments lays a Plan out as holds with Transitions (get ready,
  switch sides, next round, next exercise) or sets with count-up Rests;
  Supersets alternate members and rest per round unless asked otherwise
- SessionRunner drives segments against an injected clock: auto-advance
  with a chime, pause, skip, back, rep sets confirmed with logged reps
  and kg, timed sets started on demand, early end
- Player screen: countdown ring, accent pulse in the last 3 s, Flip
  Animation, next-up cards, Rest Screen, mute toggle, screen kept on
- Sessions table (schema v2 migration) keeps a Plan snapshot and Set
  Logs; nothing is saved when no hold or set was done
- Summary with time, exercises or sets logged, and every Set Log
- Synthesised quiet cue sounds (tools/sounds), status bar per Mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:20:16 +01:00

159 lines
5.4 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:zenxercise/src/modes/mode.dart';
import 'package:zenxercise/src/plans/models/plan.dart';
import 'package:zenxercise/src/player/segments.dart';
import 'package:zenxercise/src/player/session_runner.dart';
Plan _plan(Mode mode, List<PlanItem> items, {int transition = 5}) => Plan(
id: 'p',
mode: mode,
name: 'Plan',
items: items,
transitionSeconds: transition,
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
);
final t0 = DateTime(2026, 10, 29, 7);
DateTime at(int seconds) => t0.add(Duration(seconds: seconds));
void main() {
group('Stretching runs by itself', () {
late SessionRunner runner;
var cues = 0;
setUp(() {
cues = 0;
runner = SessionRunner(
_plan(Mode.stretching, const [StretchingStep(exerciseId: 'A', holdSeconds: 30, bothSides: true, rounds: 1)]),
startedAt: t0,
onCue: () => cues++,
);
});
test('counting down a get-ready, two holds and the switch between them', () {
expect(runner.current, isA<TransitionSegment>());
expect(runner.remaining(at(2)), const Duration(seconds: 3));
runner.tick(at(5));
expect(runner.current, isA<HoldSegment>().having((h) => h.side, 'side', Side.left));
runner.tick(at(35));
expect(runner.current, isA<TransitionSegment>().having((t) => t.kind, 'kind', TransitionKind.switchSides));
runner.tick(at(40));
runner.tick(at(70));
expect(runner.isFinished, isTrue);
expect(runner.isComplete, isTrue);
expect(cues, 4);
});
test('pausing stops the clock', () {
runner.tick(at(5));
runner.pause(at(10));
runner.tick(at(100));
expect(runner.current, isA<HoldSegment>());
expect(runner.remaining(at(100)), const Duration(seconds: 25));
runner.resume(at(100));
expect(runner.remaining(at(110)), const Duration(seconds: 15));
});
test('skipping and going back move between holds, passing over Transitions', () {
runner.skip(at(1));
expect(runner.current, isA<HoldSegment>().having((h) => h.side, 'side', Side.left));
runner.skip(at(2));
runner.skip(at(3));
expect(runner.current, isA<HoldSegment>().having((h) => h.side, 'side', Side.right));
runner.back(at(4));
expect(runner.current, isA<HoldSegment>().having((h) => h.side, 'side', Side.left));
expect(runner.remaining(at(4)), const Duration(seconds: 30));
});
test('there is progress worth saving only once a hold has ended', () {
expect(runner.hasProgress, isFalse);
runner.tick(at(5));
// Skipping the left side is not progress; the right side starts after its switch at 15 s.
runner.skip(at(10));
runner.tick(at(15));
expect(runner.hasProgress, isFalse);
runner.tick(at(45));
expect(runner.hasProgress, isTrue);
});
test('ending early finishes incomplete', () {
runner.end(at(12));
expect(runner.isFinished, isTrue);
expect(runner.isComplete, isFalse);
expect(runner.result(at(12)).duration, const Duration(seconds: 12));
});
});
group('Workout runs at the user\'s pace', () {
final plan = _plan(Mode.workout, const [
WorkoutStep(exerciseId: 'Squat', sets: 2, target: WorkoutTarget.reps(10), weightKg: 60),
WorkoutStep(exerciseId: 'Plank', sets: 1, target: WorkoutTarget.seconds(30)),
]);
test('a rep set waits for "Set done", then the Rest counts up until the next set starts', () {
final runner = SessionRunner(plan, startedAt: t0);
runner.tick(at(600));
expect(runner.current, isA<SetSegment>());
runner.completeSet(reps: 8, weightKg: 62.5, now: at(40));
expect(runner.hasProgress, isTrue);
expect(runner.current, isA<RestSegment>());
expect(runner.elapsed(at(112)), const Duration(seconds: 72));
runner.startNext(at(120));
expect(runner.current, isA<SetSegment>().having((s) => s.set, 'set', 2));
});
test('a timed set starts on demand, counts down and logs itself', () {
var cues = 0;
final runner = SessionRunner(plan, startedAt: t0, onCue: () => cues++);
runner.completeSet(reps: 10, weightKg: 60, now: at(30));
runner.startNext(at(90));
runner.completeSet(reps: 10, weightKg: 60, now: at(120));
runner.startNext(at(180));
expect(runner.isWaitingToStart, isTrue);
runner.tick(at(500));
expect(runner.current, isA<SetSegment>());
runner.startTimer(at(500));
runner.tick(at(530));
expect(runner.isFinished, isTrue);
expect(cues, 1);
expect(runner.result(at(530)).setLogs.map((l) => (l.exerciseId, l.set, l.reps, l.seconds, l.weightKg)), [
('Squat', 1, 10, null, 60.0),
('Squat', 2, 10, null, 60.0),
('Plank', 1, null, 30, null),
]);
});
test('redoing a set after going back replaces its log', () {
final runner = SessionRunner(plan, startedAt: t0);
runner.completeSet(reps: 6, weightKg: 60, now: at(30));
runner.back(at(40));
runner.completeSet(reps: 10, weightKg: 60, now: at(60));
expect(runner.result(at(60)).setLogs.single.reps, 10);
});
test('a skipped set is not logged', () {
final runner = SessionRunner(plan, startedAt: t0);
runner.skip(at(10));
runner.skip(at(11));
expect(runner.current, isA<SetSegment>().having((s) => s.set, 'set', 2));
expect(runner.result(at(11)).setLogs, isEmpty);
});
});
}