feat(train): the body's figures in a sealed, synced profile (ADR 0030, D33, D34)

A person's profile, one row per person (its ID derived from theirs),
sealed like a workout: sex, birth date, height, weight, resting and
maximum heart rate, VO2max, training background, typical day and sleep
goal, each optional. "Your body" in the account sheet edits it; the
Loop's setup is to reuse its form. The migration only adds the synced
table `profile`, deleted with the person's sync space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 18:02:04 +01:00
commit de6fe5c15a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,187 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_payload.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/sync_profile_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_profile_gateway.dart';
/// The person's profile (ADR 0030, D33): the figures of their body, sealed
/// and synced in one row of theirs.
void main() {
final full = ProfileContent(
sex: Sex.female,
birthDate: DateTime.utc(1990, 4, 12),
heightCm: 172,
weightKg: 63.5,
restingHeartRate: 52,
maxHeartRate: 186,
vo2Max: 48,
trainingBackground: TrainingBackground.frequent,
typicalDay: TypicalDay.mostlyStanding,
sleepGoal: const Duration(hours: 7, minutes: 30),
);
group('its payload', () {
test('comes back as it was written, a blank one too', () {
expect(decodeProfile(encodeProfile(full)), full);
expect(
decodeProfile(encodeProfile(const ProfileContent())),
const ProfileContent(),
);
});
test('keeps the birth date as a day and the sleep goal in minutes', () {
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(encodeProfile(full))),
) as Map<String, Object?>;
expect(json['birthDate'], '1990-04-12');
expect(json['sleepGoalMinutes'], 450);
expect(json.containsKey('notGiven'), isFalse);
});
test('of a newer schema, it is skipped; a value it does not know is '
'none', () {
ByteData of(Map<String, Object?> json) => ByteData.sublistView(
Uint8List.fromList(utf8.encode(jsonEncode(json))),
);
expect(decodeProfile(of({'schemaVersion': 2})), isNull);
expect(
decodeProfile(
of({'schemaVersion': 1, 'sex': 'other', 'heightCm': 170}),
),
const ProfileContent(heightCm: 170),
);
});
});
test('the age counts whole years, the birthday itself included', () {
final born = ProfileContent(birthDate: DateTime.utc(1990, 4, 12));
expect(born.ageOn(DateTime.utc(2026, 4, 11)), 35);
expect(born.ageOn(DateTime.utc(2026, 4, 12)), 36);
expect(const ProfileContent().ageOn(DateTime.utc(2026)), isNull);
});
group('the repository', () {
late MemoryProfileGateway rows;
late MemoryContentKeyGateway keyRows;
final opened = <TrainKeys>[];
ProfileRepository device({String person = 'alice'}) {
final keys = TrainKeys(
keyRows,
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
opened.add(keys);
return ProfileRepository(rows, keys, person: person);
}
setUp(() {
rows = MemoryProfileGateway();
keyRows = MemoryContentKeyGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('a person without one has an empty one', () async {
expect(await device().read(), const ProfileContent());
});
test(
"is one row, the same on each of the person's devices, sealed",
() async {
final phone = device();
final laptop = device();
await phone.save(const ProfileContent(heightCm: 172));
await laptop.save(const ProfileContent(heightCm: 173));
expect(rows.rows, hasLength(1));
expect(rows.rows.values.single.keyId, isNotNull);
expect(await phone.read(), const ProfileContent(heightCm: 173));
},
);
test("another person's is another row", () async {
await device().save(const ProfileContent(heightCm: 172));
await device(person: 'bob').save(const ProfileContent(heightCm: 180));
expect(rows.rows, hasLength(2));
});
test("a readable row is never taken as the person's", () async {
final alice = device();
await alice.save(const ProfileContent(heightCm: 172));
final id = rows.rows.keys.single;
rows.rows[id] = (
keyId: null,
nonce: null,
payload: encodeProfile(const ProfileContent(heightCm: 150)),
);
expect(await alice.read(), isNull);
});
test('one moved from another row does not open', () async {
await device(person: 'bob').save(const ProfileContent(heightCm: 180));
final alice = device();
await alice.save(const ProfileContent(heightCm: 172));
final [bobs, alices] = rows.rows.keys.toList();
rows.rows[alices] = rows.rows[bobs]!;
expect(await alice.read(), isNull);
});
});
test('on the sync database, writing it twice keeps one row', () async {
final folder = await Directory.systemTemp.createTemp('profile');
addTearDown(() => folder.delete(recursive: true));
final person = protocol.UuidValue.withValidation(
'01a16000-0000-7000-8000-000000000001',
);
final db = await protocol.Client(
'http://127.0.0.1:9/',
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: person);
addTearDown(db.close);
final sync = SyncService(
({required onMerge}) async {},
debounce: const Duration(days: 1),
);
addTearDown(sync.dispose);
final keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
addTearDown(keys.dispose);
final profile = ProfileRepository(
SyncProfileGateway(db, sync),
keys,
person: person.uuid,
);
await profile.save(const ProfileContent(heightCm: 172));
await profile.save(full);
expect(await protocol.Profile.db.count(db), 1);
expect(await profile.read(), full);
});
}