Split the remaining seven Dart test files

Eleventh batch of the 250-line sweep (over-cap count 12 -> 5). Every test file
in the repo, Python and Dart, is now under cap; the five left are all in
app/lib/.

  wrapper_server_test.dart        349 -> 145  (+ proxy, account siblings)
  app_settings_service_test.dart  321 -> 162  (+ sync sibling)
  average_service_test.dart       299 -> 158  (+ periods sibling)
  settings_screen_test.dart       274 -> 189  (+ battery sibling)
  day_status_service_test.dart    270 -> 161  (+ streaks sibling)
  edit_entry_screen_test.dart     269 -> 159  (+ foodbank sibling)
  day_status_calendar_test.dart   256 -> 237  (+ _test_support sibling)

day_status_calendar_test is a single group whose tests close over group-local
state, so it could not be split by block. Its ~25 lines of file-level fixtures
moved to `day_status_calendar_test_support.dart` instead, which required the
same publicisation as the sync support file (`_theme` -> `appTheme`, `_wrap` ->
`wrapInApp`, `_colorOfDay` -> `colorOfDay`) with doc comments.

Block counts were compared against HEAD per family rather than trusting the
suite total, since the duplication bug in the previous batch passed green at an
inflated count. All seven families match exactly (29/26/34/11/34/8/14).

Verified: flutter test 555 (unchanged), flutter analyze clean, pre-commit
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 20:53:55 +02:00
parent cc78dd383b
commit c0ed6154ac
16 changed files with 1199 additions and 864 deletions

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.24+1
version: 1.0.25+1
environment:
sdk: ^3.12.2

View File

@ -0,0 +1,121 @@
// The desktop wrapper is the other half of the web-build desktop app: it
// serves the assets, owns the on-disk mirror of the app's storage, and fronts
// GitHub. These tests drive it over a real loopback socket, since its
// contract is HTTP.
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: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 String webRoot;
late String dataDir;
late WrapperServer server;
late List<http.Request> outbound;
late Map<String, http.Response> canned;
Future<void> startServer({
bool serveSyncAccount = false,
String? syncConfigDir,
}) async {
outbound = [];
canned = {};
final proxy = GitHubProxy(
tokenPath: p.join(tempDir.path, 'config', 'sync_token'),
fallbackTokenPath: p.join(tempDir.path, 'config', 'fallback_token'),
httpClient: MockClient((request) async {
outbound.add(request);
return canned[request.url.toString()] ??
http.Response('{"ok":true}', 200);
}),
);
server = WrapperServer(
webRoot: webRoot,
dataDir: dataDir,
gitHubProxy: proxy,
serveSyncAccount: serveSyncAccount,
syncConfigDir: syncConfigDir,
);
await server.start(0);
}
Uri url(String path) => Uri.parse('http://localhost:${server.port}$path');
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_wrapper_');
webRoot = p.join(tempDir.path, 'web');
dataDir = p.join(tempDir.path, 'data');
await Directory(webRoot).create(recursive: true);
File(p.join(webRoot, 'index.html')).writeAsStringSync('<html>app</html>');
File(p.join(webRoot, 'main.wasm')).writeAsBytesSync([0, 97, 115, 109]);
await startServer();
});
tearDown(() async {
await server.stop();
await tempDir.delete(recursive: true);
});
group('sync-account provisioning', () {
/// Writes [files] into a temp config dir and restarts with the route on.
Future<String> enableWith(Map<String, String> files) async {
final configDir = Directory(p.join(tempDir.path, 'crdt-sync'))
..createSync(recursive: true);
files.forEach((name, contents) {
File(p.join(configDir.path, name)).writeAsStringSync(contents);
});
await server.stop();
await startServer(serveSyncAccount: true, syncConfigDir: configDir.path);
return 'http://localhost:${server.port}';
}
test('is 404 when not enabled', () async {
// The default, and the whole security posture: a credential route must
// not be reachable just because the wrapper is running.
final response = await http.get(url(kSyncAccountPath));
expect(response.statusCode, HttpStatus.notFound);
});
test('serves the account when enabled', () async {
final origin = await enableWith({
'firebase.json': '{"email":"a@b.c"}',
'password': 'pw\n',
});
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
final account = FirebaseAccount.tryParse(response.body);
expect(response.statusCode, HttpStatus.ok);
expect(account?.email, 'a@b.c');
expect(account?.password, 'pw');
});
test('is 404 when the config files are absent', () async {
final origin = await enableWith({});
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
expect(response.statusCode, HttpStatus.notFound);
});
test('is 404 when firebase.json has no usable email', () async {
final origin = await enableWith({
'firebase.json': '{"apiKey":"x"}',
'password': 'pw',
});
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
expect(response.statusCode, HttpStatus.notFound);
});
});
}

View File

