From ff12f4e171c765ef99cb73999437a1a694c3fafe Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 9 Nov 2026 18:11:23 +0100 Subject: [PATCH] Guard app screens and wrapper against words Co-Authored-By: Claude Opus 5.5 --- .../desktop/desktop_wrapper_no_text_test.dart | 73 +++++++++++++ app/test/no_words_screens_test.dart | 103 ++++++++++++++++++ app/test/no_words_support.dart | 69 ++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 app/test/desktop/desktop_wrapper_no_text_test.dart create mode 100644 app/test/no_words_screens_test.dart create mode 100644 app/test/no_words_support.dart diff --git a/app/test/desktop/desktop_wrapper_no_text_test.dart b/app/test/desktop/desktop_wrapper_no_text_test.dart new file mode 100644 index 0000000..c1c05ae --- /dev/null +++ b/app/test/desktop/desktop_wrapper_no_text_test.dart @@ -0,0 +1,73 @@ +// Guard: whatever the desktop wrapper answers with an error status reaches +// the Chrome --app window as a status code with an EMPTY body -- never an +// error sentence painted in place of the app. Terminal stderr lines (the +// launcher's own log) are not covered: they are terminal text and stay. +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/desktop/github_proxy.dart'; +import 'package:diet_guard_app/desktop/wrapper_server.dart'; +import 'package:diet_guard_app/services/desktop_wrapper.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tempDir; + late WrapperServer server; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_no_text_'); + final webRoot = p.join(tempDir.path, 'web'); + final dataDir = p.join(tempDir.path, 'data'); + await Directory(webRoot).create(recursive: true); + File(p.join(webRoot, 'index.html')).writeAsStringSync(''); + // A FILE where the documents directory belongs: a POST then throws + // inside the handler, which is the wrapper's 500 path. + await Directory(dataDir).create(recursive: true); + File(p.join(dataDir, 'documents')).writeAsStringSync(''); + server = WrapperServer( + webRoot: webRoot, + dataDir: dataDir, + gitHubProxy: GitHubProxy( + tokenPath: p.join(tempDir.path, 'sync_token'), + fallbackTokenPath: p.join(tempDir.path, 'fallback_token'), + httpClient: MockClient((_) async => http.Response('{}', 200)), + ), + ); + await server.start(0); + }); + + tearDown(() async { + await server.stop(); + await tempDir.delete(recursive: true); + }); + + Uri url(String path) => Uri.parse('http://localhost:${server.port}$path'); + + // (method, path, expected status): every error route the wrapper owns. + final cases = <(String, String, int)>[ + ('GET', '/missing.js', HttpStatus.notFound), + ('GET', '/github/nope', HttpStatus.notFound), + ('GET', WrapperPaths.documents, HttpStatus.badRequest), + ('PUT', '${WrapperPaths.documents}log.json', HttpStatus.methodNotAllowed), + ('GET', '${WrapperPaths.documents}absent.json', HttpStatus.notFound), + ('GET', kSyncAccountPath, HttpStatus.notFound), + ( + 'POST', + '${WrapperPaths.documents}log.json', + HttpStatus.internalServerError, + ), + ]; + + for (final (method, path, status) in cases) { + test('$method $path answers $status with an empty body', () async { + final response = await http.Response.fromStream( + await http.Client().send(http.Request(method, url(path))), + ); + expect(response.statusCode, status); + expect(response.body, isEmpty, reason: 'nothing painted in the window'); + }); + } +} diff --git a/app/test/no_words_screens_test.dart b/app/test/no_words_screens_test.dart new file mode 100644 index 0000000..8a6bd4d --- /dev/null +++ b/app/test/no_words_screens_test.dart @@ -0,0 +1,103 @@ +// Guard: the log-meal, history, calendar and settings screens paint no +// words; only the logged food name (content), numbers and unit symbols. +// +// Date/time pickers are framework chrome and are not opened here: they are a +// follow-up task (custom icon pickers), listed in the rework report. + +import 'dart:io'; + +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/screens/calendar_screen.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/screens/log_meal_screen.dart'; +import 'package:diet_guard_app/screens/settings_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/meal_schedule_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'fake_secure_storage.dart'; +import 'label_finders.dart'; +import 'no_words_support.dart'; + +/// The one meal logged in setUp: user content, allowed. +const _food = 'porridge'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_no_words_'); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); + await MealScheduleService.initForTesting(FileDocumentStore(tempDir)); + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + AppSettingsService.resetForTesting(); + BudgetHistoryService.resetForTesting(); + MealScheduleService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + Future show(WidgetTester tester, Widget screen) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: screen)); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + } + + Future logOne() => LogStorageService.instance.logMeal( + _food, + const Nutrition( + kcal: 350, + proteinG: 12, + carbsG: 50, + fatG: 8, + grams: 300, + source: 'manual', + ), + ); + + final screens = { + 'log meal': LogMealScreen.new, + 'history': HistoryScreen.new, + 'calendar': CalendarScreen.new, + 'settings': SettingsScreen.new, + }; + for (final MapEntry(key: name, value: build) in screens.entries) { + testWidgets('$name paints no words', (tester) async { + await tester.runAsync(() async { + await logOne(); + await show(tester, build()); + expectNoWords(tester, content: {_food}, minStrings: 3, where: name); + }); + }); + } + + testWidgets('a rejected log paints a cue, not a sentence', (tester) async { + await tester.runAsync(() async { + await show(tester, const LogMealScreen()); + final log = controlByLabel('Log meal'); + await tester.ensureVisible(log); + await tester.tap(log); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(const Duration(seconds: 1)); + expect(cueLabelled('Type what you ate first.'), findsOneWidget); + expectNoWords(tester, minStrings: 3, where: 'rejected log'); + }); + }); +} diff --git a/app/test/no_words_support.dart b/app/test/no_words_support.dart new file mode 100644 index 0000000..a2453e1 --- /dev/null +++ b/app/test/no_words_support.dart @@ -0,0 +1,69 @@ +/// The "no words" guard: collects every string the current frame paints (all +/// [RichText], which every [Text] builds, plus [Tooltip] messages, which paint +/// on long-press) and reports the ones that hold a word. +/// +/// A word is two or more letters in a row. Allowed without listing: numbers, +/// punctuation, single letters, unit symbols next to a figure ([unitTokens]) +/// and the method acronyms kept as DOUBT rows (`MET`). Content (food names, +/// workout labels, typed values) is passed per test as [content]. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Unit symbols and method acronyms that may sit next to a number. +const Set unitTokens = {'kcal', 'kg', 'cm', 'lb', 'st', 'MET'}; + +final RegExp _word = RegExp('[A-Za-z]{2,}'); + +/// Every painted string in the current tree (one per [RichText] / tooltip). +List paintedStrings(WidgetTester tester) => [ + for (final w in tester.widgetList(find.byType(RichText))) + w.text.toPlainText(includePlaceholders: false), + for (final w in tester.widgetList(find.byType(Tooltip))) ?w.message, +]; + +/// The words in [text] that are neither units nor part of [content]. +List wordsIn(String text, {Set content = const {}}) { + var rest = text; + for (final c in content) { + rest = rest.replaceAll(c, ' '); + } + return [ + for (final m in _word.allMatches(rest)) + if (!unitTokens.contains(m.group(0))) m.group(0)!, + ]; +} + +/// The painted strings of the current frame that hold a word, prefixed with +/// [where]. Fails when fewer than [minStrings] strings were found (an empty +/// tree must not pass the guard). +List wordyStrings( + WidgetTester tester, { + Set content = const {}, + int minStrings = 1, + String where = '', +}) { + final strings = paintedStrings(tester); + expect( + strings.length, + greaterThanOrEqualTo(minStrings), + reason: '$where: guard saw too few painted strings (fail closed)', + ); + return [ + for (final s in strings) + if (wordsIn(s, content: content).isNotEmpty) '$where: $s', + ]; +} + +/// Fails when a painted string of the current frame holds a word. +void expectNoWords( + WidgetTester tester, { + Set content = const {}, + int minStrings = 1, + String where = '', +}) => expect( + wordyStrings(tester, content: content, minStrings: minStrings, where: where), + isEmpty, + reason: '$where: painted words', +);