diet-guard/app/test/services/sync_merge_schedule_test.dart
Krzysztof kuhy Rudnicki 24dbffc432 Sync the meal schedule as sched: fields on the budget record
Without this the schedule would be device-local, which is the failure this
whole feature has to avoid: the phone sets five meals, the PC still derives
four, and the PC's gate nags for a 12:00 checkpoint the phone never offered
and can never satisfy. So sync lands before either edit surface, not after.

The history rides the existing `budget` CRDT record as one
`sched:<YYYY-MM-DD>` field per entry, exactly like the budget's own `hist:`
fields. merge_record is per-field LWW over the *union* of field names and both
devices push the merged record, so a device that predates the feature relays
the fields untouched -- no coordinated release. There is a test for precisely
that: merging our push with a budget-only peer keeps both schedule entries.

Verified the two languages agree on the wire, not just in their own suites --
Python and Dart both emit
  {"sched:1970-01-01":{"f":8,"l":20,"n":4},"sched:2026-08-16":{"f":8,"l":20,"n":5}}
The Hlc is derived from the *parsed* timestamp on both sides, which matters
because the epoch fallback formats differently in each language
(1970-01-01T00:00:00+00:00 vs ...000Z) and a string comparison would diverge.

Two guards worth naming:

- A device with no history contributes no `sched:` fields at all, so an unset
  default can never outrank a peer's real schedule; and an empty merge result
  is never written back, so a pre-feature peer cannot erase a real history.
- Dart's `_syncBudget` synthesises a local record when the schedule has been
  edited but the budget never has -- `budgetToLog(null, ...)` returns an empty
  Log and would otherwise drop the schedule fields too. Its budget value
  carries an epoch stamp, so it loses every LWW race against a real edit.

sync_merge/_schedule.py and sync_merge_schedule.dart are separate modules
rather than additions to the budget adapters, which were at 186 and 170 lines
against the 250 cap. Each declares its own record-id constant so the Dart
pair does not import each other.

772 Python tests at 100% branch coverage, 619 Dart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
2026-08-16 14:49:08 +02:00

172 lines
5.2 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(first: 8, last: 20, 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(first: 8, last: 20, 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(first: 8, last: 20, 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(first: 8, last: 20, 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(first: 8, last: 20, 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(first: 8, last: 20, 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(first: 8, last: 20, 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(first: 8, last: 20, count: 5),
]);
});
test('the newer edit wins per day', () {
final older = budgetToLog(_record, const [], [
_entry(
'2026-08-16',
const MealSchedule(first: 8, last: 20, count: 5),
'2026-08-16T10:00:00+02:00',
),
]);
final newer = budgetToLog(_record, const [], [
_entry(
'2026-08-16',
const MealSchedule(first: 7, last: 21, count: 3),
'2026-08-16T18:00:00+02:00',
),
]);
final merged = mergeLogs(parseRemoteBudget(_wire(older)), newer);
expect(
logToScheduleHistory(merged).single.schedule,
const MealSchedule(first: 7, last: 21, count: 3),
);
});
});
}