mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 17:13:59 +01:00
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:
parent
2c52408cec
commit
71a35d6aa1
58
stronglift_replacement/workout_app/test/text_free_guard.dart
Normal file
58
stronglift_replacement/workout_app/test/text_free_guard.dart
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user