Guard app screens and wrapper against words

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-09 18:11:23 +01:00
parent 43e4b4070d
commit ff12f4e171
3 changed files with 245 additions and 0 deletions

View File

@ -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('<html></html>');
// 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');
});
}
}

View File

@ -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<void> 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<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
Future<void> logOne() => LogStorageService.instance.logMeal(
_food,
const Nutrition(
kcal: 350,
proteinG: 12,
carbsG: 50,
fatG: 8,
grams: 300,
source: 'manual',
),
);
final screens = <String, Widget Function()>{
'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<void>.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');
});
});
}

View File

@ -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<String> 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<String> paintedStrings(WidgetTester tester) => [
for (final w in tester.widgetList<RichText>(find.byType(RichText)))
w.text.toPlainText(includePlaceholders: false),
for (final w in tester.widgetList<Tooltip>(find.byType(Tooltip))) ?w.message,
];
/// The words in [text] that are neither units nor part of [content].
List<String> wordsIn(String text, {Set<String> 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<String> wordyStrings(
WidgetTester tester, {
Set<String> 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<String> content = const {},
int minStrings = 1,
String where = '',
}) => expect(
wordyStrings(tester, content: content, minStrings: minStrings, where: where),
isEmpty,
reason: '$where: painted words',
);