@ -0,0 +1,214 @@
// The desktop wrapper is the other half of the web-build desktop app: it
// serves the assets, owns the on-disk mirror of the app's storage, and fronts
// GitHub. These tests drive it over a real loopback socket, since its
// contract is HTTP.
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/desktop/github_proxy.dart';
import 'package:diet_guard_app/desktop/wrapper_server.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 String webRoot;
late String dataDir;
late WrapperServer server;
late List<http.Request> outbound;
late Map<String, http.Response> canned;
Future<void> startServer({
bool serveSyncAccount = false,
String? syncConfigDir,
}) async {
outbound = [];
canned = {};
final proxy = GitHubProxy(
tokenPath: p.join(tempDir.path, 'config', 'sync_token'),
fallbackTokenPath: p.join(tempDir.path, 'config', 'fallback_token'),
httpClient: MockClient((request) async {
outbound.add(request);
return canned[request.url.toString()] ??
http.Response('{"ok":true}', 200);
}),
);
server = WrapperServer(
webRoot: webRoot,
dataDir: dataDir,
gitHubProxy: proxy,
serveSyncAccount: serveSyncAccount,
syncConfigDir: syncConfigDir,
);
await server.start(0);
}
Uri url(String path) => Uri.parse('http://localhost:${server.port}$path');
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_wrapper_');
webRoot = p.join(tempDir.path, 'web');
dataDir = p.join(tempDir.path, 'data');
await Directory(webRoot).create(recursive: true);
File(p.join(webRoot, 'index.html')).writeAsStringSync('<html>app</html>');
File(p.join(webRoot, 'main.wasm')).writeAsBytesSync([0, 97, 115, 109]);
await startServer();
});
tearDown(() async {
await server.stop();
await tempDir.delete(recursive: true);
});
group('github proxy', () {
test('reports no token before one is stored', () async {
final response = await http.get(url('/github/auth/status'));
expect(jsonDecode(response.body), {'configured': false});
});
test('stores a pasted token and then reports configured', () async {
await http.post(url('/github/auth/token'), body: 'ghp_pasted');
final response = await http.get(url('/github/auth/status'));
expect(jsonDecode(response.body), {'configured': true});
expect(
File(p.join(tempDir.path, 'config', 'sync_token')).readAsStringSync(),
'ghp_pasted',
);
});
test(
'falls back to the PC gate token when it has none of its own',
() async {
// The Python side already keeps a token for diet-guard-sync.timer;
// reusing it is what makes the desktop app need no second setup.
final fallback = File(p.join(tempDir.path, 'config', 'fallback_token'))
..createSync(recursive: true)
..writeAsStringSync('ghp_from_gate\n');
addTearDown(fallback.deleteSync);
final response = await http.get(url('/github/auth/status'));
expect(jsonDecode(response.body), {'configured': true});
},
);
test('attaches the token to a proxied API call', () async {
await http.post(url('/github/auth/token'), body: 'ghp_stored');
canned['https://api.github.com/repos/kuhyx/syncs'] = http.Response(
'{"name":"syncs"}',
200,
);
final response = await http.get(url('/github/api/repos/kuhyx/syncs'));
expect(response.body, '{"name":"syncs"}');
expect(outbound.single.headers['Authorization'], 'Bearer ghp_stored');
});
test('keeps a device-flow token server-side', () async {
canned['https://github.com/login/oauth/access_token'] = http.Response(
'{"access_token":"ghp_device"}',
200,
);
final response = await http.post(
url('/github/auth/device/poll'),
body: jsonEncode({'client_id': 'cid', 'device_code': 'dc'}),
);
// The point of the proxy: the browser is told "ok", never the token.
expect(jsonDecode(response.body), {'status': 'ok'});
expect(response.body, isNot(contains('ghp_device')));
expect(
File(p.join(tempDir.path, 'config', 'sync_token')).readAsStringSync(),
'ghp_device',
);
});
test('passes a still-pending device-flow poll straight back', () async {
canned['https://github.com/login/oauth/access_token'] = http.Response(
'{"error":"authorization_pending"}',
200,
);
final response = await http.post(
url('/github/auth/device/poll'),
body: jsonEncode({'client_id': 'cid', 'device_code': 'dc'}),
);
expect(jsonDecode(response.body), {'error': 'authorization_pending'});
});
test('forwards the device-code request with the client id', () async {
canned['https://github.com/login/device/code'] = http.Response(
'{"device_code":"dc","user_code":"ABCD-1234"}',
200,
);
final response = await http.post(
url('/github/auth/device/start'),
body: jsonEncode({'client_id': 'cid'}),
);
expect(jsonDecode(response.body), containsPair('user_code', 'ABCD-1234'));
expect(outbound.single.bodyFields['client_id'], 'cid');
});
test('404s an unknown /github path', () async {
expect((await http.get(url('/github/nope'))).statusCode, 404);
});
test('clears the stored token when handed an empty one', () async {
await http.post(url('/github/auth/token'), body: 'ghp_stored');
await http.post(url('/github/auth/token'), body: '');
expect(jsonDecode((await http.get(url('/github/auth/status'))).body), {
'configured': false,
});
});
test('ignores an unreadable token file rather than failing', () async {
// A directory where the token file should be: readToken must fall
// through to "no token" instead of throwing out of every request.
Directory(
p.join(tempDir.path, 'config', 'sync_token'),
).createSync(recursive: true);
expect(jsonDecode((await http.get(url('/github/auth/status'))).body), {
'configured': false,
});
});
test('passes a non-JSON device-flow response straight back', () async {
canned['https://github.com/login/oauth/access_token'] = http.Response(
'<html>rate limited</html>',
200,
);
final response = await http.post(
url('/github/auth/device/poll'),
body: jsonEncode({'client_id': 'cid', 'device_code': 'dc'}),
);
expect(response.body, contains('rate limited'));
});
test('treats a malformed request body as empty fields', () async {
final response = await http.post(
url('/github/auth/device/start'),
body: 'not json',
);
expect(response.statusCode, 200);
expect(outbound.single.bodyFields['client_id'], 'null');
});
});
}

View File

