diet-guard/app/test/services/sync_merge_schedule_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

172 lines
5.5 KiB
Dart

/// Tests for the meal-schedule half of the shared `budget` CRDT record.
///
/// Mirrors `diet_guard/tests/test_sync_merge_schedule.py`.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/models/meal_schedule.dart';
import 'package:diet_guard_app/services/meal_schedule_history.dart';
import 'package:diet_guard_app/services/sync_merge_budget.dart';
import 'package:diet_guard_app/services/sync_merge_schedule.dart';
import 'package:flutter_test/flutter_test.dart';
const _record = <String, dynamic>{
'v': 2,
'b': 2200,
't': '2026-08-16T10:00:00+02:00',
};
ScheduleEntry _entry(String day, MealSchedule schedule, String when) =>
ScheduleEntry(effectiveFrom: day, schedule: schedule, editedAt: when);
String _wire(Log log) => jsonEncode({
for (final entry in log.entries) entry.key: entry.value.toJson(),
});
void main() {
group('scheduleFields', () {
test('no entries contributes nothing', () {
// A device that never edited a schedule cannot outrank a peer.
expect(scheduleFields(const []), isEmpty);
});
test('one field per entry', () {
final fields = scheduleFields([
_entry(
'2026-08-16',
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
'2026-08-16T10:00:00+02:00',
),
]);
expect(fields.keys, ['${scheduleFieldPrefix}2026-08-16']);
expect(fields['${scheduleFieldPrefix}2026-08-16']!.$1, {
'f': 8,
'l': 20,
'n': 5,
});
});
test('the Hlc is deterministic', () {
final entry = _entry(
'2026-08-16',
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
'2026-08-16T10:00:00+02:00',
);
expect(
scheduleHlc(entry).wallTimeMs,
scheduleHlc(entry).wallTimeMs,
);
});
test('an unparsable timestamp falls back to the epoch', () {
final entry = _entry(
'2026-08-16',
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
'not-a-timestamp',
);
expect(scheduleHlc(entry).wallTimeMs, 0);
});
});
group('logToScheduleHistory', () {
test('an absent record yields nothing', () {
expect(logToScheduleHistory(const {}), isEmpty);
});
test('round trips', () {
final back = logToScheduleHistory(
budgetToLog(_record, const [], [
_entry(
'2026-08-16',
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
'2026-08-16T10:00:00+02:00',
),
]),
);
expect(back, hasLength(1));
expect(back.single.effectiveFrom, '2026-08-16');
expect(
back.single.schedule,
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
);
});
test("normalizes a peer's out-of-range schedule", () {
final log = budgetToLog(_record);
final record = log[budgetRecordId]!;
final hlc = record.fields.values.first.$2;
record.fields['${scheduleFieldPrefix}2026-08-16'] = (
{'f': 8, 'l': 20, 'n': 99},
hlc,
);
expect(
logToScheduleHistory(log).single.schedule,
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 6),
);
});
test('skips malformed values', () {
final log = budgetToLog(_record);
final record = log[budgetRecordId]!;
final hlc = record.fields.values.first.$2;
record.fields['${scheduleFieldPrefix}2026-01-01'] = (7, hlc);
record.fields['${scheduleFieldPrefix}2026-02-01'] = ({'f': '8'}, hlc);
expect(logToScheduleHistory(log), isEmpty);
});
});
group('cross-device merge', () {
test('a pre-feature peer relays the schedule untouched', () {
// `mergeRecord` is per-field LWW over the *union* of field names, and
// both devices push the merged record, so a budget-only push merges the
// fields in rather than clobbering them. This is what makes the feature
// shippable without a coordinated release.
final ours = budgetToLog(_record, const [], [
_entry('1970-01-01', kDefaultSchedule, '1970-01-01T00:00:00.000Z'),
_entry(
'2026-08-16',
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
'2026-08-16T10:00:00+02:00',
),
]);
final peer = budgetToLog(const <String, dynamic>{
'v': 2,
'b': 1900,
't': '2026-08-16T11:00:00+02:00',
});
final merged = mergeLogs(parseRemoteBudget(_wire(ours)), peer);
expect(logToScheduleHistory(merged).map((e) => e.schedule), [
kDefaultSchedule,
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
]);
});
test('the newer edit wins per day', () {
final older = budgetToLog(_record, const [], [
_entry(
'2026-08-16',
const MealSchedule(firstMinute: 8 * 60, lastMinute: 20 * 60, count: 5),
'2026-08-16T10:00:00+02:00',
),
]);
final newer = budgetToLog(_record, const [], [
_entry(
'2026-08-16',
const MealSchedule(firstMinute: 7 * 60, lastMinute: 21 * 60, count: 3),
'2026-08-16T18:00:00+02:00',
),
]);
final merged = mergeLogs(parseRemoteBudget(_wire(older)), newer);
expect(
logToScheduleHistory(merged).single.schedule,
const MealSchedule(firstMinute: 7 * 60, lastMinute: 21 * 60, count: 3),
);
});
});
}