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
172 lines
5.5 KiB
Dart
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),
|
|
);
|
|
});
|
|
});
|
|
}
|