mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 14:07:40 +01:00
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
172 lines
5.2 KiB
Dart
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),
|
|
);
|
|
});
|
|
});
|
|
}
|