mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
- FoodEntry.slot -> slotMinute: read via entrySlotMinute, written via slotFields (whole-hour stays byte-identical, else slot_min), kept by copyWithDeleted and the edit screen, so the phone never republishes a stripped copy of a PC entry. Out-of-range peer values echo, never throw. - Satisfaction is nearest-slot snapping (satisfiedSlots) everywhere: loggedSlotsToday, due check, kuchnia occupancy, future-day logged set (that day's schedule), slot selector. Schedule edits no longer un-log. - Future pre-log builds DateTime from slot ~/ 60, slot % 60. - Schedule history + sync go through scheduleToWire/scheduleFromWire. - Notifications: ids are slot minutes; syncToSlots cancels whatever the platform reports active (getActiveNotifications, reminder channel) that is not due, covering legacy hour ids; full 0..1439 sweep only if the lookup throws. - Settings: 15-minute dropdowns + "Custom..." (24h showTimePicker), off-grid values always selectable, counts capped by grid capacity; editor moved to widgets/meal_schedule_editor.dart + meal_time_dropdown.dart. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
231 lines
8.5 KiB
Dart
231 lines
8.5 KiB
Dart
// `checkAndNotify` is the scheduler-independent half of the periodic
|
|
// check; `backgroundCheckCallbackDispatcher` itself is integration-only
|
|
// (real WorkManager isolate, manual on-device smoke test) per the project
|
|
// plan, and is excluded from coverage.
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:diet_guard_app/models/nutrition.dart';
|
|
import 'package:diet_guard_app/services/app_settings_service.dart';
|
|
import 'package:diet_guard_app/services/body_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/due_slot_check.dart';
|
|
import 'package:diet_guard_app/services/foodbank_service.dart';
|
|
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
|
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
|
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
|
import 'package:diet_guard_app/services/notification_backend_io.dart';
|
|
import 'package:diet_guard_app/services/notification_service.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../fake_notifications.dart';
|
|
import '../fake_secure_storage.dart';
|
|
|
|
const _manual = Nutrition(
|
|
kcal: 200,
|
|
proteinG: 10,
|
|
carbsG: 20,
|
|
fatG: 5,
|
|
grams: 100,
|
|
source: 'manual',
|
|
);
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
late Directory tempDir;
|
|
late List<MethodCall> notificationLog;
|
|
|
|
setUp(() async {
|
|
tempDir = await Directory.systemTemp.createTemp('diet_guard_bg_check_');
|
|
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
// The due-slot pull reuses backgroundSyncPush, which re-inits these two;
|
|
// pre-seeding the singletons keeps that off the real path_provider
|
|
// channel, exactly as background_sync_service_test.dart does.
|
|
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
MealScheduleService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
BodyService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
KuchniaCredentialService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
SharedPreferences.setMockInitialValues({});
|
|
installFakeSecureStorage();
|
|
notificationLog = installFakeAndroidNotifications();
|
|
NotificationService.resetForTesting(
|
|
backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()),
|
|
);
|
|
});
|
|
|
|
tearDown(() async {
|
|
LogStorageService.resetForTesting();
|
|
FoodBankService.resetForTesting();
|
|
AppSettingsService.resetForTesting();
|
|
BudgetHistoryService.resetForTesting();
|
|
KuchniaCredentialService.resetForTesting();
|
|
MealScheduleService.resetForTesting();
|
|
BodyService.resetForTesting();
|
|
NotificationService.resetForTesting();
|
|
await tempDir.delete(recursive: true);
|
|
});
|
|
|
|
test(
|
|
'shows due-and-unlogged slots, cancels one once its meal is logged',
|
|
() async {
|
|
await checkAndNotify(now: DateTime(2026, 1, 1, 16));
|
|
Set<Object?> ids(String method) => {
|
|
for (final call in notificationLog)
|
|
if (call.method == method) (call.arguments as Map)['id'],
|
|
};
|
|
// Ids are slot minutes: 08:00, 12:00 and 16:00 are due by 16:00.
|
|
expect(ids('show'), {480, 720, 960});
|
|
notificationLog.clear();
|
|
|
|
await LogStorageService.instance.logMeal(
|
|
'lunch',
|
|
_manual,
|
|
slotMinute: 720,
|
|
);
|
|
await checkAndNotify(now: DateTime(2026, 1, 1, 16));
|
|
|
|
expect(ids('show'), {480, 960});
|
|
expect(ids('cancel'), {720});
|
|
},
|
|
);
|
|
|
|
test('a tick with every due slot logged posts and cancels nothing', () async {
|
|
await LogStorageService.instance.logMeal(
|
|
'breakfast',
|
|
_manual,
|
|
slotMinute: 480,
|
|
);
|
|
|
|
await checkAndNotify(now: DateTime(2026, 1, 1, 8));
|
|
|
|
expect(notificationLog.where((c) => c.method == 'show'), isEmpty);
|
|
// Nothing is showing, so nothing is cancelled: the active-id lookup
|
|
// replaced a sweep of the whole id space on every tick.
|
|
expect(notificationLog.where((c) => c.method == 'cancel'), isEmpty);
|
|
});
|
|
|
|
test('uses the real clock when now is omitted', () async {
|
|
// Just exercises the `now ?? DateTime.now()` branch without asserting
|
|
// on specific slots (which depend on the actual time the test runs).
|
|
await checkAndNotify();
|
|
expect(
|
|
notificationLog.where((c) => c.method == 'show' || c.method == 'cancel'),
|
|
isNotEmpty,
|
|
);
|
|
});
|
|
|
|
test('still syncs when nothing is due', () async {
|
|
await LogStorageService.instance.logMeal(
|
|
'breakfast',
|
|
_manual,
|
|
slotMinute: 480,
|
|
);
|
|
SharedPreferences.setMockInitialValues({
|
|
'sync.owner': 'o',
|
|
'sync.repo': 'r',
|
|
});
|
|
installFakeSecureStorage(initial: {'sync.token': 't'});
|
|
var requests = 0;
|
|
final mock = MockClient((req) async {
|
|
requests++;
|
|
return http.Response('', 404);
|
|
});
|
|
|
|
await checkAndNotify(now: DateTime(2026, 1, 1, 8), httpClient: mock);
|
|
|
|
// Regression guard. This asserted `requests == 0` -- "nothing due, so
|
|
// never touch the network" -- which is precisely the bug: logging every
|
|
// meal promptly on the phone left nothing ever due, so this periodic task
|
|
// never published and the PC kept locking for slots that WERE logged.
|
|
// This is the phone's only unconditional publish path; it must always run.
|
|
expect(requests, greaterThan(0));
|
|
});
|
|
|
|
test('pullWhenDue false skips the network even with a slot due', () async {
|
|
SharedPreferences.setMockInitialValues({
|
|
'sync.owner': 'o',
|
|
'sync.repo': 'r',
|
|
});
|
|
installFakeSecureStorage(initial: {'sync.token': 't'});
|
|
var requests = 0;
|
|
final mock = MockClient((req) async {
|
|
requests++;
|
|
return http.Response('', 404);
|
|
});
|
|
|
|
await checkAndNotify(
|
|
now: DateTime(2026, 1, 1, 16),
|
|
httpClient: mock,
|
|
pullWhenDue: false,
|
|
);
|
|
|
|
expect(requests, 0);
|
|
// Still reconciles notifications from the local log alone.
|
|
expect(notificationLog.where((c) => c.method == 'show'), isNotEmpty);
|
|
});
|
|
|
|
test('a meal pulled from another device cancels its reminder', () async {
|
|
SharedPreferences.setMockInitialValues({
|
|
'sync.owner': 'o',
|
|
'sync.repo': 'r',
|
|
});
|
|
installFakeSecureStorage(initial: {'sync.token': 't'});
|
|
// Must be *today* in the real clock's terms: `now` only steers which
|
|
// slots are due, while "which day's entries count" comes from the
|
|
// system date inside loggedSlotsToday.
|
|
final today = DateTime.now();
|
|
final day =
|
|
'${today.year.toString().padLeft(4, '0')}-'
|
|
'${today.month.toString().padLeft(2, '0')}-'
|
|
'${today.day.toString().padLeft(2, '0')}';
|
|
// The PC pushed a 12:00 meal this device has never seen locally.
|
|
final remote =
|
|
'{"pc-entry":{"id":"pc-entry","fields":{"body":[{"time":'
|
|
'"${day}T12:30:00+01:00","desc":"pc lunch","kcal":500,'
|
|
'"protein_g":30,"carbs_g":50,"fat_g":15,"grams":300,'
|
|
'"source":"manual","slot":12,"day":"$day"},'
|
|
'"${day}T11:30:00.000Z-0000-pc"]},"deleted":false,'
|
|
'"deleted_hlc":null}}';
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') return http.Response('{}', 200);
|
|
if (req.method == 'GET' && req.url.pathSegments.length == 3) {
|
|
return http.Response('{}', 200);
|
|
}
|
|
if (req.url.path.endsWith('/devices')) {
|
|
return http.Response('[{"name":"pc","type":"dir"}]', 200);
|
|
}
|
|
if (req.url.path.endsWith('/devices/pc/food_log.json')) {
|
|
// The Contents API returns base64 in a `content` field, not raw JSON.
|
|
return http.Response(
|
|
jsonEncode({'content': base64.encode(utf8.encode(remote))}),
|
|
200,
|
|
);
|
|
}
|
|
return http.Response('', 404);
|
|
});
|
|
|
|
await checkAndNotify(
|
|
now: DateTime(today.year, today.month, today.day, 12),
|
|
httpClient: mock,
|
|
);
|
|
|
|
// This is the false alarm the fix exists to kill: 12:00 was unlogged
|
|
// locally, so the pre-fix code fired a reminder for a meal that was
|
|
// already logged on the PC.
|
|
final shown = notificationLog
|
|
.where((c) => c.method == 'show')
|
|
.map((c) => (c.arguments as Map)['id'])
|
|
.toSet();
|
|
expect(shown, isNot(contains(720)));
|
|
});
|
|
}
|