Merge branch 'train-days' into integration

# Conflicts:
#	apps/train/halkyon_train_flutter/lib/src/app/train_paths.dart
#	docs/adr/0030-halkyon-train.md
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 23:14:02 +01:00
commit 81adeb91cc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 25705 additions and 410 deletions

View file

@ -0,0 +1,151 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_payload.dart';
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_weight_gateway.dart';
/// The person's weights, sealed on the way to the instance and opened on
/// the way back (ADR 0030, D7, D64), with libsodium.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late UnlockedAccountKeys bob;
late MemoryContentKeyGateway keyRows;
late MemoryWeightGateway rows;
final opened = <TrainKeys>[];
final monday = WeightContent(
at: DateTime.utc(2026, 11, 2, 7),
kilograms: 74.3,
);
final tuesday = WeightContent(
at: DateTime.utc(2026, 11, 3, 7),
kilograms: 74.05,
source: WeightSource.healthConnect,
healthId: 'hc-1',
app: 'com.example.scale',
);
Future<WeightRepository> device(UnlockedAccountKeys? account) async {
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
opened.add(keys);
await keys.refresh();
return WeightRepository(rows, keys);
}
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
bob = (await crypto.newAccount('bob password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemoryWeightGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('weights entered on one device read on another of the account, the '
'earliest first, and are deleted there', () async {
final phone = await device(alice);
final second = await phone.save(tuesday);
final first = await phone.save(monday);
final laptop = await device(alice);
expect(await laptop.all(), [
StoredWeight(first, monday),
StoredWeight(second, tuesday),
]);
await laptop.delete(first);
expect(await phone.all(), [StoredWeight(second, tuesday)]);
});
test('nothing the instance gets is readable: no weight, no JSON', () async {
await (await device(alice)).save(tuesday);
final sealed = rows.rows.values.single;
expect(sealed.keyId, isNotNull);
final text = utf8.decode(
Uint8List.sublistView(sealed.payload),
allowMalformed: true,
);
expect(text, isNot(contains('74050')));
expect(text, isNot(contains('scale')));
});
test("another person's weight, a readable one, or one moved to another "
'row shows locked, never as theirs', () async {
final id = await (await device(alice)).save(monday);
rows.rows
..['moved'] = rows.rows[id]!
..['readable'] = (
keyId: null,
nonce: null,
payload: encodeWeight(monday),
);
expect(
(await (await device(bob)).all()).where((w) => w.content != null),
isEmpty,
);
expect(
{for (final w in await (await device(alice)).all()) w.id: w.content},
{id: monday, 'moved': null, 'readable': null},
);
});
test('a device without the keys reads nothing and saves nothing', () async {
await (await device(alice)).save(monday);
final stranger = await device(null);
expect((await stranger.all()).single.content, isNull);
await expectLater(stranger.save(monday), throwsA(isA<KeysMissing>()));
});
group('the payload', () {
test('keeps the weight to the gram, its time in UTC, and where it came '
'from', () {
expect(decodeWeight(encodeWeight(tuesday)), tuesday);
expect(decodeWeight(encodeWeight(monday)), monday);
final local = WeightContent(
at: DateTime(2026, 11, 3, 8),
kilograms: 80.0004,
);
expect(
decodeWeight(encodeWeight(local)),
WeightContent(at: local.at.toUtc(), kilograms: 80),
);
});
test('one of a newer schema, or none at all, reads as nothing', () {
ByteData of(String json) =>
ByteData.sublistView(Uint8List.fromList(utf8.encode(json)));
expect(
decodeWeight(
of('{"schemaVersion":2,"at":"2026-11-03T07:00:00Z","grams":1}'),
),
isNull,
);
expect(decodeWeight(of('{"schemaVersion":1}')), isNull);
expect(
decodeWeight(
of('{"schemaVersion":1,"at":"2026-11-03T07:00:00Z","grams":0}'),
),
isNull,
);
});
});
}

View file

@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
import 'package:halkyon_train_flutter/src/feature/weight/bloc/weights_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_profile_gateway.dart';
import '../../support/memory_weight_gateway.dart';
void main() {
late TrainKeys keys;
late WeightRepository weights;
late ProfileRepository profile;
late WeightsCubit cubit;
setUp(() async {
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
weights = WeightRepository(MemoryWeightGateway(), keys);
profile = ProfileRepository(MemoryProfileGateway(), keys, person: 'alice');
await profile.save(const ProfileContent(heightCm: 180, weightKg: 76));
cubit = WeightsCubit(weights, profile);
await cubit.open();
});
tearDown(() async {
await cubit.close();
await weights.dispose();
await profile.dispose();
keys.dispose();
});
test('a weight entered shows, the earliest first, and the latest sets '
"Your body's weight, the other figures kept", () async {
final monday = DateTime.utc(2026, 11, 2, 7);
await cubit.add(75.2, monday.add(const Duration(days: 1)));
await pumpEventQueue();
expect(
await profile.read(),
const ProfileContent(heightCm: 180, weightKg: 75.2),
);
// An earlier one, entered late, changes nothing of the profile.
await cubit.add(75.8, monday);
await pumpEventQueue();
expect(
[for (final weight in cubit.state.weights) weight.content!.kilograms],
[75.8, 75.2],
);
expect(cubit.state.latest?.kilograms, 75.2);
expect((await profile.read())?.weightKg, 75.2);
expect(cubit.state.weights.first.content!.source, WeightSource.own);
});
test('a weight deleted goes', () async {
await cubit.add(75.2, DateTime.utc(2026, 11, 3, 7));
await pumpEventQueue();
await cubit.delete(cubit.state.weights.single.id);
await pumpEventQueue();
expect(cubit.state.weights, isEmpty);
expect(cubit.state.loaded, isTrue);
});
}