diet-guard/app/test/services/due_slot_check_test.dart
Krzysztof kuhy Rudnicki f83938bfe3 app: minute-of-day slots (phase 2 of 3: every app consumer)
- 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
2026-10-10 19:55:26 +02:00

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