@ -14,6 +14,7 @@ import 'package:http/testing.dart';
import 'package:path/path.dart' as p;
void main() {
late Directory tempDir;
late String webRoot;
late String dataDir;
@ -85,7 +86,6 @@ void main() {
expect((await http.get(url('/nope.js'))).statusCode, 404);
});
});
group('document mirror', () {
test('round-trips a document', () async {
final posted = await http.post(
@ -124,155 +124,6 @@ void main() {
expect((await http.delete(url('/documents/x.json'))).statusCode, 405);
});
});
group('github proxy', () {
test('reports no token before one is stored', () async {
final response = await http.get(url('/github/auth/status'));
expect(jsonDecode(response.body), {'configured': false});
});
test('stores a pasted token and then reports configured', () async {
await http.post(url('/github/auth/token'), body: 'ghp_pasted');
final response = await http.get(url('/github/auth/status'));
expect(jsonDecode(response.body), {'configured': true});
expect(
File(p.join(tempDir.path, 'config', 'sync_token')).readAsStringSync(),
'ghp_pasted',
);
});
test(
'falls back to the PC gate token when it has none of its own',
() async {
// The Python side already keeps a token for diet-guard-sync.timer;
// reusing it is what makes the desktop app need no second setup.
final fallback = File(p.join(tempDir.path, 'config', 'fallback_token'))
..createSync(recursive: true)
..writeAsStringSync('ghp_from_gate\n');
addTearDown(fallback.deleteSync);
final response = await http.get(url('/github/auth/status'));
expect(jsonDecode(response.body), {'configured': true});
},
);
test('attaches the token to a proxied API call', () async {
await http.post(url('/github/auth/token'), body: 'ghp_stored');
canned['https://api.github.com/repos/kuhyx/syncs'] = http.Response(
'{"name":"syncs"}',
200,
);
final response = await http.get(url('/github/api/repos/kuhyx/syncs'));
expect(response.body, '{"name":"syncs"}');
expect(outbound.single.headers['Authorization'], 'Bearer ghp_stored');
});
test('keeps a device-flow token server-side', () async {
canned['https://github.com/login/oauth/access_token'] = http.Response(
'{"access_token":"ghp_device"}',
200,
);
final response = await http.post(
url('/github/auth/device/poll'),
body: jsonEncode({'client_id': 'cid', 'device_code': 'dc'}),
);
// The point of the proxy: the browser is told "ok", never the token.
expect(jsonDecode(response.body), {'status': 'ok'});
expect(response.body, isNot(contains('ghp_device')));
expect(
File(p.join(tempDir.path, 'config', 'sync_token')).readAsStringSync(),
'ghp_device',
);
});
test('passes a still-pending device-flow poll straight back', () async {
canned['https://github.com/login/oauth/access_token'] = http.Response(
'{"error":"authorization_pending"}',
200,
);
final response = await http.post(
url('/github/auth/device/poll'),
body: jsonEncode({'client_id': 'cid', 'device_code': 'dc'}),
);
expect(jsonDecode(response.body), {'error': 'authorization_pending'});
});
test('forwards the device-code request with the client id', () async {
canned['https://github.com/login/device/code'] = http.Response(
'{"device_code":"dc","user_code":"ABCD-1234"}',
200,
);
final response = await http.post(
url('/github/auth/device/start'),
body: jsonEncode({'client_id': 'cid'}),
);
expect(jsonDecode(response.body), containsPair('user_code', 'ABCD-1234'));
expect(outbound.single.bodyFields['client_id'], 'cid');
});
test('404s an unknown /github path', () async {
expect((await http.get(url('/github/nope'))).statusCode, 404);
});
test('clears the stored token when handed an empty one', () async {
await http.post(url('/github/auth/token'), body: 'ghp_stored');
await http.post(url('/github/auth/token'), body: '');
expect(jsonDecode((await http.get(url('/github/auth/status'))).body), {
'configured': false,
});
});
test('ignores an unreadable token file rather than failing', () async {
// A directory where the token file should be: readToken must fall
// through to "no token" instead of throwing out of every request.
Directory(
p.join(tempDir.path, 'config', 'sync_token'),
).createSync(recursive: true);
expect(jsonDecode((await http.get(url('/github/auth/status'))).body), {
'configured': false,
});
});
test('passes a non-JSON device-flow response straight back', () async {
canned['https://github.com/login/oauth/access_token'] = http.Response(
'<html>rate limited</html>',
200,
);
final response = await http.post(
url('/github/auth/device/poll'),
body: jsonEncode({'client_id': 'cid', 'device_code': 'dc'}),
);
expect(response.body, contains('rate limited'));
});
test('treats a malformed request body as empty fields', () async {
final response = await http.post(
url('/github/auth/device/start'),
body: 'not json',
);
expect(response.statusCode, 200);
expect(outbound.single.bodyFields['client_id'], 'null');
});
});
group('content types', () {
test('labels each asset kind the browser is strict about', () {
ContentType typeOf(String name) =>
@ -291,59 +142,4 @@ void main() {
expect(typeOf('data.bin').mimeType, 'application/octet-stream');
});
});
group('sync-account provisioning', () {
/// Writes [files] into a temp config dir and restarts with the route on.
Future<String> enableWith(Map<String, String> files) async {
final configDir = Directory(p.join(tempDir.path, 'crdt-sync'))
..createSync(recursive: true);
files.forEach((name, contents) {
File(p.join(configDir.path, name)).writeAsStringSync(contents);
});
await server.stop();
await startServer(serveSyncAccount: true, syncConfigDir: configDir.path);
return 'http://localhost:${server.port}';
}
test('is 404 when not enabled', () async {
// The default, and the whole security posture: a credential route must
// not be reachable just because the wrapper is running.
final response = await http.get(url(kSyncAccountPath));
expect(response.statusCode, HttpStatus.notFound);
});
test('serves the account when enabled', () async {
final origin = await enableWith({
'firebase.json': '{"email":"a@b.c"}',
'password': 'pw\n',
});
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
final account = FirebaseAccount.tryParse(response.body);
expect(response.statusCode, HttpStatus.ok);
expect(account?.email, 'a@b.c');
expect(account?.password, 'pw');
});
test('is 404 when the config files are absent', () async {
final origin = await enableWith({});
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
expect(response.statusCode, HttpStatus.notFound);
});
test('is 404 when firebase.json has no usable email', () async {
final origin = await enableWith({
'firebase.json': '{"apiKey":"x"}',
'password': 'pw',
});
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
expect(response.statusCode, HttpStatus.notFound);
});
});
}

View File

@ -0,0 +1,150 @@
import 'dart:io';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/edit_entry_screen.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
const _entry = FoodEntry(
id: 'test-uuid',
time: '2026-06-22T12:00:00+02:00',
desc: 'pizza obok pracy',
grams: 400,
kcal: 1200,
proteinG: 52,
carbsG: 145,
fatG: 42,
source: 'manual',
);
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_edit_test_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
await tempDir.delete(recursive: true);
});
Future<void> settle(WidgetTester tester) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
testWidgets('selecting a food bank suggestion fills all macro fields', (
tester,
) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'pizza obok pracy',
kcal: 1200,
proteinG: 52,
carbsG: 145,
fatG: 42,
grams: 400,
count: 1,
),
);
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// The desc field already matches the bank entry — a suggestion appears.
expect(find.text('pizza obok pracy'), findsWidgets);
// Tap the suggestion tile (the one in the autocomplete list, not the
// TextField itself).
final suggestionTiles = find.text('pizza obok pracy');
// At least 2 matches: TextField text + suggestion tile.
expect(suggestionTiles, findsWidgets);
await tester.tap(suggestionTiles.last);
await settle(tester);
// After selection, suggestions are cleared and macros are filled.
expect(find.text('1200'), findsOneWidget);
});
});
testWidgets(
'editing a macro after selecting a suggestion resets source to manual',
(tester) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'pizza obok pracy',
kcal: 1200,
proteinG: 52,
carbsG: 145,
fatG: 42,
grams: 400,
count: 1,
),
);
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// Select the suggestion to set _source = 'food bank'.
final suggestionTiles = find.text('pizza obok pracy');
await tester.tap(suggestionTiles.last);
await settle(tester);
// Manually edit kcal — should flip _source back to 'manual'.
await tester.enterText(
find.ancestor(
of: find.text('1200'),
matching: find.byType(TextField),
),
'999',
);
await settle(tester);
await tester.tap(find.text('Save'));
await settle(tester);
final log = await LogStorageService.instance.readLog();
// Source reverts to 'manual' after macro edit.
expect(log['2026-06-22']!.first.source, 'manual');
});
},
);
testWidgets('tapping an entry tile navigates to EditEntryScreen', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.text('pizza obok pracy'));
await settle(tester);
expect(find.text('Edit meal'), findsOneWidget);
expect(find.text('pizza obok pracy'), findsOneWidget);
// Macros are pre-filled.
expect(find.text('1200'), findsOneWidget);
});
});
}

View File

@ -23,6 +23,7 @@ const _entry = FoodEntry(
);
void main() {
late Directory tempDir;
setUp(() async {
@ -60,7 +61,6 @@ void main() {
expect(find.text('400'), findsOneWidget);
});
});
testWidgets('Save button is present and AppBar title is correct', (
tester,
) async {
@ -76,7 +76,6 @@ void main() {
expect(find.text('Save'), findsOneWidget);
});
});
testWidgets('shows error when description is cleared', (tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
@ -99,7 +98,6 @@ void main() {
expect(find.text('Description cannot be empty.'), findsOneWidget);
});
});
testWidgets('Save persists updated kcal to the log', (tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
@ -128,7 +126,6 @@ void main() {
expect(saved.id, 'test-uuid');
});
});
testWidgets('legacy null-id entry gains a UUID on save', (tester) async {
await tester.runAsync(() async {
const legacy = FoodEntry(
@ -159,111 +156,4 @@ void main() {
expect(saved.kcal, 1174);
});
});
testWidgets('selecting a food bank suggestion fills all macro fields', (
tester,
) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'pizza obok pracy',
kcal: 1200,
proteinG: 52,
carbsG: 145,
fatG: 42,
grams: 400,
count: 1,
),
);
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// The desc field already matches the bank entry — a suggestion appears.
expect(find.text('pizza obok pracy'), findsWidgets);
// Tap the suggestion tile (the one in the autocomplete list, not the
// TextField itself).
final suggestionTiles = find.text('pizza obok pracy');
// At least 2 matches: TextField text + suggestion tile.
expect(suggestionTiles, findsWidgets);
await tester.tap(suggestionTiles.last);
await settle(tester);
// After selection, suggestions are cleared and macros are filled.
expect(find.text('1200'), findsOneWidget);
});
});
testWidgets(
'editing a macro after selecting a suggestion resets source to manual',
(tester) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'pizza obok pracy',
kcal: 1200,
proteinG: 52,
carbsG: 145,
fatG: 42,
grams: 400,
count: 1,
),
);
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// Select the suggestion to set _source = 'food bank'.
final suggestionTiles = find.text('pizza obok pracy');
await tester.tap(suggestionTiles.last);
await settle(tester);
// Manually edit kcal — should flip _source back to 'manual'.
await tester.enterText(
find.ancestor(
of: find.text('1200'),
matching: find.byType(TextField),
),
'999',
);
await settle(tester);
await tester.tap(find.text('Save'));
await settle(tester);
final log = await LogStorageService.instance.readLog();
// Source reverts to 'manual' after macro edit.
expect(log['2026-06-22']!.first.source, 'manual');
});
},
);
testWidgets('tapping an entry tile navigates to EditEntryScreen', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.text('pizza obok pracy'));
await settle(tester);
expect(find.text('Edit meal'), findsOneWidget);
expect(find.text('pizza obok pracy'), findsOneWidget);
// Macros are pre-filled.
expect(find.text('1200'), findsOneWidget);
});
});
}

