From cc78dd383b35fa45a7ef06400d6fae00a32c3788 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 20:49:54 +0200 Subject: [PATCH] Split six Dart test files; share the sync fixtures via a support sibling Tenth batch of the 250-line sweep (over-cap count 18 -> 12; all Python is already clear, so the remainder is Dart). sync_merge_test.dart 694 -> 232 (+ 4 siblings) github_mirror_screen_test.dart 512 -> 250 (+ 3 siblings) foodbank_service_test.dart 395 -> 190 (+ manual sibling) sync_service_test.dart 394 -> 203 (+ budget sibling + support) log_storage_service_test.dart 385 -> 182 (+ edit sibling) log_meal_screen_test.dart 362 -> 243 (+ nav sibling) sync_service_test's ~100-line fixture block is shared through `sync_service_test_support.dart` rather than duplicated: copying it would have put both siblings back over the cap on their own. The helpers had to become public (`_FakeGitHub` -> `FakeGitHub`, `_manual` -> `manualNutrition`, `_remoteBudgetJson` -> `remoteBudgetJson`) with doc comments, since a leading underscore is library-private in Dart and the siblings could not otherwise see them. The block-boundary matcher earned two fixes, both caught by comparing block counts against HEAD rather than by the suite: - A test name wrapped onto its own line (dart format does this past 80 cols) was invisible to a same-line-only pattern, so the neighbouring span sliced through it -- the file stopped compiling. Spans now end at the matching close paren of the call, not at a guess past the closure brace. - A double-quoted name containing an apostrophe ("skips its own device id ('phone')") was excluded by a character class that banned both quote characters. That block fell into the shared prologue and was duplicated into every output: the suite went GREEN at 559 tests instead of 555. Reverted and redone; text between two blocks is now treated as a shared helper. Verified: flutter test 555 (unchanged, and each split family's block count matches HEAD exactly), flutter analyze clean, pre-commit clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC --- app/pubspec.lock | 2 +- app/pubspec.yaml | 3 +- .../github_mirror_device_dialog_test.dart | 195 ++++++++ .../github_mirror_device_flow_test.dart | 189 +++++++ .../github_mirror_device_poll_test.dart | 169 +++++++ .../screens/github_mirror_screen_test.dart | 284 +---------- .../screens/log_meal_screen_nav_test.dart | 184 +++++++ app/test/screens/log_meal_screen_test.dart | 123 +---- .../foodbank_service_manual_test.dart | 245 +++++++++ app/test/services/foodbank_service_test.dart | 229 +-------- .../log_storage_service_edit_test.dart | 229 +++++++++ .../services/log_storage_service_test.dart | 213 +------- app/test/services/sync_merge_banks_test.dart | 136 +++++ app/test/services/sync_merge_budget_test.dart | 212 ++++++++ .../services/sync_merge_curated_test.dart | 166 +++++++ app/test/services/sync_merge_test.dart | 468 +----------------- app/test/services/sync_merge_wire_test.dart | 139 ++++++ .../services/sync_service_budget_test.dart | 127 +++++ app/test/services/sync_service_test.dart | 231 +-------- .../services/sync_service_test_support.dart | 121 +++++ 20 files changed, 2166 insertions(+), 1499 deletions(-) create mode 100644 app/test/screens/github_mirror_device_dialog_test.dart create mode 100644 app/test/screens/github_mirror_device_flow_test.dart create mode 100644 app/test/screens/github_mirror_device_poll_test.dart create mode 100644 app/test/screens/log_meal_screen_nav_test.dart create mode 100644 app/test/services/foodbank_service_manual_test.dart create mode 100644 app/test/services/log_storage_service_edit_test.dart create mode 100644 app/test/services/sync_merge_banks_test.dart create mode 100644 app/test/services/sync_merge_budget_test.dart create mode 100644 app/test/services/sync_merge_curated_test.dart create mode 100644 app/test/services/sync_merge_wire_test.dart create mode 100644 app/test/services/sync_service_budget_test.dart create mode 100644 app/test/services/sync_service_test_support.dart diff --git a/app/pubspec.lock b/app/pubspec.lock index 23bfa49..a7706de 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -571,7 +571,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index da0a977..5e5f172 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.23+1 +version: 1.0.24+1 environment: sdk: ^3.12.2 @@ -57,6 +57,7 @@ dev_dependencies: url_launcher_platform_interface: ^2.3.2 very_good_analysis: ^10.2.0 + plugin_platform_interface: any flutter: uses-material-design: true diff --git a/app/test/screens/github_mirror_device_dialog_test.dart b/app/test/screens/github_mirror_device_dialog_test.dart new file mode 100644 index 0000000..5faa67c --- /dev/null +++ b/app/test/screens/github_mirror_device_dialog_test.dart @@ -0,0 +1,195 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/screens/github_mirror_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import '../fake_secure_storage.dart'; + +/// Stub launcher that records the URL instead of opening it, so the device +/// dialog's "Open GitHub & copy code" can be exercised without a real +/// platform channel. +class _FakeUrlLauncher extends UrlLauncherPlatform + with MockPlatformInterfaceMixin { + String? launched; + + @override + final LinkDelegate? linkDelegate = null; + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launched = url; + return true; + } +} + +void main() { + + + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_github_'); + 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); + }); + + // GitHubMirrorScreen loads its settings via a fire-and-forget Future in + // initState that Flutter's frame scheduler does not track -- same pitfall + // as HistoryScreen/LogMealScreen. + 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(); + } + + /// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth + /// injects no test delay, so under `runAsync` it is a genuine Timer, not + /// the fake-clock one `tester.pump(duration)` advances) by interleaving + /// real waits with frame pumps until [done] is true or [maxTries] is hit. + Future pumpUntil( + WidgetTester tester, + bool Function() done, { + int maxTries = 200, + }) async { + for (var i = 0; i < maxTries && !done(); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + } + + + /// Types [clientId] into the client-id field. + Future enterClientId(WidgetTester tester, String clientId) async { + await tester.enterText( + find.widgetWithText(TextField, 'OAuth App client id'), + clientId, + ); + } + + testWidgets('device flow happy path saves the token and syncs', ( + tester, + ) async { + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + if (req.url.path.contains('login/oauth/access_token')) { + return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); + } + if (req.method == 'PUT') return http.Response('{}', 200); + // A bare `/repos//` GET is crdt_sync's GitHubClient + // probing whether the repo itself exists (vs. a content path just + // being unused) -- must succeed so an empty repo isn't mistaken for + // a missing one. + if (req.method == 'GET' && req.url.pathSegments.length == 3) { + return http.Response('{}', 200); + } + return http.Response('', 404); // sync's pull-side list/read calls + }); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + await enterClientId(tester, 'cid'); + + await tester.tap(find.text('Connect GitHub')); + await pumpUntil( + tester, + () => find.text('WXYZ-1234').evaluate().isNotEmpty, + ); + expect(find.text('WXYZ-1234'), findsOneWidget); + + // Let the dialog poll (interval 0) and resolve the token, then the + // post-connect sync runs against the mock. + await pumpUntil( + tester, + () => find.textContaining('Connected and synced').evaluate().isNotEmpty, + ); + + expect(find.textContaining('Connected and synced'), findsOneWidget); + }); + }); + testWidgets( + 'device flow connects but surfaces a post-connect sync failure', + (tester) async { + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + if (req.url.path.contains('login/oauth/access_token')) { + return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); + } + return http.Response('boom', 500); // the sync's repo calls fail + }); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + await enterClientId(tester, 'cid'); + + await tester.tap(find.text('Connect GitHub')); + await pumpUntil( + tester, + () => find.textContaining('sync failed').evaluate().isNotEmpty, + ); + + expect(find.textContaining('sync failed'), findsOneWidget); + }); + }, + ); +} diff --git a/app/test/screens/github_mirror_device_flow_test.dart b/app/test/screens/github_mirror_device_flow_test.dart new file mode 100644 index 0000000..2d308ba --- /dev/null +++ b/app/test/screens/github_mirror_device_flow_test.dart @@ -0,0 +1,189 @@ +import 'dart:io'; + +import 'package:diet_guard_app/screens/github_mirror_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import '../fake_secure_storage.dart'; + +/// Stub launcher that records the URL instead of opening it, so the device +/// dialog's "Open GitHub & copy code" can be exercised without a real +/// platform channel. +class _FakeUrlLauncher extends UrlLauncherPlatform + with MockPlatformInterfaceMixin { + String? launched; + + @override + final LinkDelegate? linkDelegate = null; + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launched = url; + return true; + } +} + +void main() { + + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_github_'); + 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); + }); + + // GitHubMirrorScreen loads its settings via a fire-and-forget Future in + // initState that Flutter's frame scheduler does not track -- same pitfall + // as HistoryScreen/LogMealScreen. + 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(); + } + + /// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth + /// injects no test delay, so under `runAsync` it is a genuine Timer, not + /// the fake-clock one `tester.pump(duration)` advances) by interleaving + /// real waits with frame pumps until [done] is true or [maxTries] is hit. + Future pumpUntil( + WidgetTester tester, + bool Function() done, { + int maxTries = 200, + }) async { + for (var i = 0; i < maxTries && !done(); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + } + + + /// Types [clientId] into the client-id field. + Future enterClientId(WidgetTester tester, String clientId) async { + await tester.enterText( + find.widgetWithText(TextField, 'OAuth App client id'), + clientId, + ); + } + + testWidgets('Connect GitHub without a client id opens setup guidance', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: GitHubMirrorScreen())); + await settle(tester); + await enterClientId(tester, ''); + + await tester.tap(find.text('Connect GitHub')); + await settle(tester); + + expect(find.text('One-time GitHub setup needed'), findsOneWidget); + expect(find.widgetWithText(TextField, 'Client ID'), findsOneWidget); + }); + }); + testWidgets('cancelling the client id setup dialog aborts the connect', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: GitHubMirrorScreen())); + await settle(tester); + await enterClientId(tester, ''); + + await tester.tap(find.text('Connect GitHub')); + await settle(tester); + await tester.tap(find.text('Cancel')); + await settle(tester); + + expect(find.text('One-time GitHub setup needed'), findsNothing); + }); + }); + testWidgets( + 'entering a client id in the setup dialog saves it and proceeds', + (tester) async { + final mock = MockClient((_) async => http.Response('nope', 422)); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + await enterClientId(tester, ''); + + await tester.tap(find.text('Connect GitHub')); + await settle(tester); + await tester.enterText( + find.widgetWithText(TextField, 'Client ID'), + 'cid', + ); + await tester.tap(find.text('Continue')); + await settle(tester); + + expect( + find.textContaining('Could not start device flow'), + findsOneWidget, + ); + expect( + find.widgetWithText(TextField, 'OAuth App client id'), + findsOneWidget, + ); + expect( + (tester.widget( + find.widgetWithText(TextField, 'OAuth App client id'), + ) + as TextField) + .controller! + .text, + 'cid', + ); + }); + }, + ); + testWidgets('device flow failure to start shows a message', (tester) async { + final mock = MockClient((_) async => http.Response('nope', 422)); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + await enterClientId(tester, 'cid'); + + await tester.tap(find.text('Connect GitHub')); + await settle(tester); + + expect( + find.textContaining('Could not start device flow'), + findsOneWidget, + ); + }); + }); +} diff --git a/app/test/screens/github_mirror_device_poll_test.dart b/app/test/screens/github_mirror_device_poll_test.dart new file mode 100644 index 0000000..dc0f968 --- /dev/null +++ b/app/test/screens/github_mirror_device_poll_test.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/screens/github_mirror_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import '../fake_secure_storage.dart'; + +/// Stub launcher that records the URL instead of opening it, so the device +/// dialog's "Open GitHub & copy code" can be exercised without a real +/// platform channel. +class _FakeUrlLauncher extends UrlLauncherPlatform + with MockPlatformInterfaceMixin { + String? launched; + + @override + final LinkDelegate? linkDelegate = null; + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launched = url; + return true; + } +} + +void main() { + + + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_github_'); + 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); + }); + + // GitHubMirrorScreen loads its settings via a fire-and-forget Future in + // initState that Flutter's frame scheduler does not track -- same pitfall + // as HistoryScreen/LogMealScreen. + 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(); + } + + /// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth + /// injects no test delay, so under `runAsync` it is a genuine Timer, not + /// the fake-clock one `tester.pump(duration)` advances) by interleaving + /// real waits with frame pumps until [done] is true or [maxTries] is hit. + Future pumpUntil( + WidgetTester tester, + bool Function() done, { + int maxTries = 200, + }) async { + for (var i = 0; i < maxTries && !done(); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + } + + + /// Types [clientId] into the client-id field. + Future enterClientId(WidgetTester tester, String clientId) async { + await tester.enterText( + find.widgetWithText(TextField, 'OAuth App client id'), + clientId, + ); + } + + testWidgets('device dialog: failed poll shows the error and Open launches', ( + tester, + ) async { + final launcher = _FakeUrlLauncher(); + UrlLauncherPlatform.instance = launcher; + + // The dialog's Open button copies the code to the clipboard first; + // there's no clipboard plugin in the test host, so stub the channel. + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); + addTearDown( + () => messenger.setMockMethodCallHandler(SystemChannels.platform, null), + ); + + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + return http.Response( + jsonEncode({'error': 'access_denied', 'error_description': 'no'}), + 200, + ); + }); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + await enterClientId(tester, 'cid'); + + await tester.tap(find.text('Connect GitHub')); + await pumpUntil( + tester, + () => find.text('WXYZ-1234').evaluate().isNotEmpty, + ); + expect(find.text('WXYZ-1234'), findsOneWidget); + + await pumpUntil( + tester, + () => find.textContaining('access_denied').evaluate().isNotEmpty, + ); + + expect(find.textContaining('access_denied'), findsOneWidget); + + await tester.tap(find.text('Open GitHub & copy code')); + await tester.pump(); + expect(launcher.launched, 'https://github.com/login/device'); + + await tester.tap(find.text('Cancel')); + await settle(tester); + }); + }); +} diff --git a/app/test/screens/github_mirror_screen_test.dart b/app/test/screens/github_mirror_screen_test.dart index 02e04ef..d8d714f 100644 --- a/app/test/screens/github_mirror_screen_test.dart +++ b/app/test/screens/github_mirror_screen_test.dart @@ -1,15 +1,13 @@ -import 'dart:convert'; import 'dart:io'; import 'package:diet_guard_app/screens/github_mirror_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/document_store_io.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/sync_health.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -41,6 +39,7 @@ class _FakeUrlLauncher extends UrlLauncherPlatform } void main() { + late Directory tempDir; setUp(() async { @@ -90,6 +89,15 @@ void main() { } } + + /// Types [clientId] into the client-id field. + Future enterClientId(WidgetTester tester, String clientId) async { + await tester.enterText( + find.widgetWithText(TextField, 'OAuth App client id'), + clientId, + ); + } + testWidgets('shows the kuhyx/syncs defaults on a fresh install', ( tester, ) async { @@ -101,7 +109,6 @@ void main() { expect(find.widgetWithText(TextField, 'syncs'), findsOneWidget); }); }); - testWidgets('Save persists the entered token', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: GitHubMirrorScreen())); @@ -117,7 +124,6 @@ void main() { expect(find.text('Saved.'), findsOneWidget); }); }); - testWidgets('Test connection reports success', (tester) async { final mock = MockClient( (_) async => http.Response('{}', 200), @@ -136,7 +142,6 @@ void main() { expect(find.text('GitHub connection OK.'), findsOneWidget); }); }); - testWidgets('Test connection reports failure', (tester) async { final mock = MockClient((_) async => http.Response('', 403)); await tester.runAsync(() async { @@ -153,7 +158,6 @@ void main() { expect(find.text('GitHub connection failed.'), findsOneWidget); }); }); - testWidgets('Sync now runs a sync tick and reports success', ( tester, ) async { @@ -180,7 +184,6 @@ void main() { expect(find.text('Synced.'), findsOneWidget); }); }); - testWidgets('Sync now clears a stored failure so the banner lifts', ( tester, ) async { @@ -214,7 +217,6 @@ void main() { expect(status.isStalled, isFalse); }); }); - testWidgets('Test connection reports a network exception', (tester) async { final mock = MockClient((_) async => throw const FormatException('no net')); await tester.runAsync(() async { @@ -231,7 +233,6 @@ void main() { expect(find.textContaining('GitHub connection failed:'), findsOneWidget); }); }); - testWidgets('Sync now reports a GitHub error', (tester) async { final mock = MockClient((_) async => http.Response('boom', 500)); await tester.runAsync(() async { @@ -246,267 +247,4 @@ void main() { expect(find.textContaining('Sync failed:'), findsOneWidget); }); }); - - /// Types [clientId] into the client-id field. - Future enterClientId(WidgetTester tester, String clientId) async { - await tester.enterText( - find.widgetWithText(TextField, 'OAuth App client id'), - clientId, - ); - } - - testWidgets('Connect GitHub without a client id opens setup guidance', ( - tester, - ) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: GitHubMirrorScreen())); - await settle(tester); - await enterClientId(tester, ''); - - await tester.tap(find.text('Connect GitHub')); - await settle(tester); - - expect(find.text('One-time GitHub setup needed'), findsOneWidget); - expect(find.widgetWithText(TextField, 'Client ID'), findsOneWidget); - }); - }); - - testWidgets('cancelling the client id setup dialog aborts the connect', ( - tester, - ) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: GitHubMirrorScreen())); - await settle(tester); - await enterClientId(tester, ''); - - await tester.tap(find.text('Connect GitHub')); - await settle(tester); - await tester.tap(find.text('Cancel')); - await settle(tester); - - expect(find.text('One-time GitHub setup needed'), findsNothing); - }); - }); - - testWidgets( - 'entering a client id in the setup dialog saves it and proceeds', - (tester) async { - final mock = MockClient((_) async => http.Response('nope', 422)); - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), - ); - await settle(tester); - await enterClientId(tester, ''); - - await tester.tap(find.text('Connect GitHub')); - await settle(tester); - await tester.enterText( - find.widgetWithText(TextField, 'Client ID'), - 'cid', - ); - await tester.tap(find.text('Continue')); - await settle(tester); - - expect( - find.textContaining('Could not start device flow'), - findsOneWidget, - ); - expect( - find.widgetWithText(TextField, 'OAuth App client id'), - findsOneWidget, - ); - expect( - (tester.widget( - find.widgetWithText(TextField, 'OAuth App client id'), - ) - as TextField) - .controller! - .text, - 'cid', - ); - }); - }, - ); - - testWidgets('device flow failure to start shows a message', (tester) async { - final mock = MockClient((_) async => http.Response('nope', 422)); - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), - ); - await settle(tester); - await enterClientId(tester, 'cid'); - - await tester.tap(find.text('Connect GitHub')); - await settle(tester); - - expect( - find.textContaining('Could not start device flow'), - findsOneWidget, - ); - }); - }); - - testWidgets('device flow happy path saves the token and syncs', ( - tester, - ) async { - final mock = MockClient((req) async { - if (req.url.path.contains('device/code')) { - return http.Response( - jsonEncode({ - 'device_code': 'dev123', - 'user_code': 'WXYZ-1234', - 'verification_uri': 'https://github.com/login/device', - 'interval': 0, - 'expires_in': 900, - }), - 200, - ); - } - if (req.url.path.contains('login/oauth/access_token')) { - return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); - } - if (req.method == 'PUT') return http.Response('{}', 200); - // A bare `/repos//` GET is crdt_sync's GitHubClient - // probing whether the repo itself exists (vs. a content path just - // being unused) -- must succeed so an empty repo isn't mistaken for - // a missing one. - if (req.method == 'GET' && req.url.pathSegments.length == 3) { - return http.Response('{}', 200); - } - return http.Response('', 404); // sync's pull-side list/read calls - }); - - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), - ); - await settle(tester); - await enterClientId(tester, 'cid'); - - await tester.tap(find.text('Connect GitHub')); - await pumpUntil( - tester, - () => find.text('WXYZ-1234').evaluate().isNotEmpty, - ); - expect(find.text('WXYZ-1234'), findsOneWidget); - - // Let the dialog poll (interval 0) and resolve the token, then the - // post-connect sync runs against the mock. - await pumpUntil( - tester, - () => find.textContaining('Connected and synced').evaluate().isNotEmpty, - ); - - expect(find.textContaining('Connected and synced'), findsOneWidget); - }); - }); - - testWidgets( - 'device flow connects but surfaces a post-connect sync failure', - (tester) async { - final mock = MockClient((req) async { - if (req.url.path.contains('device/code')) { - return http.Response( - jsonEncode({ - 'device_code': 'dev123', - 'user_code': 'WXYZ-1234', - 'verification_uri': 'https://github.com/login/device', - 'interval': 0, - 'expires_in': 900, - }), - 200, - ); - } - if (req.url.path.contains('login/oauth/access_token')) { - return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); - } - return http.Response('boom', 500); // the sync's repo calls fail - }); - - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), - ); - await settle(tester); - await enterClientId(tester, 'cid'); - - await tester.tap(find.text('Connect GitHub')); - await pumpUntil( - tester, - () => find.textContaining('sync failed').evaluate().isNotEmpty, - ); - - expect(find.textContaining('sync failed'), findsOneWidget); - }); - }, - ); - - testWidgets('device dialog: failed poll shows the error and Open launches', ( - tester, - ) async { - final launcher = _FakeUrlLauncher(); - UrlLauncherPlatform.instance = launcher; - - // The dialog's Open button copies the code to the clipboard first; - // there's no clipboard plugin in the test host, so stub the channel. - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - messenger.setMockMethodCallHandler( - SystemChannels.platform, - (call) async => null, - ); - addTearDown( - () => messenger.setMockMethodCallHandler(SystemChannels.platform, null), - ); - - final mock = MockClient((req) async { - if (req.url.path.contains('device/code')) { - return http.Response( - jsonEncode({ - 'device_code': 'dev123', - 'user_code': 'WXYZ-1234', - 'verification_uri': 'https://github.com/login/device', - 'interval': 0, - 'expires_in': 900, - }), - 200, - ); - } - return http.Response( - jsonEncode({'error': 'access_denied', 'error_description': 'no'}), - 200, - ); - }); - - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), - ); - await settle(tester); - await enterClientId(tester, 'cid'); - - await tester.tap(find.text('Connect GitHub')); - await pumpUntil( - tester, - () => find.text('WXYZ-1234').evaluate().isNotEmpty, - ); - expect(find.text('WXYZ-1234'), findsOneWidget); - - await pumpUntil( - tester, - () => find.textContaining('access_denied').evaluate().isNotEmpty, - ); - - expect(find.textContaining('access_denied'), findsOneWidget); - - await tester.tap(find.text('Open GitHub & copy code')); - await tester.pump(); - expect(launcher.launched, 'https://github.com/login/device'); - - await tester.tap(find.text('Cancel')); - await settle(tester); - }); - }); } diff --git a/app/test/screens/log_meal_screen_nav_test.dart b/app/test/screens/log_meal_screen_nav_test.dart new file mode 100644 index 0000000..dbdd883 --- /dev/null +++ b/app/test/screens/log_meal_screen_nav_test.dart @@ -0,0 +1,184 @@ +import 'dart:io'; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/calendar_screen.dart'; +import 'package:diet_guard_app/screens/food_bank_screen.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/screens/log_meal_screen.dart'; +import 'package:diet_guard_app/screens/settings_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +/// Stub launcher that records the URL instead of opening it -- same pattern +/// as settings_screen_test.dart's fake, duplicated locally per this +/// codebase's convention of small per-file fakes over a shared mock. +class _FakeUrlLauncher extends UrlLauncherPlatform + with MockPlatformInterfaceMixin { + String? launched; + + @override + final LinkDelegate? linkDelegate = null; + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launched = url; + return true; + } +} + +/// Returns a fixed [XFile] without touching any real platform channel. + +/// A minimal valid 1x1 transparent PNG, so the thumbnail preview can decode +/// it as a real image instead of throwing on bogus bytes. + +void main() { + + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_screen_'); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); + BudgetHistoryService.resetForTesting( + store: FileDocumentStore(tempDir), + ); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + AppSettingsService.resetForTesting(); + BudgetHistoryService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + final logMealButton = find.byTooltip('Log meal'); + + // The screen's button handlers and description-field listener trigger + // real `dart:io` file I/O as fire-and-forget Futures that Flutter's frame + // scheduler does not track -- pumpAndSettle() can return *before* that + // I/O (and its eventual setState) actually finishes. Every interaction + // that can reach a service call therefore runs inside a single + // tester.runAsync() per test, with a short real delay before each + // pumpAndSettle() to let the in-flight I/O actually complete first. + Future settle(WidgetTester tester) async { + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + } + + testWidgets('the history icon navigates to HistoryScreen', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.history)); + await settle(tester); + + expect(find.byType(HistoryScreen), findsOneWidget); + }); + }); + testWidgets('the calendar icon navigates to CalendarScreen', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.calendar_month)); + await settle(tester); + + expect(find.byType(CalendarScreen), findsOneWidget); + }); + }); + testWidgets('the settings icon navigates to SettingsScreen', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + // SettingsScreen briefly shows a perpetually-animating + // CircularProgressIndicator while its settings load; pumpAndSettle + // never settles against that, so pump explicit frames instead (see + // history_screen_test.dart's note on the same pitfall). + await tester.tap(find.byIcon(Icons.settings)); + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(); + + expect(find.byType(SettingsScreen), findsOneWidget); + }); + }); + testWidgets('food bank icon navigates to FoodBankScreen', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.restaurant_menu)); + await settle(tester); + + expect(find.byType(FoodBankScreen), findsOneWidget); + }); + }); + testWidgets('logged slot chip renders check-icon avatar', (tester) async { + await tester.runAsync(() async { + final now = DateTime.now(); + final dateKey = + '${now.year.toString().padLeft(4, '0')}-' + '${now.month.toString().padLeft(2, '0')}-' + '${now.day.toString().padLeft(2, '0')}'; + final at8 = DateTime(now.year, now.month, now.day, 8); + + await LogStorageService.instance.writeLog({ + dateKey: [ + FoodEntry( + id: 'slot-seed', + time: at8.toIso8601String(), + desc: 'breakfast', + grams: 100, + kcal: 300, + proteinG: 10, + carbsG: 40, + fatG: 5, + source: 'manual', + slot: 8, + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + // The 08:00 slot is logged — its ChoiceChip has a check-icon avatar. + expect(find.byIcon(Icons.check), findsWidgets); + }); + }); + testWidgets('tapping a slot chip selects it', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + // Tap the 08:00 chip to force _selectedSlot = 8. + await tester.tap(find.text('08:00')); + await settle(tester); + + final chip = tester.widget( + find.ancestor( + of: find.text('08:00'), + matching: find.byType(ChoiceChip), + ), + ); + expect(chip.selected, isTrue); + }); + }); +} diff --git a/app/test/screens/log_meal_screen_test.dart b/app/test/screens/log_meal_screen_test.dart index fd4aed2..8d44cb6 100644 --- a/app/test/screens/log_meal_screen_test.dart +++ b/app/test/screens/log_meal_screen_test.dart @@ -1,16 +1,10 @@ import 'dart:io'; -import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/models/food_entry.dart'; -import 'package:diet_guard_app/models/nutrition.dart'; -import 'package:diet_guard_app/models/slot.dart'; -import 'package:diet_guard_app/screens/calendar_screen.dart'; -import 'package:diet_guard_app/screens/food_bank_screen.dart'; import 'package:diet_guard_app/screens/log_meal_screen.dart'; -import 'package:diet_guard_app/screens/history_screen.dart'; -import 'package:diet_guard_app/screens/settings_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/widgets/today_progress_card.dart'; @@ -46,6 +40,7 @@ class _FakeUrlLauncher extends UrlLauncherPlatform /// it as a real image instead of throwing on bogus bytes. void main() { + late Directory tempDir; setUp(() async { @@ -80,50 +75,6 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('the history icon navigates to HistoryScreen', (tester) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); - await settle(tester); - - await tester.tap(find.byIcon(Icons.history)); - await settle(tester); - - expect(find.byType(HistoryScreen), findsOneWidget); - }); - }); - - testWidgets('the calendar icon navigates to CalendarScreen', ( - tester, - ) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); - await settle(tester); - - await tester.tap(find.byIcon(Icons.calendar_month)); - await settle(tester); - - expect(find.byType(CalendarScreen), findsOneWidget); - }); - }); - - testWidgets('the settings icon navigates to SettingsScreen', (tester) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); - await settle(tester); - - // SettingsScreen briefly shows a perpetually-animating - // CircularProgressIndicator while its settings load; pumpAndSettle - // never settles against that, so pump explicit frames instead (see - // history_screen_test.dart's note on the same pitfall). - await tester.tap(find.byIcon(Icons.settings)); - await tester.pump(); - await Future.delayed(const Duration(milliseconds: 200)); - await tester.pump(); - - expect(find.byType(SettingsScreen), findsOneWidget); - }); - }); - testWidgets('logging a manually-typed meal persists it as source manual', ( tester, ) async { @@ -149,7 +100,6 @@ void main() { expect(entries.single.kcal, 150); }); }); - testWidgets('the progress card summarises today after a log', ( tester, ) async { @@ -173,7 +123,6 @@ void main() { expect(find.text('2050 left'), findsOneWidget); }); }); - testWidgets('typing a new description dismisses the progress card', ( tester, ) async { @@ -198,7 +147,6 @@ void main() { expect(find.byType(TodayProgressCard), findsNothing); }); }); - testWidgets('refuses to log with an empty description', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); @@ -212,7 +160,6 @@ void main() { expect(await LogStorageService.instance.todayEntries(), isEmpty); }); }); - testWidgets( 'per-grams and amount-eaten fields scale macros to the eaten portion', (tester) async { @@ -243,7 +190,6 @@ void main() { }); }, ); - testWidgets( 'selecting a food-bank suggestion stamps source food bank, but ' 'editing a macro afterward reverts it to manual', @@ -294,69 +240,4 @@ void main() { }); }, ); - - testWidgets('food bank icon navigates to FoodBankScreen', (tester) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); - await settle(tester); - - await tester.tap(find.byIcon(Icons.restaurant_menu)); - await settle(tester); - - expect(find.byType(FoodBankScreen), findsOneWidget); - }); - }); - - testWidgets('logged slot chip renders check-icon avatar', (tester) async { - await tester.runAsync(() async { - final now = DateTime.now(); - final dateKey = - '${now.year.toString().padLeft(4, '0')}-' - '${now.month.toString().padLeft(2, '0')}-' - '${now.day.toString().padLeft(2, '0')}'; - final at8 = DateTime(now.year, now.month, now.day, 8); - - await LogStorageService.instance.writeLog({ - dateKey: [ - FoodEntry( - id: 'slot-seed', - time: at8.toIso8601String(), - desc: 'breakfast', - grams: 100, - kcal: 300, - proteinG: 10, - carbsG: 40, - fatG: 5, - source: 'manual', - slot: 8, - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); - await settle(tester); - - // The 08:00 slot is logged — its ChoiceChip has a check-icon avatar. - expect(find.byIcon(Icons.check), findsWidgets); - }); - }); - - testWidgets('tapping a slot chip selects it', (tester) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); - await settle(tester); - - // Tap the 08:00 chip to force _selectedSlot = 8. - await tester.tap(find.text('08:00')); - await settle(tester); - - final chip = tester.widget( - find.ancestor( - of: find.text('08:00'), - matching: find.byType(ChoiceChip), - ), - ); - expect(chip.selected, isTrue); - }); - }); } diff --git a/app/test/services/foodbank_service_manual_test.dart b/app/test/services/foodbank_service_manual_test.dart new file mode 100644 index 0000000..b644313 --- /dev/null +++ b/app/test/services/foodbank_service_manual_test.dart @@ -0,0 +1,245 @@ +import 'dart:io'; + +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/models/meal_component.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +FoodEntry _entry({ + required String id, + required String time, + required String desc, + double kcal = 100, + List? components, + bool deleted = false, +}) => FoodEntry( + id: id, + time: time, + desc: desc, + grams: 100, + kcal: kcal, + proteinG: 1, + carbsG: 1, + fatG: 1, + source: components != null ? 'meal' : 'manual', + components: components, + deleted: deleted, +); + +void main() { + + // --------------------------------------------------------------------------- + // Manual bank — addManualEntry / mergedEntries + // --------------------------------------------------------------------------- + + + // --------------------------------------------------------------------------- + // IO error paths — FileSystemException and FormatException handlers + // --------------------------------------------------------------------------- + + group('FoodBankService manual bank', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_manual_'); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + }); + + tearDown(() async { + FoodBankService.resetForTesting(); + LogStorageService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + test('addManualEntry persists and mergedEntries returns it', () async { + const record = FoodBankRecord( + desc: 'Manual oat', + kcal: 370, + proteinG: 13, + carbsG: 66, + fatG: 7, + grams: 100, + count: 0, + ); + + await FoodBankService.instance.addManualEntry(record); + final merged = await FoodBankService.instance.mergedEntries(); + + expect(merged.any((r) => r.desc == 'Manual oat'), isTrue); + }); + + test( + 'mergedEntries: log-derived entry wins over manual on collision', + () async { + // Seed log with 'oat' (count=1, kcal=100). + final log = { + '2026-06-22': [ + _entry(id: '1', time: '2026-06-22T08:00:00+02:00', desc: 'oat'), + ], + }; + await FoodBankService.instance.rebuildAndPersist(log); + + // Add manual entry with same normalized key but different kcal. + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'oat', + kcal: 999, + proteinG: 0, + carbsG: 0, + fatG: 0, + grams: 100, + count: 0, + ), + ); + + final merged = await FoodBankService.instance.mergedEntries(); + final oat = merged.firstWhere((r) => r.desc == 'oat'); + // Log-derived entry (kcal=100) should win over manual (kcal=999). + expect(oat.kcal, 100); + }, + ); + + test( + 'mergedEntries includes both log-derived and manual entries', + () async { + final log = { + '2026-06-22': [ + _entry( + id: '1', + time: '2026-06-22T08:00:00+02:00', + desc: 'toast', + ), + ], + }; + await FoodBankService.instance.rebuildAndPersist(log); + + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'Quinoa', + kcal: 370, + proteinG: 14, + carbsG: 64, + fatG: 6, + grams: 100, + count: 0, + ), + ); + + final merged = await FoodBankService.instance.mergedEntries(); + final descs = merged.map((r) => r.desc).toSet(); + expect(descs, containsAll(['toast', 'Quinoa'])); + }, + ); + + test('addManualEntry upserts by normalized key', () async { + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'Oat', + kcal: 370, + proteinG: 13, + carbsG: 66, + fatG: 7, + grams: 100, + count: 0, + ), + ); + + // Upsert same food with updated kcal. + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'oat', + kcal: 400, + proteinG: 14, + carbsG: 68, + fatG: 8, + grams: 100, + count: 0, + ), + ); + + final merged = await FoodBankService.instance.mergedEntries(); + final oats = merged.where((r) => r.desc.toLowerCase() == 'oat').toList(); + expect(oats.length, 1); + expect(oats.single.kcal, 400); + }); + + test('search includes manual entries', () async { + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'Rare ingredient', + kcal: 50, + proteinG: 1, + carbsG: 10, + fatG: 0.5, + grams: 100, + count: 0, + ), + ); + + final results = await FoodBankService.instance.search('Rare'); + expect(results.any((r) => r.name == 'Rare ingredient'), isTrue); + }); + }); + group('FoodBankService IO error paths', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_err_'); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + }); + + tearDown(() async { + FoodBankService.resetForTesting(); + LogStorageService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + test('readBank returns empty on invalid JSON (FormatException)', () async { + await File( + '${tempDir.path}/food_bank.json', + ).writeAsString('not valid json {{{'); + expect(await FoodBankService.instance.readBank(), isEmpty); + }); + + test( + 'readBank returns empty when file is unreadable (FileSystemException)', + () async { + final bankPath = '${tempDir.path}/food_bank.json'; + await File(bankPath).writeAsString('{}'); + await Process.run('chmod', ['000', bankPath]); + + expect(await FoodBankService.instance.readBank(), isEmpty); + + await Process.run('chmod', ['644', bankPath]); + }, + ); + + test( + 'mergedEntries handles invalid JSON in manual bank (FormatException)', + () async { + await File( + '${tempDir.path}/food_bank_manual.json', + ).writeAsString('not valid json {{{'); + expect(await FoodBankService.instance.mergedEntries(), isEmpty); + }, + ); + + test( + 'mergedEntries handles unreadable manual bank (FileSystemException)', + () async { + final manualPath = '${tempDir.path}/food_bank_manual.json'; + await File(manualPath).writeAsString('{}'); + await Process.run('chmod', ['000', manualPath]); + + expect(await FoodBankService.instance.mergedEntries(), isEmpty); + + await Process.run('chmod', ['644', manualPath]); + }, + ); + }); +} diff --git a/app/test/services/foodbank_service_test.dart b/app/test/services/foodbank_service_test.dart index 1db3e99..f635c80 100644 --- a/app/test/services/foodbank_service_test.dart +++ b/app/test/services/foodbank_service_test.dart @@ -1,9 +1,8 @@ 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/models/meal_component.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -30,6 +29,16 @@ FoodEntry _entry({ ); void main() { + + // --------------------------------------------------------------------------- + // Manual bank — addManualEntry / mergedEntries + // --------------------------------------------------------------------------- + + + // --------------------------------------------------------------------------- + // IO error paths — FileSystemException and FormatException handlers + // --------------------------------------------------------------------------- + group('FoodBankService.rebuild (pure)', () { test('upserts a single-food entry by normalized name', () { final log = { @@ -49,7 +58,6 @@ void main() { id: '1', time: '2026-06-22T08:00:00+02:00', desc: 'toast', - kcal: 100, ), _entry( id: '2', @@ -78,7 +86,7 @@ void main() { expect(FoodBankService.rebuild(log), isEmpty); }); - test('records a composite entry\'s components and total separately', () { + test("records a composite entry's components and total separately", () { const components = [ MealComponent( name: 'rice', @@ -124,7 +132,6 @@ void main() { ); }); }); - group('FoodBankService search/persistence', () { late Directory tempDir; @@ -180,216 +187,4 @@ void main() { expect(results.first.name, 'common'); }); }); - - // --------------------------------------------------------------------------- - // Manual bank — addManualEntry / mergedEntries - // --------------------------------------------------------------------------- - - group('FoodBankService manual bank', () { - late Directory tempDir; - - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_manual_'); - FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); - LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); - }); - - tearDown(() async { - FoodBankService.resetForTesting(); - LogStorageService.resetForTesting(); - await tempDir.delete(recursive: true); - }); - - test('addManualEntry persists and mergedEntries returns it', () async { - const record = FoodBankRecord( - desc: 'Manual oat', - kcal: 370, - proteinG: 13, - carbsG: 66, - fatG: 7, - grams: 100, - count: 0, - ); - - await FoodBankService.instance.addManualEntry(record); - final merged = await FoodBankService.instance.mergedEntries(); - - expect(merged.any((r) => r.desc == 'Manual oat'), isTrue); - }); - - test( - 'mergedEntries: log-derived entry wins over manual on collision', - () async { - // Seed log with 'oat' (count=1, kcal=100). - final log = { - '2026-06-22': [ - _entry(id: '1', time: '2026-06-22T08:00:00+02:00', desc: 'oat'), - ], - }; - await FoodBankService.instance.rebuildAndPersist(log); - - // Add manual entry with same normalized key but different kcal. - await FoodBankService.instance.addManualEntry( - const FoodBankRecord( - desc: 'oat', - kcal: 999, - proteinG: 0, - carbsG: 0, - fatG: 0, - grams: 100, - count: 0, - ), - ); - - final merged = await FoodBankService.instance.mergedEntries(); - final oat = merged.firstWhere((r) => r.desc == 'oat'); - // Log-derived entry (kcal=100) should win over manual (kcal=999). - expect(oat.kcal, 100); - }, - ); - - test( - 'mergedEntries includes both log-derived and manual entries', - () async { - final log = { - '2026-06-22': [ - _entry( - id: '1', - time: '2026-06-22T08:00:00+02:00', - desc: 'toast', - ), - ], - }; - await FoodBankService.instance.rebuildAndPersist(log); - - await FoodBankService.instance.addManualEntry( - const FoodBankRecord( - desc: 'Quinoa', - kcal: 370, - proteinG: 14, - carbsG: 64, - fatG: 6, - grams: 100, - count: 0, - ), - ); - - final merged = await FoodBankService.instance.mergedEntries(); - final descs = merged.map((r) => r.desc).toSet(); - expect(descs, containsAll(['toast', 'Quinoa'])); - }, - ); - - test('addManualEntry upserts by normalized key', () async { - await FoodBankService.instance.addManualEntry( - const FoodBankRecord( - desc: 'Oat', - kcal: 370, - proteinG: 13, - carbsG: 66, - fatG: 7, - grams: 100, - count: 0, - ), - ); - - // Upsert same food with updated kcal. - await FoodBankService.instance.addManualEntry( - const FoodBankRecord( - desc: 'oat', - kcal: 400, - proteinG: 14, - carbsG: 68, - fatG: 8, - grams: 100, - count: 0, - ), - ); - - final merged = await FoodBankService.instance.mergedEntries(); - final oats = merged.where((r) => r.desc.toLowerCase() == 'oat').toList(); - expect(oats.length, 1); - expect(oats.single.kcal, 400); - }); - - test('search includes manual entries', () async { - await FoodBankService.instance.addManualEntry( - const FoodBankRecord( - desc: 'Rare ingredient', - kcal: 50, - proteinG: 1, - carbsG: 10, - fatG: 0.5, - grams: 100, - count: 0, - ), - ); - - final results = await FoodBankService.instance.search('Rare'); - expect(results.any((r) => r.name == 'Rare ingredient'), isTrue); - }); - }); - - // --------------------------------------------------------------------------- - // IO error paths — FileSystemException and FormatException handlers - // --------------------------------------------------------------------------- - - group('FoodBankService IO error paths', () { - late Directory tempDir; - - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_err_'); - FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); - LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); - }); - - tearDown(() async { - FoodBankService.resetForTesting(); - LogStorageService.resetForTesting(); - await tempDir.delete(recursive: true); - }); - - test('readBank returns empty on invalid JSON (FormatException)', () async { - await File( - '${tempDir.path}/food_bank.json', - ).writeAsString('not valid json {{{'); - expect(await FoodBankService.instance.readBank(), isEmpty); - }); - - test( - 'readBank returns empty when file is unreadable (FileSystemException)', - () async { - final bankPath = '${tempDir.path}/food_bank.json'; - await File(bankPath).writeAsString('{}'); - await Process.run('chmod', ['000', bankPath]); - - expect(await FoodBankService.instance.readBank(), isEmpty); - - await Process.run('chmod', ['644', bankPath]); - }, - ); - - test( - 'mergedEntries handles invalid JSON in manual bank (FormatException)', - () async { - await File( - '${tempDir.path}/food_bank_manual.json', - ).writeAsString('not valid json {{{'); - expect(await FoodBankService.instance.mergedEntries(), isEmpty); - }, - ); - - test( - 'mergedEntries handles unreadable manual bank (FileSystemException)', - () async { - final manualPath = '${tempDir.path}/food_bank_manual.json'; - await File(manualPath).writeAsString('{}'); - await Process.run('chmod', ['000', manualPath]); - - expect(await FoodBankService.instance.mergedEntries(), isEmpty); - - await Process.run('chmod', ['644', manualPath]); - }, - ); - }); } diff --git a/app/test/services/log_storage_service_edit_test.dart b/app/test/services/log_storage_service_edit_test.dart new file mode 100644 index 0000000..556ad91 --- /dev/null +++ b/app/test/services/log_storage_service_edit_test.dart @@ -0,0 +1,229 @@ +import 'dart:io'; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _manual = Nutrition( + kcal: 150, + proteinG: 5, + carbsG: 20, + fatG: 3, + grams: 50, + source: 'manual', +); + +void main() { + + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_test_'); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + group('deleteEntry', () { + const entry = FoodEntry( + id: 'del-1', + time: '2026-06-22T12:00:00+02:00', + desc: 'to delete', + grams: 100, + kcal: 300, + proteinG: 10, + carbsG: 30, + fatG: 5, + source: 'manual', + ); + + test('tombstones the matching entry', () async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [entry], + }); + await LogStorageService.instance.deleteEntry('del-1'); + final log = await LogStorageService.instance.readLog(); + expect(log['2026-06-22']!.first.deleted, isTrue); + }); + + test('silently ignores an unknown id', () async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [entry], + }); + await LogStorageService.instance.deleteEntry('no-such-id'); + final log = await LogStorageService.instance.readLog(); + expect(log['2026-06-22']!.first.deleted, isFalse); + }); + + test('does not re-tombstone an already-deleted entry', () async { + const deleted = FoodEntry( + id: 'del-1', + time: '2026-06-22T12:00:00+02:00', + desc: 'to delete', + grams: 100, + kcal: 300, + proteinG: 10, + carbsG: 30, + fatG: 5, + source: 'manual', + deleted: true, + ); + await LogStorageService.instance.writeLog({ + '2026-06-22': [deleted], + }); + await LogStorageService.instance.deleteEntry('del-1'); + // Still deleted, no error thrown. + final log = await LogStorageService.instance.readLog(); + expect(log['2026-06-22']!.first.deleted, isTrue); + }); + }); + group('updateEntry', () { + const original = FoodEntry( + id: 'upd-1', + time: '2026-06-22T12:00:00+02:00', + desc: 'original desc', + grams: 100, + kcal: 300, + proteinG: 10, + carbsG: 30, + fatG: 5, + source: 'manual', + ); + + const updated = FoodEntry( + id: 'upd-1', + time: '2026-06-22T12:00:00+02:00', + desc: 'edited desc', + grams: 200, + kcal: 600, + proteinG: 20, + carbsG: 60, + fatG: 10, + source: 'manual', + ); + + test('replaces the entry by id', () async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [original], + }); + await LogStorageService.instance.updateEntry(original, updated); + final log = await LogStorageService.instance.readLog(); + final e = log['2026-06-22']!.first; + expect(e.desc, 'edited desc'); + expect(e.kcal, 600); + expect(e.proteinG, 20); + }); + + test('replaces legacy null-id entry by time+desc', () async { + const legacy = FoodEntry( + time: '2026-06-22T12:00:00+02:00', + desc: 'legacy entry', + grams: 100, + kcal: 300, + proteinG: 10, + carbsG: 30, + fatG: 5, + source: 'food bank', + ); + const legacyUpdated = FoodEntry( + id: 'new-uuid', + time: '2026-06-22T12:00:00+02:00', + desc: 'legacy entry', + grams: 150, + kcal: 450, + proteinG: 15, + carbsG: 45, + fatG: 8, + source: 'food bank', + ); + await LogStorageService.instance.writeLog({ + '2026-06-22': [legacy], + }); + await LogStorageService.instance.updateEntry(legacy, legacyUpdated); + final log = await LogStorageService.instance.readLog(); + final e = log['2026-06-22']!.first; + expect(e.id, 'new-uuid'); + expect(e.kcal, 450); + }); + + test('silently does nothing when no match is found', () async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [original], + }); + const ghost = FoodEntry( + id: 'ghost', + time: '2026-06-22T12:00:00+02:00', + desc: 'ghost', + grams: 0, + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + source: 'manual', + ); + await LogStorageService.instance.updateEntry(ghost, updated); + final log = await LogStorageService.instance.readLog(); + expect(log['2026-06-22']!.first.desc, 'original desc'); + }); + }); + group('allEntriesNewestFirst', () { + const oldest = FoodEntry( + id: 'oldest', + time: '2026-06-01T08:00:00+02:00', + desc: 'oldest', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 10, + fatG: 2, + source: 'manual', + ); + const newest = FoodEntry( + id: 'newest', + time: '2026-06-22T20:00:00+02:00', + desc: 'newest', + grams: 100, + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 4, + source: 'manual', + ); + const tombstoned = FoodEntry( + id: 'gone', + time: '2026-06-15T12:00:00+02:00', + desc: 'undone', + grams: 100, + kcal: 300, + proteinG: 1, + carbsG: 1, + fatG: 1, + source: 'manual', + deleted: true, + ); + + test( + 'sorts entries across days newest-first and drops tombstones', + () async { + await LogStorageService.instance.writeLog({ + '2026-06-01': [oldest], + '2026-06-15': [tombstoned], + '2026-06-22': [newest], + }); + + final result = await LogStorageService.instance.allEntriesNewestFirst(); + + expect(result.map((e) => e.id), ['newest', 'oldest']); + }, + ); + + test('returns empty for an empty log', () async { + expect(await LogStorageService.instance.allEntriesNewestFirst(), isEmpty); + }); + }); +} diff --git a/app/test/services/log_storage_service_test.dart b/app/test/services/log_storage_service_test.dart index d0a8824..c72e29c 100644 --- a/app/test/services/log_storage_service_test.dart +++ b/app/test/services/log_storage_service_test.dart @@ -1,10 +1,10 @@ import 'dart:io'; -import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/models/local_time.dart'; import 'package:diet_guard_app/models/meal_component.dart'; import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -18,6 +18,7 @@ const _manual = Nutrition( ); void main() { + late Directory tempDir; setUp(() async { @@ -53,7 +54,6 @@ void main() { expect(await LogStorageService.instance.readLog(), isEmpty); }); }); - group('logMeal', () { test('assigns a fresh id and never an hmac', () async { final entry = await LogStorageService.instance.logMeal( @@ -88,14 +88,13 @@ void main() { expect(reloaded.single.components!.single.name, 'bread'); }); - test('two logged meals both persist under today\'s date key', () async { + test("two logged meals both persist under today's date key", () async { await LogStorageService.instance.logMeal('a', _manual); await LogStorageService.instance.logMeal('b', _manual); final entries = await LogStorageService.instance.todayEntries(); expect(entries, hasLength(2)); }); }); - group('undoLastToday', () { test('returns null when today has no entries', () async { expect(await LogStorageService.instance.undoLastToday(), isNull); @@ -118,7 +117,7 @@ void main() { expect(await LogStorageService.instance.todayEntries(), isEmpty); }); - test('never touches a previous day\'s entries', () async { + test("never touches a previous day's entries", () async { final yesterdayKey = localDateKey( DateTime.now().subtract(const Duration(days: 1)), ); @@ -159,9 +158,8 @@ void main() { }, ); }); - group('todayTotalKcal', () { - test('sums kcal across today\'s non-tombstoned entries', () async { + test("sums kcal across today's non-tombstoned entries", () async { await LogStorageService.instance.logMeal('a', _manual); await LogStorageService.instance.logMeal('b', _manual); expect(await LogStorageService.instance.todayTotalKcal(), 300.0); @@ -174,7 +172,6 @@ void main() { expect(await LogStorageService.instance.todayTotalKcal(), 150.0); }); }); - group('loggedSlotsToday', () { test('returns only the slots with a logged entry', () async { await LogStorageService.instance.logMeal('a', _manual, slot: 8); @@ -182,204 +179,4 @@ void main() { expect(await LogStorageService.instance.loggedSlotsToday(), {8}); }); }); - - group('allEntriesNewestFirst', () { - const oldest = FoodEntry( - id: 'oldest', - time: '2026-06-01T08:00:00+02:00', - desc: 'oldest', - grams: 100, - kcal: 100, - proteinG: 5, - carbsG: 10, - fatG: 2, - source: 'manual', - ); - const newest = FoodEntry( - id: 'newest', - time: '2026-06-22T20:00:00+02:00', - desc: 'newest', - grams: 100, - kcal: 200, - proteinG: 10, - carbsG: 20, - fatG: 4, - source: 'manual', - ); - const tombstoned = FoodEntry( - id: 'gone', - time: '2026-06-15T12:00:00+02:00', - desc: 'undone', - grams: 100, - kcal: 300, - proteinG: 1, - carbsG: 1, - fatG: 1, - source: 'manual', - deleted: true, - ); - - test( - 'sorts entries across days newest-first and drops tombstones', - () async { - await LogStorageService.instance.writeLog({ - '2026-06-01': [oldest], - '2026-06-15': [tombstoned], - '2026-06-22': [newest], - }); - - final result = await LogStorageService.instance.allEntriesNewestFirst(); - - expect(result.map((e) => e.id), ['newest', 'oldest']); - }, - ); - - test('returns empty for an empty log', () async { - expect(await LogStorageService.instance.allEntriesNewestFirst(), isEmpty); - }); - }); - - group('deleteEntry', () { - const entry = FoodEntry( - id: 'del-1', - time: '2026-06-22T12:00:00+02:00', - desc: 'to delete', - grams: 100, - kcal: 300, - proteinG: 10, - carbsG: 30, - fatG: 5, - source: 'manual', - ); - - test('tombstones the matching entry', () async { - await LogStorageService.instance.writeLog({ - '2026-06-22': [entry], - }); - await LogStorageService.instance.deleteEntry('del-1'); - final log = await LogStorageService.instance.readLog(); - expect(log['2026-06-22']!.first.deleted, isTrue); - }); - - test('silently ignores an unknown id', () async { - await LogStorageService.instance.writeLog({ - '2026-06-22': [entry], - }); - await LogStorageService.instance.deleteEntry('no-such-id'); - final log = await LogStorageService.instance.readLog(); - expect(log['2026-06-22']!.first.deleted, isFalse); - }); - - test('does not re-tombstone an already-deleted entry', () async { - const deleted = FoodEntry( - id: 'del-1', - time: '2026-06-22T12:00:00+02:00', - desc: 'to delete', - grams: 100, - kcal: 300, - proteinG: 10, - carbsG: 30, - fatG: 5, - source: 'manual', - deleted: true, - ); - await LogStorageService.instance.writeLog({ - '2026-06-22': [deleted], - }); - await LogStorageService.instance.deleteEntry('del-1'); - // Still deleted, no error thrown. - final log = await LogStorageService.instance.readLog(); - expect(log['2026-06-22']!.first.deleted, isTrue); - }); - }); - - group('updateEntry', () { - const original = FoodEntry( - id: 'upd-1', - time: '2026-06-22T12:00:00+02:00', - desc: 'original desc', - grams: 100, - kcal: 300, - proteinG: 10, - carbsG: 30, - fatG: 5, - source: 'manual', - ); - - const updated = FoodEntry( - id: 'upd-1', - time: '2026-06-22T12:00:00+02:00', - desc: 'edited desc', - grams: 200, - kcal: 600, - proteinG: 20, - carbsG: 60, - fatG: 10, - source: 'manual', - ); - - test('replaces the entry by id', () async { - await LogStorageService.instance.writeLog({ - '2026-06-22': [original], - }); - await LogStorageService.instance.updateEntry(original, updated); - final log = await LogStorageService.instance.readLog(); - final e = log['2026-06-22']!.first; - expect(e.desc, 'edited desc'); - expect(e.kcal, 600); - expect(e.proteinG, 20); - }); - - test('replaces legacy null-id entry by time+desc', () async { - const legacy = FoodEntry( - time: '2026-06-22T12:00:00+02:00', - desc: 'legacy entry', - grams: 100, - kcal: 300, - proteinG: 10, - carbsG: 30, - fatG: 5, - source: 'food bank', - ); - const legacyUpdated = FoodEntry( - id: 'new-uuid', - time: '2026-06-22T12:00:00+02:00', - desc: 'legacy entry', - grams: 150, - kcal: 450, - proteinG: 15, - carbsG: 45, - fatG: 8, - source: 'food bank', - ); - await LogStorageService.instance.writeLog({ - '2026-06-22': [legacy], - }); - await LogStorageService.instance.updateEntry(legacy, legacyUpdated); - final log = await LogStorageService.instance.readLog(); - final e = log['2026-06-22']!.first; - expect(e.id, 'new-uuid'); - expect(e.kcal, 450); - }); - - test('silently does nothing when no match is found', () async { - await LogStorageService.instance.writeLog({ - '2026-06-22': [original], - }); - const ghost = FoodEntry( - id: 'ghost', - time: '2026-06-22T12:00:00+02:00', - desc: 'ghost', - grams: 0, - kcal: 0, - proteinG: 0, - carbsG: 0, - fatG: 0, - source: 'manual', - ); - await LogStorageService.instance.updateEntry(ghost, updated); - final log = await LogStorageService.instance.readLog(); - expect(log['2026-06-22']!.first.desc, 'original desc'); - }); - }); } diff --git a/app/test/services/sync_merge_banks_test.dart b/app/test/services/sync_merge_banks_test.dart new file mode 100644 index 0000000..d4fee70 --- /dev/null +++ b/app/test/services/sync_merge_banks_test.dart @@ -0,0 +1,136 @@ +// Table-driven mergeLogs() tests. `union by id` through `algebraic +// properties` are the exact same assertions the pre-migration +// `sync_merge.mergeLogs` had -- routed through `dayLogToLog -> +// crdt_sync.mergeLogs -> logToDayLog` instead, to prove the migration +// preserves the app's merge semantics exactly, mirroring +// `test_sync_merge.py`'s equivalent Python-side proof. + +import 'package:crdt_sync/crdt_sync.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/services/budget_schedule.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/sync_merge.dart'; +import 'package:flutter_test/flutter_test.dart'; + +FoodEntry _entry({ + String? id = 'id-1', + String time = '2026-06-22T08:00:00', + String desc = 'oatmeal', + bool deleted = false, +}) => FoodEntry( + id: id, + time: time, + desc: desc, + grams: 200, + kcal: 300, + proteinG: 10, + carbsG: 50, + fatG: 5, + source: 'manual', + deleted: deleted, +); + +DayLog _mergeDaylogs(DayLog a, DayLog b) => + logToDayLog(mergeLogs(dayLogToLog(a), dayLogToLog(b))); + +const _history = [ + BudgetEntry( + effectiveFrom: '1970-01-01', + kcal: 2200, + editedAt: '2026-07-13T21:15:09.000Z', + ), + BudgetEntry( + effectiveFrom: '2026-07-26', + kcal: 2000, + editedAt: '2026-07-26T10:00:00.000Z', + ), +]; + +void main() { + group('derived food-bank adapters', () { + // Mirror of `test_sync_merge.py`'s TestFoodBankAdapters. + FoodBankRecord apple({required double count, double kcal = 95}) => + FoodBankRecord( + desc: 'Apple', + kcal: kcal, + proteinG: 0.5, + carbsG: 25, + fatG: 0.3, + grams: 180, + count: count, + ); + + test('count is the clock', () { + final log = foodBankToLog({'apple': apple(count: 5)}); + expect(log['apple']!.fields['body']!.$2.wallTimeMs, 5); + }); + + test('the higher count wins', () { + final behind = foodBankToLog({'apple': apple(count: 3, kcal: 90)}); + final ahead = foodBankToLog({'apple': apple(count: 9)}); + final merged = logToFoodBank(mergeLogs(behind, ahead)); + expect(merged['apple']!.count, 9); + expect(merged['apple']!.kcal, 95); + }); + + test('the merge is order independent', () { + final a = foodBankToLog({'apple': apple(count: 3)}); + final b = foodBankToLog({'apple': apple(count: 9)}); + expect( + logToFoodBank(mergeLogs(a, b))['apple']!.count, + logToFoodBank(mergeLogs(b, a))['apple']!.count, + ); + }); + + test('re-merging is idempotent', () { + final once = foodBankToLog({'apple': apple(count: 5)}); + expect(logToFoodBank(mergeLogs(once, once))['apple']!.count, 5); + }); + + test('different foods union', () { + final a = foodBankToLog({'apple': apple(count: 1)}); + final b = foodBankToLog({ + 'pear': const FoodBankRecord( + desc: 'Pear', + kcal: 57, + proteinG: 0, + carbsG: 15, + fatG: 0, + grams: 100, + count: 1, + ), + }); + expect(logToFoodBank(mergeLogs(a, b)).keys.toSet(), {'apple', 'pear'}); + }); + + test('a non-map body is skipped on read', () { + final log = { + 'apple': Record( + id: 'apple', + fields: { + 'body': ('x', budgetHlc(const {'t': ''})), + }, + ), + }; + expect(logToFoodBank(log), isEmpty); + }); + + test('parses pushed wire content', () { + final wire = encodeFoodBankForPush( + foodBankToLog({'apple': apple(count: 5)}), + ); + expect(parseRemoteFoodBank(wire).containsKey('apple'), isTrue); + }); + + test('non-object top level throws FormatException', () { + expect(() => parseRemoteFoodBank('[1,2,3]'), throwsFormatException); + }); + + test('the wire shape matches the Python side', () { + final log = foodBankToLog({'apple': apple(count: 5)}); + expect(log['apple']!.id, 'apple'); + expect(log['apple']!.fields.keys.toList(), ['body']); + }); + }); +} diff --git a/app/test/services/sync_merge_budget_test.dart b/app/test/services/sync_merge_budget_test.dart new file mode 100644 index 0000000..17d2a5f --- /dev/null +++ b/app/test/services/sync_merge_budget_test.dart @@ -0,0 +1,212 @@ +// Table-driven mergeLogs() tests. `union by id` through `algebraic +// properties` are the exact same assertions the pre-migration +// `sync_merge.mergeLogs` had -- routed through `dayLogToLog -> +// crdt_sync.mergeLogs -> logToDayLog` instead, to prove the migration +// preserves the app's merge semantics exactly, mirroring +// `test_sync_merge.py`'s equivalent Python-side proof. + +import 'package:crdt_sync/crdt_sync.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/log_storage_service.dart'; +import 'package:diet_guard_app/services/sync_merge.dart'; +import 'package:flutter_test/flutter_test.dart'; + +FoodEntry _entry({ + String? id = 'id-1', + String time = '2026-06-22T08:00:00', + String desc = 'oatmeal', + bool deleted = false, +}) => FoodEntry( + id: id, + time: time, + desc: desc, + grams: 200, + kcal: 300, + proteinG: 10, + carbsG: 50, + fatG: 5, + source: 'manual', + deleted: deleted, +); + +DayLog _mergeDaylogs(DayLog a, DayLog b) => + logToDayLog(mergeLogs(dayLogToLog(a), dayLogToLog(b))); + +const _history = [ + BudgetEntry( + effectiveFrom: '1970-01-01', + kcal: 2200, + editedAt: '2026-07-13T21:15:09.000Z', + ), + BudgetEntry( + effectiveFrom: '2026-07-26', + kcal: 2000, + editedAt: '2026-07-26T10:00:00.000Z', + ), +]; + +void main() { + group('budget adapters', () { + Map budgetRecord({ + int b = 2000, + String t = '2026-06-22T08:00:00', + double? w, + }) => {'v': 2, 'b': b, 't': t, 'w': ?w}; + + group('budgetHlc', () { + test('same record always yields the same Hlc', () { + final record = budgetRecord(); + expect(budgetHlc(record), budgetHlc({...record})); + }); + + test('malformed t still yields a valid Hlc', () { + final record = budgetRecord(t: 'not-a-timestamp'); + expect(budgetHlc(record).wallTimeMs, 0); + }); + + test('a later t yields a greater Hlc', () { + final earlier = budgetRecord(t: '2020-01-01T00:00:00Z'); + final later = budgetRecord(t: '2030-01-01T00:00:00Z'); + expect(budgetHlc(later) > budgetHlc(earlier), isTrue); + }); + }); + + group('budgetToLog / logToBudget round trip', () { + test('null record yields an empty log', () { + expect(budgetToLog(null), isEmpty); + }); + + test('round trip preserves the budget', () { + final record = budgetRecord(); + final roundTripped = logToBudget(budgetToLog(record)); + expect(roundTripped, isNotNull); + expect(roundTripped!['b'], 2000); + }); + + test('weight never travels', () { + // `w` is PC-local, so it must not enter the shared value map: the + // phone rebuilds that map without it, and before this fix a winning + // phone edit silently deleted the PC's stored weight -- and with it + // the protein target. Matches Python's budget_to_log. + final record = budgetRecord(w: 80); + final roundTripped = logToBudget(budgetToLog(record)); + expect(roundTripped, isNotNull); + expect(roundTripped!.containsKey('w'), isFalse); + }); + + test('history becomes one field per date', () { + final log = budgetToLog(budgetRecord(), _history); + final fields = log[budgetRecordId]!.fields; + expect(fields.containsKey('value'), isTrue); + expect(fields['hist:1970-01-01']!.$1, 2200); + expect(fields['hist:2026-07-26']!.$1, 2000); + }); + + test('history round trips back to entries', () { + final entries = logToHistory(budgetToLog(budgetRecord(), _history)); + expect(entries.map((e) => e.effectiveFrom).toList(), [ + '1970-01-01', + '2026-07-26', + ]); + expect(entries.map((e) => e.kcal).toList(), [2200, 2000]); + }); + + test('no history round trips to nothing', () { + expect(logToHistory(budgetToLog(budgetRecord())), isEmpty); + }); + + test('an empty log has no history', () { + expect(logToHistory({}), isEmpty); + }); + + test('non-history fields are ignored', () { + final log = budgetToLog(budgetRecord()); + expect(logToHistory(log), isEmpty); + }); + + test('a non-int history value is skipped', () { + final log = { + budgetRecordId: Record( + id: budgetRecordId, + fields: { + 'hist:2026-07-26': ('nonsense', budgetHlc(budgetRecord())), + }, + ), + }; + expect(logToHistory(log), isEmpty); + }); + + test('an unparsable edit time still yields a field', () { + const broken = BudgetEntry( + effectiveFrom: '2026-07-26', + kcal: 2000, + editedAt: 'not a timestamp', + ); + final log = budgetToLog(budgetRecord(), [broken]); + expect( + log[budgetRecordId]!.fields.containsKey('hist:2026-07-26'), + isTrue, + ); + }); + + test('history survives a merge with a history-free peer', () { + // The rollout guarantee: mergeRecord is per-field LWW over the + // *union* of field names, so a peer that only pushes `value` leaves + // `hist:*` untouched -- which is why this needed no coordinated + // release. Mirrors the Python test of the same name. + final ours = budgetToLog(budgetRecord(), _history); + final legacy = { + budgetRecordId: Record( + id: budgetRecordId, + fields: { + 'value': ( + {'v': 2, 'b': 2200}, + budgetHlc({'t': '2099-01-01T00:00:00Z'}), + ), + }, + ), + }; + expect(logToHistory(mergeLogs(ours, legacy)), hasLength(2)); + }); + + test('the wire field names match the Python side exactly', () { + // A rename on either side silently splits the two devices' history. + expect(budgetHistoryFieldPrefix, 'hist:'); + expect(budgetRecordId, 'budget'); + }); + + test('round-tripped t reflects the winning Hlc', () { + final record = budgetRecord(); + final roundTripped = logToBudget(budgetToLog(record)); + expect(roundTripped, isNotNull); + expect(roundTripped!['t'], isNotEmpty); + }); + + test('empty log has no budget', () { + expect(logToBudget({}), isNull); + }); + }); + + group('parseRemoteBudget', () { + test('parses pushed budget wire content', () { + final record = budgetRecord(); + final pushed = encodeBudgetForPush(budgetToLog(record)); + final log = parseRemoteBudget(pushed); + expect(log[budgetRecordId]!.id, budgetRecordId); + }); + + test('empty object parses as empty log', () { + expect(parseRemoteBudget('{}'), isEmpty); + }); + + test('non-object top level throws FormatException', () { + expect(() => parseRemoteBudget('[1, 2, 3]'), throwsFormatException); + }); + + test('invalid JSON throws FormatException', () { + expect(() => parseRemoteBudget('not json{{{'), throwsFormatException); + }); + }); + }); +} diff --git a/app/test/services/sync_merge_curated_test.dart b/app/test/services/sync_merge_curated_test.dart new file mode 100644 index 0000000..64316dc --- /dev/null +++ b/app/test/services/sync_merge_curated_test.dart @@ -0,0 +1,166 @@ +// Table-driven mergeLogs() tests. `union by id` through `algebraic +// properties` are the exact same assertions the pre-migration +// `sync_merge.mergeLogs` had -- routed through `dayLogToLog -> +// crdt_sync.mergeLogs -> logToDayLog` instead, to prove the migration +// preserves the app's merge semantics exactly, mirroring +// `test_sync_merge.py`'s equivalent Python-side proof. + +import 'package:crdt_sync/crdt_sync.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/services/budget_schedule.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/sync_merge.dart'; +import 'package:flutter_test/flutter_test.dart'; + +FoodEntry _entry({ + String? id = 'id-1', + String time = '2026-06-22T08:00:00', + String desc = 'oatmeal', + bool deleted = false, +}) => FoodEntry( + id: id, + time: time, + desc: desc, + grams: 200, + kcal: 300, + proteinG: 10, + carbsG: 50, + fatG: 5, + source: 'manual', + deleted: deleted, +); + +DayLog _mergeDaylogs(DayLog a, DayLog b) => + logToDayLog(mergeLogs(dayLogToLog(a), dayLogToLog(b))); + +const _history = [ + BudgetEntry( + effectiveFrom: '1970-01-01', + kcal: 2200, + editedAt: '2026-07-13T21:15:09.000Z', + ), + BudgetEntry( + effectiveFrom: '2026-07-26', + kcal: 2000, + editedAt: '2026-07-26T10:00:00.000Z', + ), +]; + +void main() { + group('curated food-bank adapters', () { + // Mirror of `test_sync_merge.py`'s TestManualBankAdapters. A divergence + // here means a food curated on one device never reaches the other. + FoodBankRecord skyr({double kcal = 120, String? t}) => FoodBankRecord( + desc: 'Skyr', + kcal: kcal, + proteinG: 20, + carbsG: 5, + fatG: 0.5, + grams: 150, + count: 0, + editedAt: t ?? '2026-07-26T10:00:00.000Z', + ); + + test('round trips a record', () { + final back = logToManualBank(manualBankToLog({'skyr': skyr()})); + expect(back['skyr']!.desc, 'Skyr'); + expect(back['skyr']!.kcal, 120); + }); + + test('the edit stamp does not travel inside the body', () { + final log = manualBankToLog({'skyr': skyr()}); + expect( + (log['skyr']!.fields['body']!.$1! as Map).containsKey('t'), + isFalse, + ); + }); + + test('edit time is reconstructed from the Hlc', () { + final back = logToManualBank(manualBankToLog({'skyr': skyr()})); + expect(back['skyr']!.editedAt, isNotNull); + }); + + test('a missing edit stamp falls back to the epoch', () { + final log = manualBankToLog({ + 'skyr': const FoodBankRecord( + desc: 'Skyr', + kcal: 1, + proteinG: 0, + carbsG: 0, + fatG: 0, + grams: 0, + count: 0, + ), + }); + expect(log['skyr']!.fields['body']!.$2.wallTimeMs, 0); + }); + + test('a newer edit wins the merge', () { + final older = manualBankToLog({'skyr': skyr()}); + final newer = manualBankToLog({ + 'skyr': skyr(kcal: 999, t: '2999-01-01T00:00:00.000Z'), + }); + expect(logToManualBank(mergeLogs(older, newer))['skyr']!.kcal, 999); + }); + + test('different foods union rather than replace', () { + final a = manualBankToLog({'skyr': skyr()}); + final b = manualBankToLog({ + 'kefir': const FoodBankRecord( + desc: 'Kefir', + kcal: 60, + proteinG: 3, + carbsG: 4, + fatG: 2, + grams: 100, + count: 0, + editedAt: '2026-07-26T10:00:00.000Z', + ), + }); + expect(logToManualBank(mergeLogs(a, b)).keys.toSet(), {'skyr', 'kefir'}); + }); + + test('a non-map body is skipped on read', () { + final log = { + 'skyr': Record( + id: 'skyr', + fields: { + 'body': ('nope', budgetHlc(const {'t': ''})), + }, + ), + }; + expect(logToManualBank(log), isEmpty); + }); + + test('a tombstoned record is dropped on read', () { + final log = { + 'skyr': Record( + id: 'skyr', + fields: { + 'body': (skyr().toJson(), budgetHlc(const {'t': ''})), + }, + deleted: true, + ), + }; + expect(logToManualBank(log), isEmpty); + }); + + test('parses pushed wire content', () { + final wire = encodeManualBankForPush(manualBankToLog({'skyr': skyr()})); + expect(parseRemoteManualBank(wire).containsKey('skyr'), isTrue); + }); + + test('non-object top level throws FormatException', () { + expect(() => parseRemoteManualBank('[1,2,3]'), throwsFormatException); + }); + + test('the wire shape matches the Python side', () { + // Python uses the same `body` field name and the same normalized-name + // record id; a rename on either side silently splits the two banks. + final log = manualBankToLog({'skyr': skyr()}); + expect(log['skyr']!.id, 'skyr'); + expect(log['skyr']!.fields.keys.toList(), ['body']); + }); + }); +} diff --git a/app/test/services/sync_merge_test.dart b/app/test/services/sync_merge_test.dart index 48298ca..c196502 100644 --- a/app/test/services/sync_merge_test.dart +++ b/app/test/services/sync_merge_test.dart @@ -5,13 +5,10 @@ // preserves the app's merge semantics exactly, mirroring // `test_sync_merge.py`'s equivalent Python-side proof. -import 'dart:convert'; - import 'package:crdt_sync/crdt_sync.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/services/log_storage_service.dart'; import 'package:diet_guard_app/services/budget_schedule.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/sync_merge.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -53,7 +50,7 @@ void main() { group('union by id', () { test('disjoint logs union into one', () { final a = { - '2026-06-22': [_entry(id: 'a', time: '2026-06-22T08:00:00')], + '2026-06-22': [_entry(id: 'a')], }; final b = { '2026-06-22': [_entry(id: 'b', time: '2026-06-22T12:00:00')], @@ -115,7 +112,6 @@ void main() { expect(merged['2026-06-20'], hasLength(2)); }); }); - group('tombstone wins', () { test('tombstone beats a non-deleted copy either order', () { final normal = _entry(id: 'x'); @@ -155,7 +151,6 @@ void main() { expect(merged['2026-06-22']!.single.deleted, isTrue); }); }); - group('rebucketing and ordering', () { test( "entry is filed under its own time's date, not the arrival bucket", @@ -183,7 +178,7 @@ void main() { test("a day's entries are sorted oldest-first", () { final late = _entry(id: 'late', time: '2026-06-22T20:00:00'); - final early = _entry(id: 'early', time: '2026-06-22T08:00:00'); + final early = _entry(id: 'early'); final merged = _mergeDaylogs( { '2026-06-22': [late], @@ -198,7 +193,6 @@ void main() { ]); }); }); - group('algebraic properties', () { test('merge is commutative', () { final a = { @@ -235,460 +229,4 @@ void main() { expect(_mergeDaylogs({}, {}), isEmpty); }); }); - - group('entryHlc', () { - test('same entry always yields the same Hlc', () { - expect(entryHlc(_entry()), entryHlc(_entry())); - }); - - test('malformed time still yields a valid Hlc', () { - expect(entryHlc(_entry(time: 'not-a-timestamp')).wallTimeMs, 0); - }); - }); - - group('legacyEntryId', () { - test('same time and desc yields the same id', () { - final a = _entry(time: '2026-06-20T08:00:00', desc: 'toast'); - final b = _entry(time: '2026-06-20T08:00:00', desc: 'toast'); - expect(legacyEntryId(a), legacyEntryId(b)); - }); - - test('different desc yields a different id', () { - final a = _entry(time: '2026-06-20T08:00:00', desc: 'toast'); - final b = _entry(time: '2026-06-20T08:00:00', desc: 'eggs'); - expect(legacyEntryId(a), isNot(legacyEntryId(b))); - }); - }); - - group('entry <-> Record round trip', () { - test('round trip preserves all fields', () { - final entry = _entry(id: 'x'); - final roundTripped = recordToEntry(entryToRecord(entry)); - expect(roundTripped.toSyncJson(), entry.toSyncJson()); - }); - - test('round trip of a deleted entry preserves the tombstone', () { - final entry = _entry(id: 'x', deleted: true); - expect(recordToEntry(entryToRecord(entry)).deleted, isTrue); - }); - - test('legacy entry gets a derived id on round trip', () { - final entry = _entry( - id: null, - time: '2026-06-20T08:00:00', - desc: 'toast', - ); - final roundTripped = recordToEntry(entryToRecord(entry)); - expect(roundTripped.id, legacyEntryId(entry)); - }); - - test('a Record with no body field falls back to an empty body', () { - const record = Record(id: 'x', fields: {}); - final entry = recordToEntry(record); - expect(entry.id, 'x'); - expect(entry.desc, isEmpty); - }); - }); - - group('parseRemoteLog', () { - test('parses new-format wire content', () { - final entry = _entry(id: 'x'); - final pushed = encodeLogForPush({'x': entryToRecord(entry)}); - final log = parseRemoteLog(pushed); - expect(log['x']!.id, 'x'); - }); - - test('parses old DayLog format for backward compatibility', () { - final entry = _entry(id: 'x'); - final oldFormat = jsonEncode({ - '2026-06-22': [entry.toSyncJson()], - }); - final log = parseRemoteLog(oldFormat); - expect(log['x']!.id, 'x'); - }); - - test('empty object parses as empty log', () { - expect(parseRemoteLog('{}'), isEmpty); - }); - - test('non-object top level throws FormatException', () { - expect(() => parseRemoteLog('[1, 2, 3]'), throwsFormatException); - }); - - test('old-format day not a list throws FormatException', () { - expect( - () => parseRemoteLog('{"2026-06-22": "not-a-list"}'), - throwsFormatException, - ); - }); - - test('invalid JSON throws FormatException', () { - expect(() => parseRemoteLog('not json{{{'), throwsFormatException); - }); - }); - - group('derived food-bank adapters', () { - // Mirror of `test_sync_merge.py`'s TestFoodBankAdapters. - FoodBankRecord apple({required double count, double kcal = 95}) => - FoodBankRecord( - desc: 'Apple', - kcal: kcal, - proteinG: 0.5, - carbsG: 25, - fatG: 0.3, - grams: 180, - count: count, - ); - - test('count is the clock', () { - final log = foodBankToLog({'apple': apple(count: 5)}); - expect(log['apple']!.fields['body']!.$2.wallTimeMs, 5); - }); - - test('the higher count wins', () { - final behind = foodBankToLog({'apple': apple(count: 3, kcal: 90)}); - final ahead = foodBankToLog({'apple': apple(count: 9, kcal: 95)}); - final merged = logToFoodBank(mergeLogs(behind, ahead)); - expect(merged['apple']!.count, 9); - expect(merged['apple']!.kcal, 95); - }); - - test('the merge is order independent', () { - final a = foodBankToLog({'apple': apple(count: 3)}); - final b = foodBankToLog({'apple': apple(count: 9)}); - expect( - logToFoodBank(mergeLogs(a, b))['apple']!.count, - logToFoodBank(mergeLogs(b, a))['apple']!.count, - ); - }); - - test('re-merging is idempotent', () { - final once = foodBankToLog({'apple': apple(count: 5)}); - expect(logToFoodBank(mergeLogs(once, once))['apple']!.count, 5); - }); - - test('different foods union', () { - final a = foodBankToLog({'apple': apple(count: 1)}); - final b = foodBankToLog({ - 'pear': FoodBankRecord( - desc: 'Pear', - kcal: 57, - proteinG: 0, - carbsG: 15, - fatG: 0, - grams: 100, - count: 1, - ), - }); - expect(logToFoodBank(mergeLogs(a, b)).keys.toSet(), {'apple', 'pear'}); - }); - - test('a non-map body is skipped on read', () { - final log = { - 'apple': Record( - id: 'apple', - fields: { - 'body': ('x', budgetHlc(const {'t': ''})), - }, - ), - }; - expect(logToFoodBank(log), isEmpty); - }); - - test('parses pushed wire content', () { - final wire = encodeFoodBankForPush( - foodBankToLog({'apple': apple(count: 5)}), - ); - expect(parseRemoteFoodBank(wire).containsKey('apple'), isTrue); - }); - - test('non-object top level throws FormatException', () { - expect(() => parseRemoteFoodBank('[1,2,3]'), throwsFormatException); - }); - - test('the wire shape matches the Python side', () { - final log = foodBankToLog({'apple': apple(count: 5)}); - expect(log['apple']!.id, 'apple'); - expect(log['apple']!.fields.keys.toList(), ['body']); - }); - }); - - group('curated food-bank adapters', () { - // Mirror of `test_sync_merge.py`'s TestManualBankAdapters. A divergence - // here means a food curated on one device never reaches the other. - FoodBankRecord skyr({double kcal = 120, String? t}) => FoodBankRecord( - desc: 'Skyr', - kcal: kcal, - proteinG: 20, - carbsG: 5, - fatG: 0.5, - grams: 150, - count: 0, - editedAt: t ?? '2026-07-26T10:00:00.000Z', - ); - - test('round trips a record', () { - final back = logToManualBank(manualBankToLog({'skyr': skyr()})); - expect(back['skyr']!.desc, 'Skyr'); - expect(back['skyr']!.kcal, 120); - }); - - test('the edit stamp does not travel inside the body', () { - final log = manualBankToLog({'skyr': skyr()}); - expect( - (log['skyr']!.fields['body']!.$1 as Map).containsKey('t'), - isFalse, - ); - }); - - test('edit time is reconstructed from the Hlc', () { - final back = logToManualBank(manualBankToLog({'skyr': skyr()})); - expect(back['skyr']!.editedAt, isNotNull); - }); - - test('a missing edit stamp falls back to the epoch', () { - final log = manualBankToLog({ - 'skyr': const FoodBankRecord( - desc: 'Skyr', - kcal: 1, - proteinG: 0, - carbsG: 0, - fatG: 0, - grams: 0, - count: 0, - ), - }); - expect(log['skyr']!.fields['body']!.$2.wallTimeMs, 0); - }); - - test('a newer edit wins the merge', () { - final older = manualBankToLog({'skyr': skyr()}); - final newer = manualBankToLog({ - 'skyr': skyr(kcal: 999, t: '2999-01-01T00:00:00.000Z'), - }); - expect(logToManualBank(mergeLogs(older, newer))['skyr']!.kcal, 999); - }); - - test('different foods union rather than replace', () { - final a = manualBankToLog({'skyr': skyr()}); - final b = manualBankToLog({ - 'kefir': FoodBankRecord( - desc: 'Kefir', - kcal: 60, - proteinG: 3, - carbsG: 4, - fatG: 2, - grams: 100, - count: 0, - editedAt: '2026-07-26T10:00:00.000Z', - ), - }); - expect(logToManualBank(mergeLogs(a, b)).keys.toSet(), {'skyr', 'kefir'}); - }); - - test('a non-map body is skipped on read', () { - final log = { - 'skyr': Record( - id: 'skyr', - fields: { - 'body': ('nope', budgetHlc(const {'t': ''})), - }, - ), - }; - expect(logToManualBank(log), isEmpty); - }); - - test('a tombstoned record is dropped on read', () { - final log = { - 'skyr': Record( - id: 'skyr', - fields: { - 'body': (skyr().toJson(), budgetHlc(const {'t': ''})), - }, - deleted: true, - ), - }; - expect(logToManualBank(log), isEmpty); - }); - - test('parses pushed wire content', () { - final wire = encodeManualBankForPush(manualBankToLog({'skyr': skyr()})); - expect(parseRemoteManualBank(wire).containsKey('skyr'), isTrue); - }); - - test('non-object top level throws FormatException', () { - expect(() => parseRemoteManualBank('[1,2,3]'), throwsFormatException); - }); - - test('the wire shape matches the Python side', () { - // Python uses the same `body` field name and the same normalized-name - // record id; a rename on either side silently splits the two banks. - final log = manualBankToLog({'skyr': skyr()}); - expect(log['skyr']!.id, 'skyr'); - expect(log['skyr']!.fields.keys.toList(), ['body']); - }); - }); - - group('budget adapters', () { - Map budgetRecord({ - int b = 2000, - String t = '2026-06-22T08:00:00', - double? w, - }) => {'v': 2, 'b': b, 't': t, if (w != null) 'w': w}; - - group('budgetHlc', () { - test('same record always yields the same Hlc', () { - final record = budgetRecord(); - expect(budgetHlc(record), budgetHlc({...record})); - }); - - test('malformed t still yields a valid Hlc', () { - final record = budgetRecord(t: 'not-a-timestamp'); - expect(budgetHlc(record).wallTimeMs, 0); - }); - - test('a later t yields a greater Hlc', () { - final earlier = budgetRecord(t: '2020-01-01T00:00:00Z'); - final later = budgetRecord(t: '2030-01-01T00:00:00Z'); - expect(budgetHlc(later) > budgetHlc(earlier), isTrue); - }); - }); - - group('budgetToLog / logToBudget round trip', () { - test('null record yields an empty log', () { - expect(budgetToLog(null), isEmpty); - }); - - test('round trip preserves the budget', () { - final record = budgetRecord(); - final roundTripped = logToBudget(budgetToLog(record)); - expect(roundTripped, isNotNull); - expect(roundTripped!['b'], 2000); - }); - - test('weight never travels', () { - // `w` is PC-local, so it must not enter the shared value map: the - // phone rebuilds that map without it, and before this fix a winning - // phone edit silently deleted the PC's stored weight -- and with it - // the protein target. Matches Python's budget_to_log. - final record = budgetRecord(w: 80); - final roundTripped = logToBudget(budgetToLog(record)); - expect(roundTripped, isNotNull); - expect(roundTripped!.containsKey('w'), isFalse); - }); - - test('history becomes one field per date', () { - final log = budgetToLog(budgetRecord(), _history); - final fields = log[budgetRecordId]!.fields; - expect(fields.containsKey('value'), isTrue); - expect(fields['hist:1970-01-01']!.$1, 2200); - expect(fields['hist:2026-07-26']!.$1, 2000); - }); - - test('history round trips back to entries', () { - final entries = logToHistory(budgetToLog(budgetRecord(), _history)); - expect(entries.map((e) => e.effectiveFrom).toList(), [ - '1970-01-01', - '2026-07-26', - ]); - expect(entries.map((e) => e.kcal).toList(), [2200, 2000]); - }); - - test('no history round trips to nothing', () { - expect(logToHistory(budgetToLog(budgetRecord())), isEmpty); - }); - - test('an empty log has no history', () { - expect(logToHistory({}), isEmpty); - }); - - test('non-history fields are ignored', () { - final log = budgetToLog(budgetRecord()); - expect(logToHistory(log), isEmpty); - }); - - test('a non-int history value is skipped', () { - final log = { - budgetRecordId: Record( - id: budgetRecordId, - fields: { - 'hist:2026-07-26': ('nonsense', budgetHlc(budgetRecord())), - }, - ), - }; - expect(logToHistory(log), isEmpty); - }); - - test('an unparsable edit time still yields a field', () { - const broken = BudgetEntry( - effectiveFrom: '2026-07-26', - kcal: 2000, - editedAt: 'not a timestamp', - ); - final log = budgetToLog(budgetRecord(), [broken]); - expect( - log[budgetRecordId]!.fields.containsKey('hist:2026-07-26'), - isTrue, - ); - }); - - test('history survives a merge with a history-free peer', () { - // The rollout guarantee: mergeRecord is per-field LWW over the - // *union* of field names, so a peer that only pushes `value` leaves - // `hist:*` untouched -- which is why this needed no coordinated - // release. Mirrors the Python test of the same name. - final ours = budgetToLog(budgetRecord(), _history); - final legacy = { - budgetRecordId: Record( - id: budgetRecordId, - fields: { - 'value': ( - {'v': 2, 'b': 2200}, - budgetHlc({'t': '2099-01-01T00:00:00Z'}), - ), - }, - ), - }; - expect(logToHistory(mergeLogs(ours, legacy)), hasLength(2)); - }); - - test('the wire field names match the Python side exactly', () { - // A rename on either side silently splits the two devices' history. - expect(budgetHistoryFieldPrefix, 'hist:'); - expect(budgetRecordId, 'budget'); - }); - - test('round-tripped t reflects the winning Hlc', () { - final record = budgetRecord(); - final roundTripped = logToBudget(budgetToLog(record)); - expect(roundTripped, isNotNull); - expect(roundTripped!['t'], isNotEmpty); - }); - - test('empty log has no budget', () { - expect(logToBudget({}), isNull); - }); - }); - - group('parseRemoteBudget', () { - test('parses pushed budget wire content', () { - final record = budgetRecord(); - final pushed = encodeBudgetForPush(budgetToLog(record)); - final log = parseRemoteBudget(pushed); - expect(log[budgetRecordId]!.id, budgetRecordId); - }); - - test('empty object parses as empty log', () { - expect(parseRemoteBudget('{}'), isEmpty); - }); - - test('non-object top level throws FormatException', () { - expect(() => parseRemoteBudget('[1, 2, 3]'), throwsFormatException); - }); - - test('invalid JSON throws FormatException', () { - expect(() => parseRemoteBudget('not json{{{'), throwsFormatException); - }); - }); - }); } diff --git a/app/test/services/sync_merge_wire_test.dart b/app/test/services/sync_merge_wire_test.dart new file mode 100644 index 0000000..d4e23d0 --- /dev/null +++ b/app/test/services/sync_merge_wire_test.dart @@ -0,0 +1,139 @@ +// Table-driven mergeLogs() tests. `union by id` through `algebraic +// properties` are the exact same assertions the pre-migration +// `sync_merge.mergeLogs` had -- routed through `dayLogToLog -> +// crdt_sync.mergeLogs -> logToDayLog` instead, to prove the migration +// preserves the app's merge semantics exactly, mirroring +// `test_sync_merge.py`'s equivalent Python-side proof. + +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.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/log_storage_service.dart'; +import 'package:diet_guard_app/services/sync_merge.dart'; +import 'package:flutter_test/flutter_test.dart'; + +FoodEntry _entry({ + String? id = 'id-1', + String time = '2026-06-22T08:00:00', + String desc = 'oatmeal', + bool deleted = false, +}) => FoodEntry( + id: id, + time: time, + desc: desc, + grams: 200, + kcal: 300, + proteinG: 10, + carbsG: 50, + fatG: 5, + source: 'manual', + deleted: deleted, +); + +DayLog _mergeDaylogs(DayLog a, DayLog b) => + logToDayLog(mergeLogs(dayLogToLog(a), dayLogToLog(b))); + +const _history = [ + BudgetEntry( + effectiveFrom: '1970-01-01', + kcal: 2200, + editedAt: '2026-07-13T21:15:09.000Z', + ), + BudgetEntry( + effectiveFrom: '2026-07-26', + kcal: 2000, + editedAt: '2026-07-26T10:00:00.000Z', + ), +]; + +void main() { + group('entryHlc', () { + test('same entry always yields the same Hlc', () { + expect(entryHlc(_entry()), entryHlc(_entry())); + }); + + test('malformed time still yields a valid Hlc', () { + expect(entryHlc(_entry(time: 'not-a-timestamp')).wallTimeMs, 0); + }); + }); + group('legacyEntryId', () { + test('same time and desc yields the same id', () { + final a = _entry(time: '2026-06-20T08:00:00', desc: 'toast'); + final b = _entry(time: '2026-06-20T08:00:00', desc: 'toast'); + expect(legacyEntryId(a), legacyEntryId(b)); + }); + + test('different desc yields a different id', () { + final a = _entry(time: '2026-06-20T08:00:00', desc: 'toast'); + final b = _entry(time: '2026-06-20T08:00:00', desc: 'eggs'); + expect(legacyEntryId(a), isNot(legacyEntryId(b))); + }); + }); + group('entry <-> Record round trip', () { + test('round trip preserves all fields', () { + final entry = _entry(id: 'x'); + final roundTripped = recordToEntry(entryToRecord(entry)); + expect(roundTripped.toSyncJson(), entry.toSyncJson()); + }); + + test('round trip of a deleted entry preserves the tombstone', () { + final entry = _entry(id: 'x', deleted: true); + expect(recordToEntry(entryToRecord(entry)).deleted, isTrue); + }); + + test('legacy entry gets a derived id on round trip', () { + final entry = _entry( + id: null, + time: '2026-06-20T08:00:00', + desc: 'toast', + ); + final roundTripped = recordToEntry(entryToRecord(entry)); + expect(roundTripped.id, legacyEntryId(entry)); + }); + + test('a Record with no body field falls back to an empty body', () { + const record = Record(id: 'x', fields: {}); + final entry = recordToEntry(record); + expect(entry.id, 'x'); + expect(entry.desc, isEmpty); + }); + }); + group('parseRemoteLog', () { + test('parses new-format wire content', () { + final entry = _entry(id: 'x'); + final pushed = encodeLogForPush({'x': entryToRecord(entry)}); + final log = parseRemoteLog(pushed); + expect(log['x']!.id, 'x'); + }); + + test('parses old DayLog format for backward compatibility', () { + final entry = _entry(id: 'x'); + final oldFormat = jsonEncode({ + '2026-06-22': [entry.toSyncJson()], + }); + final log = parseRemoteLog(oldFormat); + expect(log['x']!.id, 'x'); + }); + + test('empty object parses as empty log', () { + expect(parseRemoteLog('{}'), isEmpty); + }); + + test('non-object top level throws FormatException', () { + expect(() => parseRemoteLog('[1, 2, 3]'), throwsFormatException); + }); + + test('old-format day not a list throws FormatException', () { + expect( + () => parseRemoteLog('{"2026-06-22": "not-a-list"}'), + throwsFormatException, + ); + }); + + test('invalid JSON throws FormatException', () { + expect(() => parseRemoteLog('not json{{{'), throwsFormatException); + }); + }); +} diff --git a/app/test/services/sync_service_budget_test.dart b/app/test/services/sync_service_budget_test.dart new file mode 100644 index 0000000..2b55790 --- /dev/null +++ b/app/test/services/sync_service_budget_test.dart @@ -0,0 +1,127 @@ +// Mirrors `test_sync.py`'s `TestRunSync` and `TestSyncBudget` cases +// (own-id-skip, no-prior-push, non-object payload, corrupt JSON, remote +// merge, food bank rebuild, budget last-writer-wins), plus one Dart-specific +// that has no PC-side equivalent. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/sync_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'sync_service_test_support.dart'; + +void main() { + + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_sync_test_'); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); + BudgetHistoryService.resetForTesting( + store: FileDocumentStore(tempDir), + ); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + AppSettingsService.resetForTesting(); + BudgetHistoryService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + group('budget sync', () { + test( + 'pushes the local budget when no other devices have synced', + () async { + await AppSettingsService.instance.saveDailyKcalGoal(2000); + final fake = FakeGitHub(); + await runSync(fake.buildClient()); + + expect( + fake.putsByPath.containsKey( + 'diet-guard-sync/devices/phone/budget.json', + ), + isTrue, + ); + }, + ); + + test('remote-only budget is adopted locally', () async { + final remoteJson = remoteBudgetJson( + kcal: 1800, + t: '2026-01-01T09:00:00', + ); + final fake = FakeGitHub( + deviceDirs: const ['pc'], + files: {'diet-guard-sync/devices/pc/budget.json': remoteJson}, + ); + await runSync(fake.buildClient()); + expect(AppSettingsService.dailyKcalGoal, 1800); + }); + + test('a local edit later than a remote edit wins', () async { + await AppSettingsService.instance.saveDailyKcalGoal(1500); // now + final remoteJson = remoteBudgetJson( + kcal: 1800, + t: '2020-01-01T09:00:00', + ); + final fake = FakeGitHub( + deviceDirs: const ['pc'], + files: {'diet-guard-sync/devices/pc/budget.json': remoteJson}, + ); + await runSync(fake.buildClient()); + expect(AppSettingsService.dailyKcalGoal, 1500); + }); + + test('a remote edit later than a local edit wins', () async { + await AppSettingsService.instance.saveDailyKcalGoal(1500); // now + final remoteJson = remoteBudgetJson( + kcal: 1800, + t: '2999-01-01T09:00:00', + ); + final fake = FakeGitHub( + deviceDirs: const ['pc'], + files: {'diet-guard-sync/devices/pc/budget.json': remoteJson}, + ); + await runSync(fake.buildClient()); + expect(AppSettingsService.dailyKcalGoal, 1800); + }); + + test('a malformed remote budget is skipped, not a crash', () async { + await AppSettingsService.instance.saveDailyKcalGoal(2000); + final fake = FakeGitHub( + deviceDirs: const ['pc'], + files: const { + 'diet-guard-sync/devices/pc/budget.json': '{not valid json', + }, + ); + await runSync(fake.buildClient()); + expect(AppSettingsService.dailyKcalGoal, 2000); + }); + + test( + 'a fresh install with no budget ever set contributes nothing', + () async { + final fake = FakeGitHub(); + await runSync(fake.buildClient()); + + final pushed = + fake.putsByPath['diet-guard-sync/devices/phone/budget.json']!; + final pushedText = utf8.decode( + base64.decode(pushed['content'] as String), + ); + expect(jsonDecode(pushedText), isEmpty); + expect(AppSettingsService.dailyKcalGoal, 2200); + }, + ); + }); +} diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart index 48bc749..303ba3b 100644 --- a/app/test/services/sync_service_test.dart +++ b/app/test/services/sync_service_test.dart @@ -6,118 +6,20 @@ import 'dart:convert'; import 'dart:io'; -import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/models/food_entry.dart'; -import 'package:diet_guard_app/models/nutrition.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; -import 'package:diet_guard_app/services/sync_merge.dart'; import 'package:diet_guard_app/services/sync_service.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -/// Builds the wire text a remote device would push for a given budget edit. -String _remoteBudgetJson({required int kcal, required String t}) { - final record = {'v': 2, 'b': kcal, 't': t}; - final log = budgetToLog(record); - return jsonEncode({ - for (final entry in log.entries) entry.key: entry.value.toJson(), - }); -} - -const _manual = Nutrition( - kcal: 200, - proteinG: 10, - carbsG: 20, - fatG: 5, - grams: 100, - source: 'manual', -); - -/// A tiny in-memory stand-in for the GitHub Contents API, scoped to exactly -/// the calls [runSync] makes via crdt_sync's [GitHubClient]: list `devices`, -/// get a device's file text (which crdt_sync also uses internally to -/// resolve this device's own existing sha before a push), and put this -/// device's file text. -class _FakeGitHub { - _FakeGitHub({this.deviceDirs = const [], Map? files}) - : files = {...?files}; - - final List deviceDirs; - final Map files; - - /// Every `diet-guard-sync/devices//food_log.json` path this fake - /// actually served a file-content GET for. - final List fileGets = []; - - /// Every PUT this fake received, decoded. - final List> puts = []; - - /// Same PUTs, keyed by the repo-relative path they targeted -- lets a - /// test pick out the food-log push from the budget push, now that a - /// sync tick does both. - final Map> putsByPath = {}; - - GitHubClient buildClient() => GitHubClient( - owner: 'o', - repo: 'r', - token: 't', - httpClient: MockClient(_handle), - ); - - Future _handle(http.Request req) async { - if (req.url.path == '/repos/o/r') { - // Repo-existence probe: crdt_sync's GitHubClient calls this to - // disambiguate "path unused yet" (still 404, but a real repo) from - // "repo missing/inaccessible" whenever a content-path GET 404s. - return http.Response('{}', 200); - } - final path = req.url.path.replaceFirst('/repos/o/r/contents/', ''); - if (req.method == 'PUT') { - final body = jsonDecode(req.body) as Map; - puts.add(body); - putsByPath[path] = body; - return http.Response('{}', 200); - } - if (path == 'diet-guard-sync/devices') { - return http.Response( - jsonEncode([ - for (final d in deviceDirs) - { - 'type': 'dir', - 'name': d, - 'path': 'diet-guard-sync/devices/$d', - 'sha': 'd-$d', - }, - ]), - 200, - ); - } - if (!files.containsKey(path)) return http.Response('', 404); - fileGets.add(path); - final content = base64.encode(utf8.encode(files[path]!)); - // Real GitHub always returns `sha` alongside `content` for a - // get-file-contents call -- crdt_sync's GitHubClient reads it back to - // resolve this device's own existing sha before a push. - final segments = path.split('/'); - final sha = - segments.length == 4 && - segments[0] == 'diet-guard-sync' && - segments[1] == 'devices' - ? 'f-${segments[2]}' - : null; - return http.Response( - jsonEncode({'content': content, if (sha != null) 'sha': sha}), - 200, - ); - } -} +import 'sync_service_test_support.dart'; void main() { + late Directory tempDir; setUp(() async { @@ -139,8 +41,8 @@ void main() { }); test('pushes the local log when no other devices have synced', () async { - await LogStorageService.instance.logMeal('oatmeal', _manual); - final fake = _FakeGitHub(); + await LogStorageService.instance.logMeal('oatmeal', manualNutrition); + final fake = FakeGitHub(); final merged = await runSync(fake.buildClient()); expect(merged.values.expand((e) => e).length, 1); @@ -150,9 +52,8 @@ void main() { // "seen rev X" against a log it never received. expect(fake.puts, hasLength(5)); }); - test("skips its own device id ('phone') when listing", () async { - final fake = _FakeGitHub( + final fake = FakeGitHub( deviceDirs: const ['pc', 'phone'], files: const { 'diet-guard-sync/devices/pc/food_log.json': '{}', @@ -167,24 +68,21 @@ void main() { 'diet-guard-sync/devices/pc/budget.json', ]); }); - test('skips a device with no pushed file yet', () async { - final fake = _FakeGitHub(deviceDirs: const ['pc']); + final fake = FakeGitHub(deviceDirs: const ['pc']); final merged = await runSync(fake.buildClient()); expect(merged, isEmpty); }); - test('ignores a device whose pushed file is not a JSON object', () async { - final fake = _FakeGitHub( + final fake = FakeGitHub( deviceDirs: const ['pc'], files: const {'diet-guard-sync/devices/pc/food_log.json': '[]'}, ); final merged = await runSync(fake.buildClient()); expect(merged, isEmpty); }); - test('skips a device whose pushed file is corrupt json', () async { - final fake = _FakeGitHub( + final fake = FakeGitHub( deviceDirs: const ['pc'], files: const { 'diet-guard-sync/devices/pc/food_log.json': '{not valid json', @@ -193,7 +91,6 @@ void main() { final merged = await runSync(fake.buildClient()); expect(merged, isEmpty); }); - test( "merges in a remote device's entries (old pre-migration format)", () async { @@ -212,7 +109,7 @@ void main() { }, ], }); - final fake = _FakeGitHub( + final fake = FakeGitHub( deviceDirs: const ['pc'], files: {'diet-guard-sync/devices/pc/food_log.json': remoteLog}, ); @@ -221,7 +118,6 @@ void main() { expect(descs, contains('pc meal')); }, ); - test( "merges in a remote device's entries (new Record-based format)", () async { @@ -247,7 +143,7 @@ void main() { }, ).toJson(), }); - final fake = _FakeGitHub( + final fake = FakeGitHub( deviceDirs: const ['pc'], files: {'diet-guard-sync/devices/pc/food_log.json': remoteLog}, ); @@ -256,22 +152,20 @@ void main() { expect(descs, contains('pc meal')); }, ); - test('rebuilds the food bank after merge', () async { - await LogStorageService.instance.logMeal('oatmeal', _manual); - final fake = _FakeGitHub(); + await LogStorageService.instance.logMeal('oatmeal', manualNutrition); + final fake = FakeGitHub(); await runSync(fake.buildClient()); final bank = await FoodBankService.instance.readBank(); expect(bank.containsKey('oatmeal'), isTrue); }); - test('pushes a payload without hmac', () async { await LogStorageService.instance.logMeal( 'oatmeal', - _manual, + manualNutrition, ); - final fake = _FakeGitHub(); + final fake = FakeGitHub(); await runSync(fake.buildClient()); final pushed = @@ -279,10 +173,9 @@ void main() { final pushedText = utf8.decode(base64.decode(pushed['content'] as String)); expect(pushedText, isNot(contains('hmac'))); }); - test('pushes in the new Record-based wire format', () async { - await LogStorageService.instance.logMeal('oatmeal', _manual); - final fake = _FakeGitHub(); + await LogStorageService.instance.logMeal('oatmeal', manualNutrition); + final fake = FakeGitHub(); await runSync(fake.buildClient()); final pushed = @@ -293,9 +186,8 @@ void main() { expect(record, containsPair('id', isA())); expect(record, contains('fields')); }); - test("reuses this device's existing sha when it has pushed before", () async { - final fake = _FakeGitHub( + final fake = FakeGitHub( files: const {'diet-guard-sync/devices/phone/food_log.json': '{}'}, ); await runSync(fake.buildClient()); @@ -304,91 +196,4 @@ void main() { 'f-phone', ); }); - - group('budget sync', () { - test( - 'pushes the local budget when no other devices have synced', - () async { - await AppSettingsService.instance.saveDailyKcalGoal(2000); - final fake = _FakeGitHub(); - await runSync(fake.buildClient()); - - expect( - fake.putsByPath.containsKey( - 'diet-guard-sync/devices/phone/budget.json', - ), - isTrue, - ); - }, - ); - - test('remote-only budget is adopted locally', () async { - final remoteJson = _remoteBudgetJson( - kcal: 1800, - t: '2026-01-01T09:00:00', - ); - final fake = _FakeGitHub( - deviceDirs: const ['pc'], - files: {'diet-guard-sync/devices/pc/budget.json': remoteJson}, - ); - await runSync(fake.buildClient()); - expect(AppSettingsService.dailyKcalGoal, 1800); - }); - - test('a local edit later than a remote edit wins', () async { - await AppSettingsService.instance.saveDailyKcalGoal(1500); // now - final remoteJson = _remoteBudgetJson( - kcal: 1800, - t: '2020-01-01T09:00:00', - ); - final fake = _FakeGitHub( - deviceDirs: const ['pc'], - files: {'diet-guard-sync/devices/pc/budget.json': remoteJson}, - ); - await runSync(fake.buildClient()); - expect(AppSettingsService.dailyKcalGoal, 1500); - }); - - test('a remote edit later than a local edit wins', () async { - await AppSettingsService.instance.saveDailyKcalGoal(1500); // now - final remoteJson = _remoteBudgetJson( - kcal: 1800, - t: '2999-01-01T09:00:00', - ); - final fake = _FakeGitHub( - deviceDirs: const ['pc'], - files: {'diet-guard-sync/devices/pc/budget.json': remoteJson}, - ); - await runSync(fake.buildClient()); - expect(AppSettingsService.dailyKcalGoal, 1800); - }); - - test('a malformed remote budget is skipped, not a crash', () async { - await AppSettingsService.instance.saveDailyKcalGoal(2000); - final fake = _FakeGitHub( - deviceDirs: const ['pc'], - files: const { - 'diet-guard-sync/devices/pc/budget.json': '{not valid json', - }, - ); - await runSync(fake.buildClient()); - expect(AppSettingsService.dailyKcalGoal, 2000); - }); - - test( - 'a fresh install with no budget ever set contributes nothing', - () async { - final fake = _FakeGitHub(); - await runSync(fake.buildClient()); - - final pushed = - fake.putsByPath['diet-guard-sync/devices/phone/budget.json']!; - final pushedText = utf8.decode( - base64.decode(pushed['content'] as String), - ); - expect(jsonDecode(pushedText), isEmpty); - expect(AppSettingsService.dailyKcalGoal, 2200); - }, - ); - }); } diff --git a/app/test/services/sync_service_test_support.dart b/app/test/services/sync_service_test_support.dart new file mode 100644 index 0000000..928f6b0 --- /dev/null +++ b/app/test/services/sync_service_test_support.dart @@ -0,0 +1,121 @@ +// Shared fixtures for the sync_service test siblings. +// +// Split out of `sync_service_test.dart` for the repo's 250-line cap: these +// helpers are ~100 lines that both siblings need, so duplicating them per +// file would put each back over the cap on its own. +// +// The names are public because they now cross a file boundary; a `_` prefix +// is library-private in Dart, so the siblings could not see them. + +// Mirrors `test_sync.py`'s `TestRunSync` and `TestSyncBudget` cases +// (own-id-skip, no-prior-push, non-object payload, corrupt JSON, remote +// merge, food bank rebuild, budget last-writer-wins), plus one Dart-specific +// that has no PC-side equivalent. + +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/sync_merge.dart'; +import 'package:diet_guard_app/services/sync_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// Builds the wire text a remote device would push for a given budget edit. +String remoteBudgetJson({required int kcal, required String t}) { + final record = {'v': 2, 'b': kcal, 't': t}; + final log = budgetToLog(record); + return jsonEncode({ + for (final entry in log.entries) entry.key: entry.value.toJson(), + }); +} + +/// A plain manual-source nutrition value used across the sync fixtures. +const manualNutrition = Nutrition( + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 5, + grams: 100, + source: 'manual', +); + +/// A tiny in-memory stand-in for the GitHub Contents API, scoped to exactly +/// the calls [runSync] makes via crdt_sync's [GitHubClient]: list `devices`, +/// get a device's file text (which crdt_sync also uses internally to +/// resolve this device's own existing sha before a push), and put this +/// device's file text. +class FakeGitHub { + FakeGitHub({this.deviceDirs = const [], Map? files}) + : files = {...?files}; + + final List deviceDirs; + final Map files; + + /// Every `diet-guard-sync/devices//food_log.json` path this fake + /// actually served a file-content GET for. + final List fileGets = []; + + /// Every PUT this fake received, decoded. + final List> puts = []; + + /// Same PUTs, keyed by the repo-relative path they targeted -- lets a + /// test pick out the food-log push from the budget push, now that a + /// sync tick does both. + final Map> putsByPath = {}; + + GitHubClient buildClient() => GitHubClient( + owner: 'o', + repo: 'r', + token: 't', + httpClient: MockClient(_handle), + ); + + Future _handle(http.Request req) async { + if (req.url.path == '/repos/o/r') { + // Repo-existence probe: crdt_sync's GitHubClient calls this to + // disambiguate "path unused yet" (still 404, but a real repo) from + // "repo missing/inaccessible" whenever a content-path GET 404s. + return http.Response('{}', 200); + } + final path = req.url.path.replaceFirst('/repos/o/r/contents/', ''); + if (req.method == 'PUT') { + final body = jsonDecode(req.body) as Map; + puts.add(body); + putsByPath[path] = body; + return http.Response('{}', 200); + } + if (path == 'diet-guard-sync/devices') { + return http.Response( + jsonEncode([ + for (final d in deviceDirs) + { + 'type': 'dir', + 'name': d, + 'path': 'diet-guard-sync/devices/$d', + 'sha': 'd-$d', + }, + ]), + 200, + ); + } + if (!files.containsKey(path)) return http.Response('', 404); + fileGets.add(path); + final content = base64.encode(utf8.encode(files[path]!)); + // Real GitHub always returns `sha` alongside `content` for a + // get-file-contents call -- crdt_sync's GitHubClient reads it back to + // resolve this device's own existing sha before a push. + final segments = path.split('/'); + final sha = + segments.length == 4 && + segments[0] == 'diet-guard-sync' && + segments[1] == 'devices' + ? 'f-${segments[2]}' + : null; + return http.Response( + jsonEncode({'content': content, 'sha': ?sha}), + 200, + ); + } +}