diff --git a/stronglift_replacement/workout_app/test/text_free_guard.dart b/stronglift_replacement/workout_app/test/text_free_guard.dart new file mode 100644 index 0000000..f0ab025 --- /dev/null +++ b/stronglift_replacement/workout_app/test/text_free_guard.dart @@ -0,0 +1,58 @@ +/// textgate guard: collects every word a screen PAINTS. +/// +/// Walks the render tree (every [RenderParagraph], so Text, RichText, button +/// labels, AppBar titles, SnackBars and dialog bodies alike) and returns the +/// painted strings that still hold a 2+ letter word once content is removed: +/// exercise names, a unit symbol right after a number. Spoken-only labels +/// (`semanticLabel`, `Semantics(label:)`) are never painted, so they never +/// show up here. User-typed field values live in EditableText, not in a +/// RenderParagraph, so they are skipped too. +library; + +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/workout_plan.dart'; + +final _word = RegExp('[A-Za-z]{2,}'); + +// Unit symbols are content only when they qualify a number: "20kg", +// "1.2 km", "5 min". +final _unitAfterNumber = RegExp(r'(\d|\d\s)(kg|km|min|bpm|ms)\b'); + +/// Exercise names from both plans plus the test fixtures' names. +Set _exerciseNames(Iterable extra) => { + for (final e in [...workoutA, ...workoutB]) e.name, + 'Squat', + 'Press', + ...extra, +}; + +/// Painted strings that still contain a word after content is removed. +List chromeWords( + WidgetTester tester, { + Iterable content = const [], +}) { + final names = _exerciseNames(content).toList() + ..sort((a, b) => b.length.compareTo(a.length)); + final hits = {}; + for (final r in tester.allRenderObjects.whereType()) { + final painted = r.text.toPlainText(); + var rest = painted.replaceAllMapped(_unitAfterNumber, (m) => m[1]!); + for (final n in names) { + rest = rest.replaceAll(n, ''); + } + if (_word.hasMatch(rest)) hits.add(painted); + } + return hits.toList()..sort(); +} + +/// Fails with every offending string, and fails when nothing was painted +/// at all (an empty capture must never read as "clean"). +void expectTextFree( + WidgetTester tester, { + Iterable content = const [], +}) { + final painted = tester.allRenderObjects.whereType(); + expect(painted, isNotEmpty, reason: 'nothing painted: the guard saw no UI'); + expect(chromeWords(tester, content: content), isEmpty); +} diff --git a/stronglift_replacement/workout_app/test/text_free_guard_test.dart b/stronglift_replacement/workout_app/test/text_free_guard_test.dart new file mode 100644 index 0000000..3ce45b3 --- /dev/null +++ b/stronglift_replacement/workout_app/test/text_free_guard_test.dart @@ -0,0 +1,147 @@ +// textgate guard: the main screens paint no chrome words. +// +// Each test pumps one screen through the same fixtures its own test file +// uses and asserts, via [expectTextFree], that nothing painted holds a word +// besides exercise names and unit symbols after numbers. +import 'dart:convert'; + +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/exercise_result.dart'; +import 'package:workout_app/models/set_result.dart'; +import 'package:workout_app/models/workout_session.dart'; +import 'package:workout_app/screens/manual_workout_screen.dart'; +import 'package:workout_app/screens/settings_screen.dart'; +import 'package:workout_app/services/progression_sync_service.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/sync_service.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/workout_summary_dialog.dart'; + +import 'fake_audio_platform.dart'; +import 'fake_pc_pairing.dart'; +import 'fake_secure_storage.dart'; +import 'screens/_history_test_fixtures.dart'; +import 'screens/_home_test_fixtures.dart'; +import 'screens/_manual_screen_fixtures.dart'; +import 'screens/_workout_screen_test_fixtures.dart'; +import 'text_free_guard.dart'; + +String _sessionJson() => jsonEncode({ + 'exercises': [ + { + 'name': 'Squat', + 'targetSets': 3, + 'targetReps': 5, + 'targetWeight': 20.0, + 'warmupDone': false, + 'succeeded': true, + 'sets': [ + {'targetReps': 5, 'doneReps': 5, 'weight': 20.0, 'succeeded': true}, + ], + }, + ], +}); + +WorkoutSession _session({required bool ok}) { + final start = DateTime(2024, 6, 1, 9); + return WorkoutSession( + workoutType: 'A', + startTime: start, + endTime: start.add(const Duration(minutes: 45)), + exercises: [ + ExerciseResult( + exercise: const Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20), + sets: List.generate( + 3, + (_) => SetResult(targetReps: 5, doneReps: ok ? 5 : 3, weight: 20), + ), + ), + ], + ); +} + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + installFakeAudioPlatform(); + }); + + testWidgets('home, nothing done yet', (tester) async { + await pumpHome(tester, wrapHome()); + expectTextFree(tester); + }); + + testWidgets('home, done for today', (tester) async { + await seedSession(tester, _sessionJson(), date: '2026-08-15'); + await pumpHome(tester, wrapHome()); + expectTextFree(tester); + }); + + testWidgets('workout screen', (tester) async { + await pumpWorkout(tester, wrapWorkout()); + expectTextFree(tester); + }); + + testWidgets('history, empty and with a session', (tester) async { + await pumpHistory(tester, wrapHistory()); + expectTextFree(tester); + await seedSession(tester, _sessionJson()); + await pumpHistory(tester, wrapHistory()); + expectTextFree(tester); + }); + + testWidgets('settings', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: SettingsScreen( + pcPairingLoader: fakePcPairing, + firebaseFactory: () async => null, + accountLoader: () async => null, + sessionProbe: () async => false, + storageChecker: () async => false, + progressionPuller: () async => + const ProgressionSyncResult(changed: false, reason: 'stub'), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + expectTextFree(tester); + }); + + testWidgets('manual workout form', (tester) async { + await pumpManual( + tester, + ManualWorkoutScreen(syncService: FakeManualSync(), clock: fixedManualNow), + ); + expectTextFree(tester); + }); + + for (final ok in [true, false]) { + testWidgets('summary dialog, all succeeded: $ok', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: WorkoutSummaryDialog( + session: _session(ok: ok), + syncResult: const SyncResult(success: true, path: '/sdcard/x.json'), + ), + ), + ); + expectTextFree(tester); + }); + } +} diff --git a/stronglift_replacement/workout_app/test/text_free_widgets_guard_test.dart b/stronglift_replacement/workout_app/test/text_free_widgets_guard_test.dart new file mode 100644 index 0000000..fa23a20 --- /dev/null +++ b/stronglift_replacement/workout_app/test/text_free_widgets_guard_test.dart @@ -0,0 +1,109 @@ +// textgate guard, widget states the screen-level guard does not reach: +// every sync-card state, every PC poke outcome, the settings sheet in each +// progression mode, and the GitHub mirror screen. +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_state.dart'; +import 'package:workout_app/models/progression.dart'; +import 'package:workout_app/models/workout_plan.dart'; +import 'package:workout_app/screens/github_mirror_screen.dart'; +import 'package:workout_app/services/pc_poke_result.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/sync_status.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/exercise_settings_sheet.dart'; +import 'package:workout_app/widgets/pc_poke_line.dart'; +import 'package:workout_app/widgets/sync_setup_banner.dart'; +import 'package:workout_app/widgets/sync_status_card.dart'; + +import 'fake_secure_storage.dart'; +import 'text_free_guard.dart'; + +Widget _app(Widget child) => MaterialApp( + theme: buildAppTheme(), + home: Scaffold(body: child), +); + +void main() { + const statuses = [ + SyncStatus(state: SyncState.notConnected), + SyncStatus(state: SyncState.failed, reason: 'sync failed: offline'), + SyncStatus(state: SyncState.outOfDate, age: Duration(hours: 30)), + SyncStatus(state: SyncState.synced, age: Duration(minutes: 5)), + ]; + for (final s in statuses) { + testWidgets('sync card: ${s.state.name}', (tester) async { + await tester.pumpWidget( + _app(SyncStatusCard(status: s, onRetry: () {}, onSetUp: () {})), + ); + expectTextFree(tester); + }); + } + + testWidgets('sync setup banner', (tester) async { + await tester.pumpWidget(_app(const SyncSetupBanner())); + expectTextFree(tester); + }); + + const pokes = [ + PokeResult( + outcome: PokeOutcome.credited, + reason: 'credited', + shutdown: '22:00', + gamingBudgetMinutes: 90, + ), + PokeResult(outcome: PokeOutcome.duplicate, reason: 'dup'), + PokeResult(outcome: PokeOutcome.sandbox, reason: 'sb', roundTripMs: 40), + PokeResult(outcome: PokeOutcome.alreadyPaidToday, reason: 'paid'), + PokeResult(outcome: PokeOutcome.notCounted, reason: 'too short'), + PokeResult.failed('no reply'), + ]; + for (final p in pokes) { + testWidgets('pc poke line: ${p.outcome.name}', (tester) async { + await tester.pumpWidget(_app(PcPokeLine(poke: Future.value(p)))); + await tester.pump(); + expectTextFree(tester); + }); + } + + for (final mode in ProgressionMode.values) { + testWidgets('settings sheet: ${mode.name}', (tester) async { + final state = ExerciseState.initial(workoutA.first).copyWith(mode: mode); + await tester.pumpWidget( + _app( + ExerciseSettingsSheet( + state: state, + onChanged: (_) {}, + onDeload: () async => null, + ), + ), + ); + expectTextFree(tester); + }); + } + + group('github mirror screen', () { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + }); + + testWidgets('idle', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(theme: buildAppTheme(), home: const GitHubMirrorScreen()), + ); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + expectTextFree(tester); + }); + }); +}