View File

@ -0,0 +1,134 @@
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/document_store_io.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/firebase_backend.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../fake_secure_storage.dart';
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_settings_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});
// SettingsScreen loads its settings via real (non-fake-clocked) I/O
// through FileDocumentStore, so every test body runs under
// tester.runAsync() -- without it, those Futures never get a chance to
// complete inside TestWidgetsFlutterBinding's fake-clocked zone, and
// pumpAndSettle spins forever. Also grows the test viewport: the
// Notifications section pushes earlier fields/buttons below the default
// 800x600 fold, making them unreachable to tester.tap otherwise.
Future<void> settle(WidgetTester tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
testWidgets('battery exemption button reports a granted status', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: SettingsScreen(
requestBatteryExemption: () async => PermissionStatus.granted,
),
),
);
await settle(tester);
await tester.tap(find.text('Disable battery optimization'));
await settle(tester);
expect(find.textContaining('exemption granted'), findsOneWidget);
});
});
testWidgets('battery exemption button reports a denied status', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: SettingsScreen(
requestBatteryExemption: () async => PermissionStatus.denied,
),
),
);
await settle(tester);
await tester.tap(find.text('Disable battery optimization'));
await settle(tester);
expect(find.textContaining('not granted'), findsOneWidget);
});
});
testWidgets('battery exemption defaults to the real permission_handler '
'call, which fails predictably under test', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
await tester.tap(find.text('Disable battery optimization'));
await settle(tester);
expect(
find.textContaining('Could not request exemption'),
findsOneWidget,
);
});
});
testWidgets('battery exemption button surfaces a request failure', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: SettingsScreen(
requestBatteryExemption: () async =>
throw Exception('no permission service'),
),
),
);
await settle(tester);
await tester.tap(find.text('Disable battery optimization'));
await settle(tester);
expect(
find.textContaining('Could not request exemption'),
findsOneWidget,
);
});
});
}

View File

@ -16,6 +16,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../fake_secure_storage.dart';
void main() {
late Directory tempDir;
setUp(() async {
@ -63,7 +64,6 @@ void main() {
expect(find.widgetWithText(TextField, '1800'), findsOneWidget);
});
});
testWidgets('typing a kcal goal debounces the save', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
@ -81,7 +81,6 @@ void main() {
expect(AppSettingsService.dailyKcalGoal, 2100);
});
});
testWidgets('an invalid kcal goal is not saved', (tester) async {
await tester.runAsync(() async {
final initial = AppSettingsService.dailyKcalGoal;
@ -98,7 +97,6 @@ void main() {
expect(AppSettingsService.dailyKcalGoal, initial);
});
});
testWidgets('disposing mid-debounce still flushes the pending goal', (
tester,
) async {
@ -122,7 +120,6 @@ void main() {
expect(AppSettingsService.dailyKcalGoal, 1950);
});
});
testWidgets('tapping Sync settings opens the shared SyncSettingsScreen', (
tester,
) async {
@ -144,7 +141,6 @@ void main() {
expect(find.text('Firebase sync'), findsOneWidget);
});
});
testWidgets(
'Sync settings wires storedAccount, not loadAccount, as the '
'default accountLoader',
@ -175,7 +171,6 @@ void main() {
});
},
);
testWidgets(
'tapping Advanced sync (GitHub) opens the local GitHubMirrorScreen',
(tester) async {
@ -191,84 +186,4 @@ void main() {
});
},
);
testWidgets('battery exemption button reports a granted status', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: SettingsScreen(
requestBatteryExemption: () async => PermissionStatus.granted,
),
),
);
await settle(tester);
await tester.tap(find.text('Disable battery optimization'));
await settle(tester);
expect(find.textContaining('exemption granted'), findsOneWidget);
});
});
testWidgets('battery exemption button reports a denied status', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: SettingsScreen(
requestBatteryExemption: () async => PermissionStatus.denied,
),
),
);
await settle(tester);
await tester.tap(find.text('Disable battery optimization'));
await settle(tester);
expect(find.textContaining('not granted'), findsOneWidget);
});
});
testWidgets('battery exemption defaults to the real permission_handler '
'call, which fails predictably under test', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
await tester.tap(find.text('Disable battery optimization'));
await settle(tester);
expect(
find.textContaining('Could not request exemption'),
findsOneWidget,
);
});
});
testWidgets('battery exemption button surfaces a request failure', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: SettingsScreen(
requestBatteryExemption: () async =>
throw Exception('no permission service'),
),
),
);
await settle(tester);
await tester.tap(find.text('Disable battery optimization'));
await settle(tester);
expect(
find.textContaining('Could not request exemption'),
findsOneWidget,
);
});
});
}

View File

@ -0,0 +1,182 @@
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp(
'diet_guard_settings_test_',
);
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
});
tearDown(() async {
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});
group('applySyncedBudget', () {
test(
'persists the given value and updatedAt verbatim, not "now"',
() async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
final winningEdit = DateTime.utc(2020);
await AppSettingsService.instance.applySyncedBudget(
1700,
updatedAt: winningEdit,
);
expect(AppSettingsService.dailyKcalGoal, 1700);
expect(AppSettingsService.dailyKcalGoalUpdatedAt, winningEdit);
final raw = await File(
'${tempDir.path}/app_settings.json',
).readAsString();
final data = jsonDecode(raw) as Map;
expect(data['daily_kcal_goal'], 1700);
expect(
data['daily_kcal_goal_updated_at'],
winningEdit.toIso8601String(),
);
},
);
test('a null updatedAt is accepted and persisted as null', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
await AppSettingsService.instance.applySyncedBudget(1700);
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
});
});
group('initForTesting (_load paths)', () {
test('loads daily_kcal_goal from an existing file', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode({'daily_kcal_goal': 1600}));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 1600);
});
test('keeps default 2200 when file does not exist', () async {
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
test('keeps default 2200 on unparsable JSON', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString('not json at all');
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
test('keeps default 2200 when JSON root is not a Map', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode([1, 2, 3]));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
test('keeps default 2200 when daily_kcal_goal is not an int', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode({'daily_kcal_goal': 'two thousand'}));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
test('loads daily_kcal_goal_updated_at from an existing file', () async {
await File('${tempDir.path}/app_settings.json').writeAsString(
jsonEncode({
'daily_kcal_goal': 1600,
'daily_kcal_goal_updated_at': '2026-01-01T00:00:00.000Z',
}),
);
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(
AppSettingsService.dailyKcalGoalUpdatedAt,
DateTime.parse('2026-01-01T00:00:00.000Z'),
);
});
test(
'dailyKcalGoalUpdatedAt stays null when file has no such key',
() async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode({'daily_kcal_goal': 1600}));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
},
);
test(
'dailyKcalGoalUpdatedAt stays null when the field is not a string',
() async {
await File('${tempDir.path}/app_settings.json').writeAsString(
jsonEncode({
'daily_kcal_goal': 1600,
'daily_kcal_goal_updated_at': 12345,
}),
);
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
},
);
});
}

