diet-guard/app/test/services/sync_merge_test.dart
Krzysztof kuhy Rudnicki cc78dd383b Split six Dart test files; share the sync fixtures via a support sibling
Tenth batch of the 250-line sweep (over-cap count 18 -> 12; all Python is
already clear, so the remainder is Dart).

  sync_merge_test.dart            694 -> 232  (+ 4 siblings)
  github_mirror_screen_test.dart  512 -> 250  (+ 3 siblings)
  foodbank_service_test.dart      395 -> 190  (+ manual sibling)
  sync_service_test.dart          394 -> 203  (+ budget sibling + support)
  log_storage_service_test.dart   385 -> 182  (+ edit sibling)
  log_meal_screen_test.dart       362 -> 243  (+ nav sibling)

sync_service_test's ~100-line fixture block is shared through
`sync_service_test_support.dart` rather than duplicated: copying it would have
put both siblings back over the cap on their own. The helpers had to become
public (`_FakeGitHub` -> `FakeGitHub`, `_manual` -> `manualNutrition`,
`_remoteBudgetJson` -> `remoteBudgetJson`) with doc comments, since a leading
underscore is library-private in Dart and the siblings could not otherwise see
them.

The block-boundary matcher earned two fixes, both caught by comparing block
counts against HEAD rather than by the suite:

- A test name wrapped onto its own line (dart format does this past 80 cols)
  was invisible to a same-line-only pattern, so the neighbouring span sliced
  through it -- the file stopped compiling. Spans now end at the matching
  close paren of the call, not at a guess past the closure brace.
