Split history_screen_test.dart (386) into three

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 22:14:16 +02:00
parent a4b189ee3b
commit 786d261d9e
3 changed files with 335 additions and 284 deletions

View File

@ -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<void> pumpHistory(WidgetTester tester, Widget w) async {
await tester.runAsync(() async {
await tester.pumpWidget(w);
await Future<void>.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<void> 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,
),
);
}

View File

@ -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,
},
]
: <Map<String, dynamic>>[],
},
],
});
}
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<String>));
await Future<void>.delayed(const Duration(milliseconds: 400));
await tester.pump();
await tester.tap(find.text(name).last);
await Future<void>.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': <Map<String, dynamic>>[],
},
],
});
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': <Map<String, dynamic>>[],
},
],
}),
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);
});
}

View File

@ -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<void> _pump(WidgetTester tester, Widget w) async {
await tester.runAsync(() async {
await tester.pumpWidget(w);
await Future<void>.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<void> _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,
},
]
: <Map<String, dynamic>>[],
},
],
});
}
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<String>));
await Future<void>.delayed(const Duration(milliseconds: 400));
await tester.pump();
await tester.tap(find.text(name).last);
await Future<void>.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': <Map<String, dynamic>>[],
},
],
});
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': <Map<String, dynamic>>[],
},
],
}),
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);
});
}