View File

@ -7,6 +7,7 @@ import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late Directory tempDir;
setUp(() async {
@ -29,7 +30,6 @@ void main() {
expect(AppSettingsService.dailyKcalGoal, 2200);
});
});
group('resetForTesting', () {
test('with testDir creates a working instance', () {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
@ -50,7 +50,6 @@ void main() {
expect(AppSettingsService.dailyKcalGoal, 2200);
});
});
group('init early-return', () {
test('returns existing instance without re-initialising', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
@ -63,7 +62,6 @@ void main() {
expect(identical(first, second), isTrue);
});
});
group('saveDailyKcalGoal', () {
test('updates in-memory value and persists to file', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
@ -161,161 +159,4 @@ void main() {
expect(updatedAt.isAfter(after), isFalse);
});
});
group('applySyncedBudget', () {
test(
'persists the given value and updatedAt verbatim, not "now"',
() async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
final winningEdit = DateTime.utc(2020);
await AppSettingsService.instance.applySyncedBudget(
1700,
updatedAt: winningEdit,
);
expect(AppSettingsService.dailyKcalGoal, 1700);
expect(AppSettingsService.dailyKcalGoalUpdatedAt, winningEdit);
final raw = await File(
'${tempDir.path}/app_settings.json',
).readAsString();
final data = jsonDecode(raw) as Map;
expect(data['daily_kcal_goal'], 1700);
expect(
data['daily_kcal_goal_updated_at'],
winningEdit.toIso8601String(),
);
},
);
test('a null updatedAt is accepted and persisted as null', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
await AppSettingsService.instance.applySyncedBudget(1700);
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
});
});
group('initForTesting (_load paths)', () {
test('loads daily_kcal_goal from an existing file', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode({'daily_kcal_goal': 1600}));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 1600);
});
test('keeps default 2200 when file does not exist', () async {
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
test('keeps default 2200 on unparsable JSON', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString('not json at all');
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
test('keeps default 2200 when JSON root is not a Map', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode([1, 2, 3]));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
test('keeps default 2200 when daily_kcal_goal is not an int', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode({'daily_kcal_goal': 'two thousand'}));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
test('loads daily_kcal_goal_updated_at from an existing file', () async {
await File('${tempDir.path}/app_settings.json').writeAsString(
jsonEncode({
'daily_kcal_goal': 1600,
'daily_kcal_goal_updated_at': '2026-01-01T00:00:00.000Z',
}),
);
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(
AppSettingsService.dailyKcalGoalUpdatedAt,
DateTime.parse('2026-01-01T00:00:00.000Z'),
);
});
test(
'dailyKcalGoalUpdatedAt stays null when file has no such key',
() async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode({'daily_kcal_goal': 1600}));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
},
);
test(
'dailyKcalGoalUpdatedAt stays null when the field is not a string',
() async {
await File('${tempDir.path}/app_settings.json').writeAsString(
jsonEncode({
'daily_kcal_goal': 1600,
'daily_kcal_goal_updated_at': 12345,
}),
);
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
},
);
});
}

View File

@ -0,0 +1,176 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/models/period_average.dart';
import 'package:diet_guard_app/services/average_service.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter_test/flutter_test.dart';
FoodEntry buildEntry({required double kcal, bool deleted = false}) => FoodEntry(
time: '2026-01-01T12:00:00',
desc: 'test',
grams: 100,
kcal: kcal,
proteinG: 0,
carbsG: 0,
fatG: 0,
source: 'manual',
deleted: deleted,
);
BudgetSchedule flat(int budget) => BudgetSchedule(
[BudgetEntry(effectiveFrom: kEpochDay, kcal: budget, editedAt: '')],
fallback: budget,
);
DayLog logOf(Map<String, double> kcalByDay) => {
for (final e in kcalByDay.entries) e.key: [buildEntry(kcal: e.value)],
};
void main() {
// Every other fixture in this file sits in a DST-free span, so none of them
// can see the bug these cover: stepping a local DateTime by
// Duration(days: 1) moves 23 or 25 hours across a DST boundary and lands on
// the wrong wall-clock day. Under TZ=Europe/Warsaw (spring-forward
// 2026-03-29) that made the phone and the PC report different averages for
// the same log. Assertions are on the calendar day, never on the DateTime,
// so they mean the same thing whatever zone the runner is in.
group('weeklyAverage', () {
test('the current week stops at yesterday', () {
final log = logOf({
'2026-01-05': 3000,
'2026-01-06': 3000,
'2026-01-07': 3000,
'2026-01-08': 100,
});
final result = weeklyAverage(
log,
schedule: flat(2000),
today: DateTime(2026, 1, 8),
);
expect(result.avgKcal, 3000);
expect(result.end, '2026-01-07');
expect(result.band, AverageBand.veryOver);
});
test('Monday has no complete days this week', () {
final result = weeklyAverage(
logOf({'2026-01-05': 3000}),
schedule: flat(2000),
today: DateTime(2026, 1, 5),
);
expect(result.elapsedDays, 0);
expect(result.avgKcal, isNull);
});
test('the previous week is whole', () {
final result = weeklyAverage(
logOf({'2026-01-05': 2100, '2026-01-11': 2100}),
schedule: flat(2000),
weeksAgo: 1,
today: DateTime(2026, 1, 14),
);
expect(result.start, '2026-01-05');
expect(result.end, '2026-01-11');
expect(result.elapsedDays, 7);
expect(result.band, AverageBand.slightlyOver);
});
});
group('monthlyAverage', () {
test('the current month stops at yesterday', () {
final log = logOf({
'2026-03-01': 1000,
'2026-03-02': 1000,
'2026-03-04': 9000,
});
final result = monthlyAverage(
log,
schedule: flat(2000),
today: DateTime(2026, 3, 4),
);
expect(result.avgKcal, 1000);
expect(result.end, '2026-03-03');
expect(result.band, AverageBand.under);
});
test('the first of the month has no complete days', () {
final result = monthlyAverage(
const {},
schedule: flat(2000),
today: DateTime(2026, 3),
);
expect(result.elapsedDays, 0);
});
test('the previous month is whole', () {
final result = monthlyAverage(
logOf({'2026-02-10': 2500}),
schedule: flat(2000),
monthsAgo: 1,
today: DateTime(2026, 3, 10),
);
expect(result.start, '2026-02-01');
expect(result.end, '2026-02-28');
expect(result.elapsedDays, 28);
expect(result.band, AverageBand.veryOver);
});
});
group('date arithmetic across a DST boundary', () {
test('lastCompleteDay is yesterday even across spring-forward', () {
final result = lastCompleteDay(DateTime(2026, 3, 30));
expect(
(result.year, result.month, result.day),
(2026, 3, 29),
reason: 'Duration-based arithmetic returned 2026-03-28 23:00 here',
);
});
test('a period spanning spring-forward keeps every day', () {
final result = periodAverage(
logOf({
'2026-03-27': 2000,
'2026-03-28': 2000,
'2026-03-29': 2000,
'2026-03-30': 2000,
'2026-03-31': 2000,
}),
schedule: flat(2000),
start: DateTime(2026, 3, 27),
end: DateTime(2026, 3, 31),
);
expect(result.elapsedDays, 5);
expect(
result.loggedDays,
5,
reason: 'the day-stepping loop used to drop 2026-03-31',
);
});
test('the weeksAgo anchor stays in the right ISO week', () {
// 2026-04-01 is a Wednesday; one week back is the Mon 03-23..Sun 03-29
// week, the one containing the clock change.
final result = weeklyAverage(
logOf({'2026-03-23': 2000, '2026-03-29': 2000}),
schedule: flat(2000),
weeksAgo: 1,
today: DateTime(2026, 4),
);
expect(result.start, '2026-03-23');
expect(result.end, '2026-03-29');
expect(result.loggedDays, 2);
});
test('weekBounds spans Monday to Sunday across the change', () {
final (monday, sunday) = weekBounds(DateTime(2026, 3, 29));
expect((monday.year, monday.month, monday.day), (2026, 3, 23));
expect((sunday.year, sunday.month, sunday.day), (2026, 3, 29));
});
test('monthBounds ends on the real last day of a DST month', () {
final (first, last) = monthBounds(DateTime(2026, 3, 15));
expect((first.year, first.month, first.day), (2026, 3, 1));
expect((last.year, last.month, last.day), (2026, 3, 31));
});
});
}

