mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
Guard app screens and wrapper against words
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
43e4b4070d
commit
ff12f4e171
73
app/test/desktop/desktop_wrapper_no_text_test.dart
Normal file
73
app/test/desktop/desktop_wrapper_no_text_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
103
app/test/no_words_screens_test.dart
Normal file
103
app/test/no_words_screens_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
69
app/test/no_words_support.dart
Normal file
69
app/test/no_words_support.dart
Normal 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',
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user