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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 20:49:54 +02:00
parent 3e51e614b1
commit cc78dd383b
20 changed files with 2166 additions and 1499 deletions

View File

@ -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"

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.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

View File

@ -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<bool> supportsMode(PreferredLaunchMode mode) async => true;
@override
Future<bool> 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<void> settle(WidgetTester tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
/// 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<void> pumpUntil(
WidgetTester tester,
bool Function() done, {
int maxTries = 200,
}) async {
for (var i = 0; i < maxTries && !done(); i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
await tester.pump();
}
}
/// Types [clientId] into the client-id field.
Future<void> 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/<owner>/<repo>` 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);
});
},
);
}

View File

@ -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<bool> supportsMode(PreferredLaunchMode mode) async => true;
@override
Future<bool> 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<void> settle(WidgetTester tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
/// 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<void> pumpUntil(
WidgetTester tester,
bool Function() done, {
int maxTries = 200,
}) async {
for (var i = 0; i < maxTries && !done(); i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
await tester.pump();
}
}
/// Types [clientId] into the client-id field.
Future<void> 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,
);
});
});
}

View File

@ -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<bool> supportsMode(PreferredLaunchMode mode) async => true;
@override
Future<bool> 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<void> settle(WidgetTester tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
/// 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<void> pumpUntil(
WidgetTester tester,
bool Function() done, {
int maxTries = 200,
}) async {
for (var i = 0; i < maxTries && !done(); i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
await tester.pump();
}
}
/// Types [clientId] into the client-id field.
Future<void> 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);
});
});
}

View File

@ -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<void> 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<void> 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/<owner>/<repo>` 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);
});
});
}

View File

@ -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<bool> supportsMode(PreferredLaunchMode mode) async => true;
@override
Future<bool> 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<void> settle(WidgetTester tester) async {
await Future<void>.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<void>.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<ChoiceChip>(
find.ancestor(
of: find.text('08:00'),
matching: find.byType(ChoiceChip),
),
);
expect(chip.selected, isTrue);
});
});
}

View File

@ -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<void>.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<ChoiceChip>(
find.ancestor(
of: find.text('08:00'),
matching: find.byType(ChoiceChip),
),
);
expect(chip.selected, isTrue);
});
});
}

View File

@ -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<MealComponent>? 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]);
},
);
});
}

View File

@ -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]);
},
);
});
}

View File

@ -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);
});
});
}

View File

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

View File

@ -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']);
});
});
}

View File

@ -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<String, dynamic> 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(<String, Record>{}), 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);
});
});
});
}

View File

@ -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']);
});
});
}

View File

@ -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<String, dynamic> 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(<String, Record>{}), 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);
});
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
},
);
});
}

View File

@ -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 = <String, dynamic>{'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<String, String>? files})
: files = {...?files};
final List<String> deviceDirs;
final Map<String, String> files;
/// Every `diet-guard-sync/devices/<id>/food_log.json` path this fake
/// actually served a file-content GET for.
final List<String> fileGets = [];
/// Every PUT this fake received, decoded.
final List<Map<String, dynamic>> 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<String, Map<String, dynamic>> putsByPath = {};
GitHubClient buildClient() => GitHubClient(
owner: 'o',
repo: 'r',
token: 't',
httpClient: MockClient(_handle),
);
Future<http.Response> _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<String, dynamic>;
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<String>()));
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);
},
);
});
}

View File

@ -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 = <String, dynamic>{'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<String, String>? files})
: files = {...?files};
final List<String> deviceDirs;
final Map<String, String> files;
/// Every `diet-guard-sync/devices/<id>/food_log.json` path this fake
/// actually served a file-content GET for.
final List<String> fileGets = [];
/// Every PUT this fake received, decoded.
final List<Map<String, dynamic>> 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<String, Map<String, dynamic>> putsByPath = {};
GitHubClient buildClient() => GitHubClient(
owner: 'o',
repo: 'r',
token: 't',
httpClient: MockClient(_handle),
);
Future<http.Response> _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<String, dynamic>;
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,
);
}
}