View File

@ -27,6 +27,15 @@ DayLog logOf(Map<String, double> kcalByDay) => {
};
void main() {
// Every other fixture in this file sits in a DST-free span, so none of them
// can see the bug these cover: stepping a local DateTime by
// Duration(days: 1) moves 23 or 25 hours across a DST boundary and lands on
// the wrong wall-clock day. Under TZ=Europe/Warsaw (spring-forward
// 2026-03-29) that made the phone and the PC report different averages for
// the same log. Assertions are on the calendar day, never on the DateTime,
// so they mean the same thing whatever zone the runner is in.
group('bandFor', () {
test('exactly at budget is under', () {
expect(bandFor(2000, 2000), AverageBand.under);
@ -41,7 +50,6 @@ void main() {
expect(bandFor(2400.01, 2000), AverageBand.veryOver);
});
});
group('bandLabel', () {
test('mirrors the Python labels exactly', () {
expect(bandLabel(null), 'no data');
@ -50,7 +58,6 @@ void main() {
expect(bandLabel(AverageBand.veryOver), 'very over');
});
});
group('bounds', () {
test('week starts on Monday', () {
// 2026-01-07 is a Wednesday.
@ -78,7 +85,6 @@ void main() {
expect(shiftMonths(DateTime(2026, 1, 20), 1), DateTime(2025, 12));
});
});
group('periodAverage', () {
test('averages over logged days only, not elapsed days', () {
final log = logOf({
@ -149,151 +155,4 @@ void main() {
expect(result.band, AverageBand.under);
});
});
group('weeklyAverage', () {
test('the current week stops at yesterday', () {
final log = logOf({
'2026-01-05': 3000,
'2026-01-06': 3000,
'2026-01-07': 3000,
'2026-01-08': 100,
});
final result = weeklyAverage(
log,
schedule: flat(2000),
today: DateTime(2026, 1, 8),
);
expect(result.avgKcal, 3000);
expect(result.end, '2026-01-07');
expect(result.band, AverageBand.veryOver);
});
test('Monday has no complete days this week', () {
final result = weeklyAverage(
logOf({'2026-01-05': 3000}),
schedule: flat(2000),
today: DateTime(2026, 1, 5),
);
expect(result.elapsedDays, 0);
expect(result.avgKcal, isNull);
});
test('the previous week is whole', () {
final result = weeklyAverage(
logOf({'2026-01-05': 2100, '2026-01-11': 2100}),
schedule: flat(2000),
weeksAgo: 1,
today: DateTime(2026, 1, 14),
);
expect(result.start, '2026-01-05');
expect(result.end, '2026-01-11');
expect(result.elapsedDays, 7);
expect(result.band, AverageBand.slightlyOver);
});
});
group('monthlyAverage', () {
test('the current month stops at yesterday', () {
final log = logOf({
'2026-03-01': 1000,
'2026-03-02': 1000,
'2026-03-04': 9000,
});
final result = monthlyAverage(
log,
schedule: flat(2000),
today: DateTime(2026, 3, 4),
);
expect(result.avgKcal, 1000);
expect(result.end, '2026-03-03');
expect(result.band, AverageBand.under);
});
test('the first of the month has no complete days', () {
final result = monthlyAverage(
const {},
schedule: flat(2000),
today: DateTime(2026, 3),
);
expect(result.elapsedDays, 0);
});
test('the previous month is whole', () {
final result = monthlyAverage(
logOf({'2026-02-10': 2500}),
schedule: flat(2000),
monthsAgo: 1,
today: DateTime(2026, 3, 10),
);
expect(result.start, '2026-02-01');
expect(result.end, '2026-02-28');
expect(result.elapsedDays, 28);
expect(result.band, AverageBand.veryOver);
});
});
// Every other fixture in this file sits in a DST-free span, so none of them
// can see the bug these cover: stepping a local DateTime by
// Duration(days: 1) moves 23 or 25 hours across a DST boundary and lands on
// the wrong wall-clock day. Under TZ=Europe/Warsaw (spring-forward
// 2026-03-29) that made the phone and the PC report different averages for
// the same log. Assertions are on the calendar day, never on the DateTime,
// so they mean the same thing whatever zone the runner is in.
group('date arithmetic across a DST boundary', () {
test('lastCompleteDay is yesterday even across spring-forward', () {
final result = lastCompleteDay(DateTime(2026, 3, 30));
expect(
(result.year, result.month, result.day),
(2026, 3, 29),
reason: 'Duration-based arithmetic returned 2026-03-28 23:00 here',
);
});
test('a period spanning spring-forward keeps every day', () {
final result = periodAverage(
logOf({
'2026-03-27': 2000,
'2026-03-28': 2000,
'2026-03-29': 2000,
'2026-03-30': 2000,
'2026-03-31': 2000,
}),
schedule: flat(2000),
start: DateTime(2026, 3, 27),
end: DateTime(2026, 3, 31),
);
expect(result.elapsedDays, 5);
expect(
result.loggedDays,
5,
reason: 'the day-stepping loop used to drop 2026-03-31',
);
});
test('the weeksAgo anchor stays in the right ISO week', () {
// 2026-04-01 is a Wednesday; one week back is the Mon 03-23..Sun 03-29
// week, the one containing the clock change.
final result = weeklyAverage(
logOf({'2026-03-23': 2000, '2026-03-29': 2000}),
schedule: flat(2000),
weeksAgo: 1,
today: DateTime(2026, 4),
);
expect(result.start, '2026-03-23');
expect(result.end, '2026-03-29');
expect(result.loggedDays, 2);
});
test('weekBounds spans Monday to Sunday across the change', () {
final (monday, sunday) = weekBounds(DateTime(2026, 3, 29));
expect((monday.year, monday.month, monday.day), (2026, 3, 23));
expect((sunday.year, sunday.month, sunday.day), (2026, 3, 29));
});
test('monthBounds ends on the real last day of a DST month', () {
final (first, last) = monthBounds(DateTime(2026, 3, 15));
expect((first.year, first.month, first.day), (2026, 3, 1));
expect((last.year, last.month, last.day), (2026, 3, 31));
});
});
}

View File

@ -0,0 +1,152 @@
import 'package:diet_guard_app/models/day_status.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/day_status_service.dart';
import 'package:flutter_test/flutter_test.dart';
FoodEntry buildEntry({required double kcal, bool deleted = false}) => FoodEntry(
time: '2026-01-01T12:00:00',
desc: 'test',
grams: 100,
kcal: kcal,
proteinG: 0,
carbsG: 0,
fatG: 0,
source: 'manual',
deleted: deleted,
);
FoodEntry buildMacroEntry({
required double proteinG,
required double carbsG,
required double fatG,
}) => FoodEntry(
time: '2026-01-01T12:00:00',
desc: 'test',
grams: 100,
kcal: 0,
proteinG: proteinG,
carbsG: carbsG,
fatG: fatG,
source: 'manual',
);
/// A schedule where one budget has applied since the beginning of time.
BudgetSchedule flat(int budget) => BudgetSchedule(
[const BudgetEntry(effectiveFrom: kEpochDay, kcal: 0, editedAt: '')]
.map(
(_) => BudgetEntry(
effectiveFrom: kEpochDay,
kcal: budget,
editedAt: '1970-01-01T00:00:00.000Z',
),
)
.toList(),
fallback: budget,
);
void main() {
group('loggingStreak', () {
test('empty map is zero', () {
expect(loggingStreak({}, today: DateTime(2026, 1, 5)), 0);
});
test('counts consecutive logged days including today', () {
final sm = {
'2026-01-03': DayStatus.green,
'2026-01-04': DayStatus.red,
'2026-01-05': DayStatus.yellow,
};
expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 3);
});
test('breaks on a gap', () {
final sm = {
'2026-01-01': DayStatus.green,
'2026-01-03': DayStatus.green,
'2026-01-04': DayStatus.green,
};
expect(loggingStreak(sm, today: DateTime(2026, 1, 4)), 2);
});
test('today not logged is not a break', () {
final sm = {
'2026-01-03': DayStatus.green,
'2026-01-04': DayStatus.yellow,
};
expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 2);
});
test('yesterday not logged breaks streak even if today is', () {
final sm = {
'2026-01-03': DayStatus.green,
'2026-01-05': DayStatus.green,
};
expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 1);
});
});
group('adherenceStreak', () {
test('counts consecutive green and yellow', () {
final sm = {
'2026-01-03': DayStatus.green,
'2026-01-04': DayStatus.yellow,
'2026-01-05': DayStatus.green,
};
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 3);
});
test('red today breaks the streak immediately', () {
final sm = {
'2026-01-04': DayStatus.green,
'2026-01-05': DayStatus.red,
};
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 0);
});
test('not-logged today is not a break', () {
final sm = {'2026-01-04': DayStatus.green};
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 1);
});
test('red in history breaks the streak', () {
final sm = {
'2026-01-03': DayStatus.green,
'2026-01-04': DayStatus.red,
'2026-01-05': DayStatus.green,
};
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 1);
});
});
group('yearToDateTally', () {
test('counts logged and adherent days this year only', () {
final sm = {
'2026-01-01': DayStatus.green,
'2026-01-02': DayStatus.red,
'2026-01-03': DayStatus.notLogged,
'2025-12-31': DayStatus.green,
};
final tally = yearToDateTally(sm, today: DateTime(2026, 1, 3));
expect(tally.loggedDays, 2);
expect(tally.elapsedDays, 3);
expect(tally.adherentDays, 1);
});
test('future day in map is excluded', () {
final sm = {
'2026-01-01': DayStatus.green,
'2026-06-01': DayStatus.green,
};
final tally = yearToDateTally(sm, today: DateTime(2026));
expect(tally.loggedDays, 1);
expect(tally.elapsedDays, 1);
expect(tally.adherentDays, 1);
});
test('empty map', () {
final tally = yearToDateTally({}, today: DateTime(2026, 3));
expect(tally.loggedDays, 0);
expect(tally.elapsedDays, 60);
expect(tally.adherentDays, 0);
});
});
}

