diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 5e5f172..75fad6b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/app/test/desktop/wrapper_server_account_test.dart b/app/test/desktop/wrapper_server_account_test.dart new file mode 100644 index 0000000..3e698a6 --- /dev/null +++ b/app/test/desktop/wrapper_server_account_test.dart @@ -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 outbound; + late Map canned; + + Future 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('app'); + 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 enableWith(Map 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); + }); + }); +} diff --git a/app/test/desktop/wrapper_server_proxy_test.dart b/app/test/desktop/wrapper_server_proxy_test.dart new file mode 100644 index 0000000..d1589bf --- /dev/null +++ b/app/test/desktop/wrapper_server_proxy_test.dart @@ -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 outbound; + late Map canned; + + Future 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('app'); + 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( + 'rate limited', + 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'); + }); + }); +} diff --git a/app/test/desktop/wrapper_server_test.dart b/app/test/desktop/wrapper_server_test.dart index a083ca7..0320197 100644 --- a/app/test/desktop/wrapper_server_test.dart +++ b/app/test/desktop/wrapper_server_test.dart @@ -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( - 'rate limited', - 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 enableWith(Map 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); - }); - }); } diff --git a/app/test/screens/edit_entry_screen_foodbank_test.dart b/app/test/screens/edit_entry_screen_foodbank_test.dart new file mode 100644 index 0000000..2ccfc29 --- /dev/null +++ b/app/test/screens/edit_entry_screen_foodbank_test.dart @@ -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 settle(WidgetTester tester) async { + await Future.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); + }); + }); +} diff --git a/app/test/screens/edit_entry_screen_test.dart b/app/test/screens/edit_entry_screen_test.dart index 36c7b70..ebbfb46 100644 --- a/app/test/screens/edit_entry_screen_test.dart +++ b/app/test/screens/edit_entry_screen_test.dart @@ -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); - }); - }); } diff --git a/app/test/screens/settings_screen_battery_test.dart b/app/test/screens/settings_screen_battery_test.dart new file mode 100644 index 0000000..64ba2cb --- /dev/null +++ b/app/test/screens/settings_screen_battery_test.dart @@ -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 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.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, + ); + }); + }); +} diff --git a/app/test/screens/settings_screen_test.dart b/app/test/screens/settings_screen_test.dart index 7b0e8a5..593184f 100644 --- a/app/test/screens/settings_screen_test.dart +++ b/app/test/screens/settings_screen_test.dart @@ -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, - ); - }); - }); } diff --git a/app/test/services/app_settings_service_sync_test.dart b/app/test/services/app_settings_service_sync_test.dart new file mode 100644 index 0000000..5bd99ab --- /dev/null +++ b/app/test/services/app_settings_service_sync_test.dart @@ -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); + }, + ); + }); +} diff --git a/app/test/services/app_settings_service_test.dart b/app/test/services/app_settings_service_test.dart index 0d3cd3f..2573423 100644 --- a/app/test/services/app_settings_service_test.dart +++ b/app/test/services/app_settings_service_test.dart @@ -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); - }, - ); - }); } diff --git a/app/test/services/average_service_periods_test.dart b/app/test/services/average_service_periods_test.dart new file mode 100644 index 0000000..f3a5c79 --- /dev/null +++ b/app/test/services/average_service_periods_test.dart @@ -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 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)); + }); + }); +} diff --git a/app/test/services/average_service_test.dart b/app/test/services/average_service_test.dart index 742a8ff..9d71483 100644 --- a/app/test/services/average_service_test.dart +++ b/app/test/services/average_service_test.dart @@ -27,6 +27,15 @@ DayLog logOf(Map 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)); - }); - }); } diff --git a/app/test/services/day_status_service_streaks_test.dart b/app/test/services/day_status_service_streaks_test.dart new file mode 100644 index 0000000..8106564 --- /dev/null +++ b/app/test/services/day_status_service_streaks_test.dart @@ -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); + }); + }); +} diff --git a/app/test/services/day_status_service_test.dart b/app/test/services/day_status_service_test.dart index f281c12..aa626b9 100644 --- a/app/test/services/day_status_service_test.dart +++ b/app/test/services/day_status_service_test.dart @@ -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 = >{}; @@ -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)), {}); }); }); - - 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); - }); - }); } diff --git a/app/test/widgets/day_status_calendar_test.dart b/app/test/widgets/day_status_calendar_test.dart index 7340842..a597d6f 100644 --- a/app/test/widgets/day_status_calendar_test.dart +++ b/app/test/widgets/day_status_calendar_test.dart @@ -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()!; - -Widget _wrap(Widget child) => MaterialApp( - theme: _theme, - home: Scaffold(body: child), -); - -Color _colorOfDay(WidgetTester tester, String day) { - final container = tester.widget( - 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, diff --git a/app/test/widgets/day_status_calendar_test_support.dart b/app/test/widgets/day_status_calendar_test_support.dart new file mode 100644 index 0000000..d348092 --- /dev/null +++ b/app/test/widgets/day_status_calendar_test_support.dart @@ -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()!; + +/// 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( + find.ancestor(of: find.text(day), matching: find.byType(Container)).first, + ); + final decoration = container.decoration! as BoxDecoration; + return decoration.color!; +}