- A double-quoted name containing an apostrophe ("skips its own device id
  ('phone')") was excluded by a character class that banned both quote
  characters. That block fell into the shared prologue and was duplicated into
  every output: the suite went GREEN at 559 tests instead of 555. Reverted and
  redone; text between two blocks is now treated as a shared helper.

Verified: flutter test 555 (unchanged, and each split family's block count
matches HEAD exactly), flutter analyze clean, pre-commit clean.

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

233 lines
6.2 KiB
Dart

// Table-driven mergeLogs() tests. `union by id` through `algebraic
// properties` are the exact same assertions the pre-migration
// `sync_merge.mergeLogs` had -- routed through `dayLogToLog ->
// crdt_sync.mergeLogs -> logToDayLog` instead, to prove the migration
// preserves the app's merge semantics exactly, mirroring
// `test_sync_merge.py`'s equivalent Python-side proof.
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/sync_merge.dart';
import 'package:flutter_test/flutter_test.dart';
FoodEntry _entry({
String? id = 'id-1',
String time = '2026-06-22T08:00:00',
String desc = 'oatmeal',
bool deleted = false,
}) => FoodEntry(
id: id,
time: time,
desc: desc,
grams: 200,
kcal: 300,
proteinG: 10,
carbsG: 50,
fatG: 5,
source: 'manual',
deleted: deleted,
);
DayLog _mergeDaylogs(DayLog a, DayLog b) =>
logToDayLog(mergeLogs(dayLogToLog(a), dayLogToLog(b)));
const _history = [
BudgetEntry(
effectiveFrom: '1970-01-01',
kcal: 2200,
editedAt: '2026-07-13T21:15:09.000Z',
),
BudgetEntry(
effectiveFrom: '2026-07-26',
kcal: 2000,
editedAt: '2026-07-26T10:00:00.000Z',
),
];
void main() {
group('union by id', () {
test('disjoint logs union into one', () {
final a = {
'2026-06-22': [_entry(id: 'a')],
};
final b = {
'2026-06-22': [_entry(id: 'b', time: '2026-06-22T12:00:00')],
};
final merged = _mergeDaylogs(a, b);
expect(merged['2026-06-22']!.map((e) => e.id).toSet(), {'a', 'b'});
});
test('same id in both logs is not duplicated', () {
final shared = _entry(id: 'shared');
final merged = _mergeDaylogs(
{
'2026-06-22': [shared],
},
{
'2026-06-22': [shared],
},
);
expect(merged['2026-06-22'], hasLength(1));
});
test('legacy entries without id dedup by time and desc', () {
final legacyA = _entry(
id: null,
time: '2026-06-20T08:00:00',
desc: 'toast',
);
final legacyB = _entry(
id: null,
time: '2026-06-20T08:00:00',
desc: 'toast',
);
final merged = _mergeDaylogs(
{
'2026-06-20': [legacyA],
},
{
'2026-06-20': [legacyB],
},
);
expect(merged['2026-06-20'], hasLength(1));
});
test('legacy and id entries with different keys both survive', () {
final legacy = _entry(
id: null,
time: '2026-06-20T08:00:00',
desc: 'toast',
);
final withId = _entry(id: 'x', time: '2026-06-20T09:00:00', desc: 'eggs');
final merged = _mergeDaylogs(
{
'2026-06-20': [legacy],
},
{
'2026-06-20': [withId],
},
);
expect(merged['2026-06-20'], hasLength(2));
});
});
group('tombstone wins', () {
test('tombstone beats a non-deleted copy either order', () {
final normal = _entry(id: 'x');
final tombstoned = _entry(id: 'x', deleted: true);
final forward = _mergeDaylogs(
{
'2026-06-22': [normal],
},
{
'2026-06-22': [tombstoned],
},
);
final backward = _mergeDaylogs(
{
'2026-06-22': [tombstoned],
},
{
'2026-06-22': [normal],
},
);
expect(forward['2026-06-22']!.single.deleted, isTrue);
expect(backward['2026-06-22']!.single.deleted, isTrue);
});
test('two tombstoned copies stay tombstoned', () {
final tombstoned = _entry(id: 'x', deleted: true);
final merged = _mergeDaylogs(
{
'2026-06-22': [tombstoned],
},
{
'2026-06-22': [_entry(id: 'x', deleted: true)],
},
);
expect(merged['2026-06-22']!.single.deleted, isTrue);
});
});
group('rebucketing and ordering', () {
test(
"entry is filed under its own time's date, not the arrival bucket",
() {
final misfiled = _entry(id: 'x', time: '2026-06-21T23:00:00');
final merged = _mergeDaylogs({
'2026-06-22': [misfiled],
}, {});
expect(merged.keys, ['2026-06-21']);
expect(merged['2026-06-21']!.single.id, 'x');
},
);
test(
'an entry with a time shorter than a date key buckets under the '
'raw time instead of crashing',
() {
final short = _entry(id: 'x', time: '2026');
final merged = _mergeDaylogs({
'2026-06-22': [short],
}, {});
expect(merged.keys, ['2026']);
},
);
test("a day's entries are sorted oldest-first", () {
final late = _entry(id: 'late', time: '2026-06-22T20:00:00');
final early = _entry(id: 'early');
final merged = _mergeDaylogs(
{
'2026-06-22': [late],
},
{
'2026-06-22': [early],
},
);
expect(merged['2026-06-22']!.map((e) => e.id).toList(), [
'early',
'late',
]);
});
});
group('algebraic properties', () {
test('merge is commutative', () {
final a = {
'2026-06-22': [_entry(id: 'a')],
};
final b = {
'2026-06-22': [_entry(id: 'b', time: '2026-06-22T09:00:00')],
};
final ab = _mergeDaylogs(a, b);
final ba = _mergeDaylogs(b, a);
expect(
ab['2026-06-22']!.map((e) => e.id).toList(),
ba['2026-06-22']!.map((e) => e.id).toList(),
);
});
test('merge is idempotent', () {
final canonical = {
'2026-06-22': [_entry(id: 'a')],
};
final merged = _mergeDaylogs(canonical, canonical);
expect(merged['2026-06-22']!.map((e) => e.id).toList(), ['a']);
});
test('merging with an empty log is a no-op', () {
final log = {
'2026-06-22': [_entry(id: 'a')],
};
expect(_mergeDaylogs(log, {}).keys, log.keys);
expect(_mergeDaylogs({}, log).keys, log.keys);
});
test('merging two empty logs is empty', () {
expect(_mergeDaylogs({}, {}), isEmpty);
});
});
}