View File

@ -68,7 +68,6 @@ void main() {
expect(dayTotalKcal(log, '2026-01-01'), 100);
});
});
group('sumMacros', () {
test('sums protein, carbs and fat across entries', () {
final totals = sumMacros([
@ -87,7 +86,6 @@ void main() {
expect(totals.fatG, 0);
});
});
group('dayStatus', () {
test('missing day is notLogged', () {
final log = <String, List<FoodEntry>>{};
@ -143,7 +141,6 @@ void main() {
expect(dayStatus(log, '2026-01-01', 2000), DayStatus.red);
});
});
group('statusMap', () {
test('maps every present day', () {
final log = {
@ -161,110 +158,4 @@ void main() {
expect(statusMap(log, schedule: flat(2000)), <String, DayStatus>{});
});
});
group('loggingStreak', () {
test('empty map is zero', () {
expect(loggingStreak({}, today: DateTime(2026, 1, 5)), 0);
});
test('counts consecutive logged days including today', () {
final sm = {
'2026-01-03': DayStatus.green,
'2026-01-04': DayStatus.red,
'2026-01-05': DayStatus.yellow,
};
expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 3);
});
test('breaks on a gap', () {
final sm = {
'2026-01-01': DayStatus.green,
'2026-01-03': DayStatus.green,
'2026-01-04': DayStatus.green,
};
expect(loggingStreak(sm, today: DateTime(2026, 1, 4)), 2);
});
test('today not logged is not a break', () {
final sm = {
'2026-01-03': DayStatus.green,
'2026-01-04': DayStatus.yellow,
};
expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 2);
});
test('yesterday not logged breaks streak even if today is', () {
final sm = {
'2026-01-03': DayStatus.green,
'2026-01-05': DayStatus.green,
};
expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 1);
});
});
group('adherenceStreak', () {
test('counts consecutive green and yellow', () {
final sm = {
'2026-01-03': DayStatus.green,
'2026-01-04': DayStatus.yellow,
'2026-01-05': DayStatus.green,
};
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 3);
});
test('red today breaks the streak immediately', () {
final sm = {
'2026-01-04': DayStatus.green,
'2026-01-05': DayStatus.red,
};
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 0);
});
test('not-logged today is not a break', () {
final sm = {'2026-01-04': DayStatus.green};
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 1);
});
test('red in history breaks the streak', () {
final sm = {
'2026-01-03': DayStatus.green,
'2026-01-04': DayStatus.red,
'2026-01-05': DayStatus.green,
};
expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 1);
});
});
group('yearToDateTally', () {
test('counts logged and adherent days this year only', () {
final sm = {
'2026-01-01': DayStatus.green,
'2026-01-02': DayStatus.red,
'2026-01-03': DayStatus.notLogged,
'2025-12-31': DayStatus.green,
};
final tally = yearToDateTally(sm, today: DateTime(2026, 1, 3));
expect(tally.loggedDays, 2);
expect(tally.elapsedDays, 3);
expect(tally.adherentDays, 1);
});
test('future day in map is excluded', () {
final sm = {
'2026-01-01': DayStatus.green,
'2026-06-01': DayStatus.green,
};
final tally = yearToDateTally(sm, today: DateTime(2026));
expect(tally.loggedDays, 1);
expect(tally.elapsedDays, 1);
expect(tally.adherentDays, 1);
});
test('empty map', () {
final tally = yearToDateTally({}, today: DateTime(2026, 3));
expect(tally.loggedDays, 0);
expect(tally.elapsedDays, 60);
expect(tally.adherentDays, 0);
});
});
}

