mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:27:11 +01:00
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:
parent
3e51e614b1
commit
cc78dd383b
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
195
app/test/screens/github_mirror_device_dialog_test.dart
Normal file
195
app/test/screens/github_mirror_device_dialog_test.dart
Normal 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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
189
app/test/screens/github_mirror_device_flow_test.dart
Normal file
189
app/test/screens/github_mirror_device_flow_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
169
app/test/screens/github_mirror_device_poll_test.dart
Normal file
169
app/test/screens/github_mirror_device_poll_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
184
app/test/screens/log_meal_screen_nav_test.dart
Normal file
184
app/test/screens/log_meal_screen_nav_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
245
app/test/services/foodbank_service_manual_test.dart
Normal file
245
app/test/services/foodbank_service_manual_test.dart
Normal 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]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
229
app/test/services/log_storage_service_edit_test.dart
Normal file
229
app/test/services/log_storage_service_edit_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
136
app/test/services/sync_merge_banks_test.dart
Normal file
136
app/test/services/sync_merge_banks_test.dart
Normal 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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
212
app/test/services/sync_merge_budget_test.dart
Normal file
212
app/test/services/sync_merge_budget_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
166
app/test/services/sync_merge_curated_test.dart
Normal file
166
app/test/services/sync_merge_curated_test.dart
Normal 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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
139
app/test/services/sync_merge_wire_test.dart
Normal file
139
app/test/services/sync_merge_wire_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
127
app/test/services/sync_service_budget_test.dart
Normal file
127
app/test/services/sync_service_budget_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
121
app/test/services/sync_service_test_support.dart
Normal file
121
app/test/services/sync_service_test_support.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user