From 786d261d9e7bf3926fc8a97faf749c3e9fdaafa6 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 22:14:16 +0200 Subject: [PATCH] Split history_screen_test.dart (386) into three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the file the previous session's splitter could not handle: `_pump`, `_wrap`, `_seed` (and `_syncMock`) were local functions declared inside `main()`. A textual split copies the preamble into both halves, and the second half's references then resolve against the wrong closure — which is why the file was left on the list rather than split badly. Hoisted all four to top level in _history_test_fixtures.dart, renamed to pumpHistory / wrapHistory / seedSession / historySyncMock, then split by subject: history_chart_test.dart 221 chart, drill-down, PC-synced rows history_screen_test.dart 117 app bar, list, calendar, tile states _history_test_fixtures.dart 99 the four hoisted helpers None of the four closed over `main()` state, so hoisting is a pure move; the runAsync/sqflite-ffi comment on `seedSession` travels with it, since that is the non-obvious part a future reader needs. 402 tests pass. The name set gains only the new file's `(setUpAll)` / `(tearDownAll)` reporter records. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- .../test/screens/_history_test_fixtures.dart | 99 ++++++ .../test/screens/history_chart_test.dart | 221 +++++++++++++ .../test/screens/history_screen_test.dart | 299 +----------------- 3 files changed, 335 insertions(+), 284 deletions(-) create mode 100644 stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart create mode 100644 stronglift_replacement/workout_app/test/screens/history_chart_test.dart diff --git a/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart b/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart new file mode 100644 index 0000000..5478934 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart @@ -0,0 +1,99 @@ +// Shared fixtures for the history-screen test files. +// +// These four were local functions inside `main()`. That is the shape that +// silently breaks a split: both halves get a copy of the preamble, but the +// second half's references resolve against the wrong closure. Hoisted to +// top level so each split file imports one definition. +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:workout_app/screens/history_screen.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/ui/theme.dart'; + +Future pumpHistory(WidgetTester tester, Widget w) async { + await tester.runAsync(() async { + await tester.pumpWidget(w); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); +} + +Widget wrapHistory({http.Client? httpClient}) => MaterialApp( + theme: buildAppTheme(), + home: HistoryScreen(httpClient: httpClient), +); + +/// A GitHub mock serving one PC device log holding a run and a manual. +http.Client historySyncMock() { + String file(String body) => jsonEncode({ + 'content': base64Encode(utf8.encode(body)), + 'sha': 'sha', + }); + Record rec(String id, String kind, int ms) => Record( + id: id, + fields: { + 'payload': ( + { + 'kind': kind, + 'date': '2026-07-13', + 'source': 'Running: 9.8 km in 55 min', + }, + Hlc(wallTimeMs: ms, counter: 0, nodeId: 'pc'), + ), + }, + ); + final log = jsonEncode({ + 'runnerup_verified:2026-07-13': rec( + 'runnerup_verified:2026-07-13', + 'runnerup_verified', + 2000, + ).toJson(), + 'manual:2026-07-13T14:00': rec( + 'manual:2026-07-13T14:00', + 'manual_workout', + 1000, + ).toJson(), + }); + return MockClient((req) async { + final path = req.url.path; + if (path.endsWith('screen-locker-sync/devices')) { + return http.Response( + jsonEncode([ + {'name': 'pc', 'type': 'dir'}, + ]), + 200, + ); + } + if (path.contains('pc/log.json')) { + return http.Response(file(log), 200); + } + return http.Response('not found', 404); + }); +} + +// Seed a workout. DB writes must run on the real event loop: the widget-test +// zone fakes async, so a sqflite-ffi write in the test body hangs (then the +// isolate crashes on shutdown). runAsync gives the write the real loop. +Future seedSession( + WidgetTester tester, + String json, { + String date = '2024-06-01', + String type = 'A', + int duration = 1800, + bool succeeded = true, +}) async { + await tester.runAsync( + () => StorageService.instance.saveSession( + date: date, + workoutType: type, + durationSeconds: duration, + succeeded: succeeded, + json: json, + ), + ); +} diff --git a/stronglift_replacement/workout_app/test/screens/history_chart_test.dart b/stronglift_replacement/workout_app/test/screens/history_chart_test.dart new file mode 100644 index 0000000..02e3bd4 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/history_chart_test.dart @@ -0,0 +1,221 @@ +// The history chart, the per-exercise drill-down, and PC-synced rows. +// +// Split out of history_screen_test.dart, which keeps the list and tile tests. +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/workout_plan.dart'; +import 'package:workout_app/screens/history_screen.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/ui/theme.dart'; + +import '../fake_secure_storage.dart'; +import '_history_test_fixtures.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + // HistoryScreen pulls the PC's synced workouts, which reads the sync token + // from secure storage — fake it so tests never touch the OS keystore. + // No token => not configured => no network, and an empty synced list. + installFakeSecureStorage(); + }); + + testWidgets('chart renders with enough data points', (tester) async { + final json = jsonEncode({ + 'exercises': [ + { + 'name': 'Squat', + 'targetSets': 3, + 'targetReps': 5, + 'targetWeight': 20.0, + 'warmupDone': false, + 'succeeded': true, + 'sets': [], + }, + ], + }); + for (var i = 1; i <= 3; i++) { + await seedSession(tester, json, date: '2024-06-0$i'); + } + await pumpHistory(tester, wrapHistory()); + expect(find.byType(HistoryScreen), findsOneWidget); + }); + + // JSON for one session containing [name] at [weight]. + String _exerciseSessionJson( + String name, + double weight, { + required bool ok, + required bool warmup, + required bool withSets, + }) { + return jsonEncode({ + 'exercises': [ + { + 'name': name, + 'targetSets': 3, + 'targetReps': 5, + 'targetWeight': weight, + 'warmupDone': warmup, + 'succeeded': ok, + 'sets': withSets + ? [ + { + 'targetReps': 5, + 'doneReps': 5, + 'weight': weight, + 'succeeded': true, + }, + { + 'targetReps': 5, + 'doneReps': 4, + 'weight': weight, + 'succeeded': false, + }, + ] + : >[], + }, + ], + }); + } + + testWidgets('selecting an exercise shows the drill-down view', ( + tester, + ) async { + // Use a real progression-tracked exercise so getExerciseState is non-null + // and the streak stats card renders. Three distinct weights on three dates + // give the weight chart >= 2 points with a real range (painter fully runs). + final name = workoutA.first.name; + await seedSession( + tester, + _exerciseSessionJson(name, 20, ok: true, warmup: true, withSets: true), + date: '2024-06-01', + ); + await seedSession( + tester, + _exerciseSessionJson( + name, + 22.5, + ok: false, + warmup: false, + withSets: false, + ), + date: '2024-06-08', + succeeded: false, + ); + await seedSession( + tester, + _exerciseSessionJson(name, 25, ok: true, warmup: true, withSets: true), + date: '2024-06-15', + ); + + await pumpHistory(tester, wrapHistory()); + + // Open the dropdown and pick the exercise; _pickExercise awaits a DB read, + // and the menu open/close animation runs on real timers under runAsync, so + // drive the whole interaction on the real loop. + await tester.runAsync(() async { + await tester.tap(find.byType(DropdownButton)); + await Future.delayed(const Duration(milliseconds: 400)); + await tester.pump(); + await tester.tap(find.text(name).last); + await Future.delayed(const Duration(milliseconds: 400)); + await tester.pump(); + }); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Exercise view rendered: chart + streak stats card near the top. + expect(find.text('WEIGHT OVER TIME'), findsOneWidget); + expect(find.textContaining('more to'), findsWidgets); + + // Exercise-view calendar month nav (covers its onPrev/onNext closures). + await tester.tap(find.byIcon(Icons.chevron_right).first); + await tester.pump(); + await tester.tap(find.byIcon(Icons.chevron_left).first); + await tester.pump(); + + // The session list + name label sit below the fold; scroll to build them. + await tester.scrollUntilVisible( + find.text(name.toUpperCase()), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text(name.toUpperCase()), findsOneWidget); + // Session tiles: warmup marker and reps summary both present. + expect(find.textContaining('warmup'), findsWidgets); + expect(find.textContaining('reps:'), findsWidgets); + }); + + testWidgets('chart handles two points on the same date (tRange == 0)', ( + tester, + ) async { + // Two sessions on the SAME date give the total chart two points that share + // a timestamp, exercising the painter's tRange == 0 (centre-x) branch. + String vol(double w) => jsonEncode({ + 'exercises': [ + { + 'name': 'Squat', + 'targetSets': 3, + 'targetReps': 5, + 'targetWeight': w, + 'warmupDone': false, + 'succeeded': true, + 'sets': >[], + }, + ], + }); + await seedSession(tester, vol(20), date: '2024-06-01'); + await seedSession(tester, vol(40), date: '2024-06-01'); + await pumpHistory(tester, wrapHistory()); + expect(find.byType(HistoryScreen), findsOneWidget); + expect(find.text('WEIGHT OVER TIME').evaluate(), isEmpty); // total view + }); + + testWidgets('shows the PC-synced workouts the phone has no session for', ( + tester, + ) async { + // The PC publishes its whole workout_log.json; without this the two + // devices show different histories (a RunnerUp run exists only on the PC). + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + await seedSession( + tester, + jsonEncode({ + 'exercises': [ + { + 'name': 'Squat', + 'targetSets': 3, + 'targetReps': 5, + 'targetWeight': 40.0, + 'warmupDone': false, + 'succeeded': true, + 'setResults': >[], + }, + ], + }), + date: '2026-07-11', + ); + await pumpHistory(tester, wrapHistory(httpClient: historySyncMock())); + + await tester.scrollUntilVisible( + find.text('SYNCED FROM PC'), + 200, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('SYNCED FROM PC'), findsOneWidget); + expect(find.textContaining('2026-07-13 · Run'), findsOneWidget); + expect(find.textContaining('2026-07-13 · Manual'), findsOneWidget); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/history_screen_test.dart b/stronglift_replacement/workout_app/test/screens/history_screen_test.dart index 38de324..22d8cbe 100644 --- a/stronglift_replacement/workout_app/test/screens/history_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/history_screen_test.dart @@ -12,6 +12,7 @@ import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; import '../fake_secure_storage.dart'; +import '_history_test_fixtures.dart'; void main() { setUpAll(() { @@ -28,96 +29,13 @@ void main() { installFakeSecureStorage(); }); - Future _pump(WidgetTester tester, Widget w) async { - await tester.runAsync(() async { - await tester.pumpWidget(w); - await Future.delayed(const Duration(milliseconds: 300)); - }); - await tester.pump(); - } - - Widget _wrap({http.Client? httpClient}) => MaterialApp( - theme: buildAppTheme(), - home: HistoryScreen(httpClient: httpClient), - ); - - /// A GitHub mock serving one PC device log holding a run and a manual. - http.Client _syncMock() { - String file(String body) => jsonEncode({ - 'content': base64Encode(utf8.encode(body)), - 'sha': 'sha', - }); - Record rec(String id, String kind, int ms) => Record( - id: id, - fields: { - 'payload': ( - { - 'kind': kind, - 'date': '2026-07-13', - 'source': 'Running: 9.8 km in 55 min', - }, - Hlc(wallTimeMs: ms, counter: 0, nodeId: 'pc'), - ), - }, - ); - final log = jsonEncode({ - 'runnerup_verified:2026-07-13': rec( - 'runnerup_verified:2026-07-13', - 'runnerup_verified', - 2000, - ).toJson(), - 'manual:2026-07-13T14:00': rec( - 'manual:2026-07-13T14:00', - 'manual_workout', - 1000, - ).toJson(), - }); - return MockClient((req) async { - final path = req.url.path; - if (path.endsWith('screen-locker-sync/devices')) { - return http.Response( - jsonEncode([ - {'name': 'pc', 'type': 'dir'}, - ]), - 200, - ); - } - if (path.contains('pc/log.json')) { - return http.Response(file(log), 200); - } - return http.Response('not found', 404); - }); - } - - // Seed a workout. DB writes must run on the real event loop: the widget-test - // zone fakes async, so a sqflite-ffi write in the test body hangs (then the - // isolate crashes on shutdown). runAsync gives the write the real loop. - Future _seed( - WidgetTester tester, - String json, { - String date = '2024-06-01', - String type = 'A', - int duration = 1800, - bool succeeded = true, - }) async { - await tester.runAsync( - () => StorageService.instance.saveSession( - date: date, - workoutType: type, - durationSeconds: duration, - succeeded: succeeded, - json: json, - ), - ); - } - testWidgets('shows Progress app bar', (tester) async { - await _pump(tester, _wrap()); + await pumpHistory(tester, wrapHistory()); expect(find.text('Progress'), findsOneWidget); }); testWidgets('shows "No workouts yet." when history is empty', (tester) async { - await _pump(tester, _wrap()); + await pumpHistory(tester, wrapHistory()); expect(find.text('No workouts yet.'), findsOneWidget); }); @@ -137,8 +55,8 @@ void main() { }, ], }); - await _seed(tester, json); - await _pump(tester, _wrap()); + await seedSession(tester, json); + await pumpHistory(tester, wrapHistory()); expect(find.textContaining('Workout A'), findsWidgets); }); @@ -158,15 +76,15 @@ void main() { }, ], }); - await _seed(tester, json); - await _pump(tester, _wrap()); + await seedSession(tester, json); + await pumpHistory(tester, wrapHistory()); expect(find.textContaining('Total'), findsOneWidget); }); testWidgets('calendar prev/next month navigation works', (tester) async { final json = jsonEncode({'exercises': []}); - await _seed(tester, json); - await _pump(tester, _wrap()); + await seedSession(tester, json); + await pumpHistory(tester, wrapHistory()); await tester.tap(find.byIcon(Icons.chevron_right).first); await tester.pump(); await tester.tap(find.byIcon(Icons.chevron_left).first); @@ -176,15 +94,15 @@ void main() { testWidgets('session tile shows succeeded checkmark', (tester) async { final json = jsonEncode({'exercises': []}); - await _seed(tester, json); - await _pump(tester, _wrap()); + await seedSession(tester, json); + await pumpHistory(tester, wrapHistory()); expect(find.byIcon(Icons.check_circle), findsWidgets); }); testWidgets('session tile shows cancel icon on failure', (tester) async { final json = jsonEncode({'exercises': []}); - await _seed(tester, json, duration: 900, succeeded: false); - await _pump(tester, _wrap()); + await seedSession(tester, json, duration: 900, succeeded: false); + await pumpHistory(tester, wrapHistory()); expect(find.byIcon(Icons.cancel), findsWidgets); }); @@ -192,195 +110,8 @@ void main() { tester, ) async { final json = jsonEncode({'exercises': []}); - await _seed(tester, json, duration: 3700); - await _pump(tester, _wrap()); + await seedSession(tester, json, duration: 3700); + await pumpHistory(tester, wrapHistory()); expect(find.textContaining('1h'), findsOneWidget); }); - - testWidgets('chart renders with enough data points', (tester) async { - final json = jsonEncode({ - 'exercises': [ - { - 'name': 'Squat', - 'targetSets': 3, - 'targetReps': 5, - 'targetWeight': 20.0, - 'warmupDone': false, - 'succeeded': true, - 'sets': [], - }, - ], - }); - for (var i = 1; i <= 3; i++) { - await _seed(tester, json, date: '2024-06-0$i'); - } - await _pump(tester, _wrap()); - expect(find.byType(HistoryScreen), findsOneWidget); - }); - - // JSON for one session containing [name] at [weight]. - String _exerciseSessionJson( - String name, - double weight, { - required bool ok, - required bool warmup, - required bool withSets, - }) { - return jsonEncode({ - 'exercises': [ - { - 'name': name, - 'targetSets': 3, - 'targetReps': 5, - 'targetWeight': weight, - 'warmupDone': warmup, - 'succeeded': ok, - 'sets': withSets - ? [ - { - 'targetReps': 5, - 'doneReps': 5, - 'weight': weight, - 'succeeded': true, - }, - { - 'targetReps': 5, - 'doneReps': 4, - 'weight': weight, - 'succeeded': false, - }, - ] - : >[], - }, - ], - }); - } - - testWidgets('selecting an exercise shows the drill-down view', ( - tester, - ) async { - // Use a real progression-tracked exercise so getExerciseState is non-null - // and the streak stats card renders. Three distinct weights on three dates - // give the weight chart >= 2 points with a real range (painter fully runs). - final name = workoutA.first.name; - await _seed( - tester, - _exerciseSessionJson(name, 20, ok: true, warmup: true, withSets: true), - date: '2024-06-01', - ); - await _seed( - tester, - _exerciseSessionJson( - name, - 22.5, - ok: false, - warmup: false, - withSets: false, - ), - date: '2024-06-08', - succeeded: false, - ); - await _seed( - tester, - _exerciseSessionJson(name, 25, ok: true, warmup: true, withSets: true), - date: '2024-06-15', - ); - - await _pump(tester, _wrap()); - - // Open the dropdown and pick the exercise; _pickExercise awaits a DB read, - // and the menu open/close animation runs on real timers under runAsync, so - // drive the whole interaction on the real loop. - await tester.runAsync(() async { - await tester.tap(find.byType(DropdownButton)); - await Future.delayed(const Duration(milliseconds: 400)); - await tester.pump(); - await tester.tap(find.text(name).last); - await Future.delayed(const Duration(milliseconds: 400)); - await tester.pump(); - }); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); - - // Exercise view rendered: chart + streak stats card near the top. - expect(find.text('WEIGHT OVER TIME'), findsOneWidget); - expect(find.textContaining('more to'), findsWidgets); - - // Exercise-view calendar month nav (covers its onPrev/onNext closures). - await tester.tap(find.byIcon(Icons.chevron_right).first); - await tester.pump(); - await tester.tap(find.byIcon(Icons.chevron_left).first); - await tester.pump(); - - // The session list + name label sit below the fold; scroll to build them. - await tester.scrollUntilVisible( - find.text(name.toUpperCase()), - 300, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text(name.toUpperCase()), findsOneWidget); - // Session tiles: warmup marker and reps summary both present. - expect(find.textContaining('warmup'), findsWidgets); - expect(find.textContaining('reps:'), findsWidgets); - }); - - testWidgets('chart handles two points on the same date (tRange == 0)', ( - tester, - ) async { - // Two sessions on the SAME date give the total chart two points that share - // a timestamp, exercising the painter's tRange == 0 (centre-x) branch. - String vol(double w) => jsonEncode({ - 'exercises': [ - { - 'name': 'Squat', - 'targetSets': 3, - 'targetReps': 5, - 'targetWeight': w, - 'warmupDone': false, - 'succeeded': true, - 'sets': >[], - }, - ], - }); - await _seed(tester, vol(20), date: '2024-06-01'); - await _seed(tester, vol(40), date: '2024-06-01'); - await _pump(tester, _wrap()); - expect(find.byType(HistoryScreen), findsOneWidget); - expect(find.text('WEIGHT OVER TIME').evaluate(), isEmpty); // total view - }); - - testWidgets('shows the PC-synced workouts the phone has no session for', ( - tester, - ) async { - // The PC publishes its whole workout_log.json; without this the two - // devices show different histories (a RunnerUp run exists only on the PC). - installFakeSecureStorage(initial: {'sync.token': 'tok'}); - await _seed( - tester, - jsonEncode({ - 'exercises': [ - { - 'name': 'Squat', - 'targetSets': 3, - 'targetReps': 5, - 'targetWeight': 40.0, - 'warmupDone': false, - 'succeeded': true, - 'setResults': >[], - }, - ], - }), - date: '2026-07-11', - ); - await _pump(tester, _wrap(httpClient: _syncMock())); - - await tester.scrollUntilVisible( - find.text('SYNCED FROM PC'), - 200, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('SYNCED FROM PC'), findsOneWidget); - expect(find.textContaining('2026-07-13 · Run'), findsOneWidget); - expect(find.textContaining('2026-07-13 · Manual'), findsOneWidget); - }); }