View File

@ -1,28 +1,9 @@
import 'package:diet_guard_app/models/day_status.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/day_status_calendar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// The real app theme, not a bare default MaterialApp -- the widget under
// test reads its colors from Theme.of(context), so asserting against the
// same theme the app actually ships is what makes these assertions mean
// anything (a bare MaterialApp's own default colors would be arbitrary).
final _theme = buildAppTheme();
final _statusColors = _theme.extension<AppStatusColors>()!;
Widget _wrap(Widget child) => MaterialApp(
theme: _theme,
home: Scaffold(body: child),
);
Color _colorOfDay(WidgetTester tester, String day) {
final container = tester.widget<Container>(
find.ancestor(of: find.text(day), matching: find.byType(Container)).first,
);
final decoration = container.decoration! as BoxDecoration;
return decoration.color!;
}
import 'day_status_calendar_test_support.dart';
void main() {
group('DayStatusCalendar', () {
@ -31,7 +12,7 @@ void main() {
testWidgets('shows month and year in header', (tester) async {
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {},
month: june2026,
@ -46,7 +27,7 @@ void main() {
testWidgets('shows day-of-week headers', (tester) async {
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {},
month: june2026,
@ -63,7 +44,7 @@ void main() {
testWidgets('calls onPrevMonth when left arrow tapped', (tester) async {
var called = false;
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {},
month: june2026,
@ -80,7 +61,7 @@ void main() {
testWidgets('calls onNextMonth when right arrow tapped', (tester) async {
var called = false;
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {},
month: june2026,
@ -96,7 +77,7 @@ void main() {
testWidgets('colors a green day', (tester) async {
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {'2026-06-15': DayStatus.green},
month: june2026,
@ -106,12 +87,12 @@ void main() {
),
),
);
expect(_colorOfDay(tester, '15'), _statusColors.success);
expect(colorOfDay(tester, '15'), statusColors.success);
});
testWidgets('colors a yellow day', (tester) async {
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {'2026-06-15': DayStatus.yellow},
month: june2026,
@ -121,12 +102,12 @@ void main() {
),
),
);
expect(_colorOfDay(tester, '15'), _statusColors.warning);
expect(colorOfDay(tester, '15'), statusColors.warning);
});
testWidgets('colors a red day', (tester) async {
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {'2026-06-15': DayStatus.red},
month: june2026,
@ -136,14 +117,14 @@ void main() {
),
),
);
expect(_colorOfDay(tester, '15'), _theme.colorScheme.error);
expect(colorOfDay(tester, '15'), appTheme.colorScheme.error);
});
testWidgets(
'a past day absent from statusByDate renders black (not logged)',
(tester) async {
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {},
month: june2026,
@ -153,7 +134,7 @@ void main() {
),
),
);
expect(_colorOfDay(tester, '15'), _theme.colorScheme.surface);
expect(colorOfDay(tester, '15'), appTheme.colorScheme.surface);
},
);
@ -161,7 +142,7 @@ void main() {
'a future day renders neutrally, never as a false not-logged cell',
(tester) async {
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {},
month: june2026,
@ -173,17 +154,17 @@ void main() {
);
// Day 25 is after the reference "today" of June 10.
expect(
_colorOfDay(tester, '25'),
_theme.colorScheme.surfaceContainerHigh,
colorOfDay(tester, '25'),
appTheme.colorScheme.surfaceContainerHigh,
);
},
);
testWidgets("today itself is classified, not treated as future", (
testWidgets('today itself is classified, not treated as future', (
tester,
) async {
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {'2026-06-20': DayStatus.red},
month: june2026,
@ -193,7 +174,7 @@ void main() {
),
),
);
expect(_colorOfDay(tester, '20'), _theme.colorScheme.error);
expect(colorOfDay(tester, '20'), appTheme.colorScheme.error);
});
testWidgets('renders a month starting on Sunday correctly', (
@ -201,11 +182,11 @@ void main() {
) async {
final sep2026 = DateTime(2026, 9);
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {},
month: sep2026,
today: DateTime(2026, 9, 1),
today: DateTime(2026, 9),
onPrevMonth: () {},
onNextMonth: () {},
),
@ -219,7 +200,7 @@ void main() {
) async {
DateTime? selected;
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {'2026-06-15': DayStatus.green},
month: june2026,
@ -238,7 +219,7 @@ void main() {
tester,
) async {
await tester.pumpWidget(
_wrap(
wrapInApp(
DayStatusCalendar(
statusByDate: const {},
month: june2026,

View File

@ -0,0 +1,33 @@
// Shared fixtures for the day_status_calendar widget tests.
//
// Split out of `day_status_calendar_test.dart` for the repo's 250-line cap.
// Public because the names now cross a file boundary -- a leading underscore
// is library-private in Dart.
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// The real app theme, not a bare default MaterialApp -- the widget under
// test reads its colors from Theme.of(context), so asserting against the
// same theme the app actually ships is what makes these assertions mean
// anything (a bare MaterialApp's own default colors would be arbitrary).
/// The real app theme the widget under test reads its colors from.
final ThemeData appTheme = buildAppTheme();
/// The app's status-color extension, for asserting per-day cell colors.
final AppStatusColors statusColors = appTheme.extension<AppStatusColors>()!;
/// Wraps [child] in a MaterialApp carrying the real app theme.
Widget wrapInApp(Widget child) => MaterialApp(
theme: appTheme,
home: Scaffold(body: child),
);
/// Returns the background color of the calendar cell showing [day].
Color colorOfDay(WidgetTester tester, String day) {
final container = tester.widget<Container>(
find.ancestor(of: find.text(day), matching: find.byType(Container)).first,
);
final decoration = container.decoration! as BoxDecoration;
return decoration.color!;
}