Guard workout app screens against painted words

Walks every RenderParagraph of the main screens and widget states;
fails on any 2+ letter word besides exercise names and units after a
number. Red on 32a735f (22 of 23), green now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 01:28:09 +01:00
parent 2c52408cec
commit 71a35d6aa1
3 changed files with 314 additions and 0 deletions

View File

@ -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<String> _exerciseNames(Iterable<String> extra) => {
for (final e in [...workoutA, ...workoutB]) e.name,
'Squat',
'Press',
...extra,
};
/// Painted strings that still contain a word after content is removed.
List<String> chromeWords(
WidgetTester tester, {
Iterable<String> content = const [],
}) {
final names = _exerciseNames(content).toList()
..sort((a, b) => b.length.compareTo(a.length));
final hits = <String>{};
for (final r in tester.allRenderObjects.whereType<RenderParagraph>()) {
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<String> content = const [],
}) {
final painted = tester.allRenderObjects.whereType<RenderParagraph>();
expect(painted, isNotEmpty, reason: 'nothing painted: the guard saw no UI');
expect(chromeWords(tester, content: content), isEmpty);
}

View File

@ -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<void>.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);
});
}
}

View File

@ -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<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump();
expectTextFree(tester);
});
});
}