feat(train): Days, the Loop's days and nights kept sealed (ADR 0030, D37, D38)

What the band read brings, from the bridge or the background job's
file (`LoopLines`), is kept by `DayRepository`: one sealed `day` and
`night` a date and the day's heart rate in chunks of three hours, every
ID made from the person and the date, a later read replacing what it
brought and keeping the rest, nothing rewritten unchanged; versioned,
gzipped JSON (`day_payload.dart`). `PolarLoop` reads the band as each
connection is ready (every half hour at most, or when asked), takes in
the background file as the app opens, and remembers up to which day
the days are sealed, which the next read then deletes on the band;
it keeps the background job on the band and from the right day, and
`Let the band go` stops it.

Home gains a Days tab, each day with its steps, active time, sleep and
Nightly Recharge, pulled to read the band; a day's page shows its
figures, its time in each class, its heart rate, the night's phases
and rating, Nightly Recharge and skin temperature.

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 19:53:12 +01:00
commit 5250f32329
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 4079 additions and 28 deletions

View file

@ -9,6 +9,8 @@ import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/src/app/train_app.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
@ -32,6 +34,7 @@ import '../support/fake_health_platform.dart';
import '../support/fake_loop_platform.dart';
import '../support/fake_recorder_platform.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_day_gateway.dart';
import '../support/memory_profile_gateway.dart';
import '../support/memory_workout_gateway.dart';
@ -63,6 +66,7 @@ void main() {
bool mapReachable = true,
HealthConnect? health,
ProfileRepository? profile,
DayRepository? days,
PolarLoop? loop,
}) async {
tester.view.physicalSize = const Size(412, 900);
@ -143,6 +147,17 @@ void main() {
),
person: person.authUserId,
),
days:
days ??
DayRepository(
MemoryDayGateway(),
TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => null,
),
person: person.authUserId,
),
recording: recording,
health: health,
loop: loop,
@ -451,6 +466,7 @@ void main() {
late Directory folder;
late TrainKeys keys;
late ProfileRepository profile;
late DayRepository days;
late FakeLoopPlatform platform;
late PolarLoop loop;
@ -466,6 +482,7 @@ void main() {
keys,
person: 'alice',
);
days = DayRepository(MemoryDayGateway(), keys, person: 'alice');
platform = FakeLoopPlatform()
..nearby.add(band)
..inReach.add('A1');
@ -499,8 +516,16 @@ void main() {
platform,
LoopStore(File('${folder.path}/loop.json')),
profile,
days: days,
backgroundLines: File('${folder.path}/loop_days.txt'),
);
await start(
tester,
usedBefore: true,
profile: profile,
days: days,
loop: loop,
);
await start(tester, usedBefore: true, profile: profile, loop: loop);
await tester.runAsync(() async => loop.start());
await openAccount(tester);
await tester.tap(find.text('Polar Loop'));
@ -1347,6 +1372,108 @@ void main() {
});
});
group('days (ADR 0030, D37)', () {
late TrainKeys keys;
late DayRepository days;
setUp(() {
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
days = DayRepository(MemoryDayGateway(), keys, person: 'alice');
});
tearDown(() => keys.dispose());
Future<void> keepTuesday(WidgetTester tester) => tester.runAsync(
() => days.take(
LoopLines()
..add({
'kind': 'summary',
'date': '2026-10-27',
'steps': 9876,
'times': {'light': 5400, 'continuousModerate': 1200},
})
..add({
'kind': 'heart',
'date': '2026-10-27',
'runs': [
{
'at': 600,
'trigger': 'timed',
'bpm': [58, 60],
},
{
'at': 40000,
'trigger': 'timed',
'bpm': [92],
},
],
})
..add({
'kind': 'sleep',
'date': '2026-10-27',
'start': '2026-10-26T22:00:00Z',
'end': '2026-10-27T06:00:00Z',
'phases': [
[0, 'wake'],
[600, 'nonrem12'],
[7200, 'nonrem3'],
[10800, 'rem'],
],
'rating': 'well',
})
..add({
'kind': 'recharge',
'date': '2026-10-27',
'recovery': 4,
'rmssd': 48,
'baselineRmssd': 45,
}),
),
);
testWidgets('without a band, the days tab says where days come from', (
tester,
) async {
await start(tester, usedBefore: true, days: days);
await tester.tap(find.text('Days'));
await tester.pumpAndSettle();
expect(find.text('No days yet'), findsOneWidget);
});
testWidgets('a day kept lists its figures and opens its page: heart '
'rate, the night and its Nightly Recharge', (tester) async {
await keepTuesday(tester);
await start(tester, usedBefore: true, days: days);
await tester.tap(find.text('Days'));
await tester.pumpAndSettle();
expect(find.text('Tuesday, October 27'), findsOneWidget);
expect(find.text('9876 steps'), findsOneWidget);
expect(find.text('1 h 50 min active'), findsOneWidget);
expect(find.text('7 h 50 min asleep'), findsOneWidget);
expect(find.text('Recharge 4 of 6'), findsOneWidget);
await tester.tap(find.text('Tuesday, October 27'));
await tester.pumpAndSettle();
expect(find.text('Heart rate'), findsOneWidget);
expect(find.text('The night'), findsOneWidget);
expect(find.text('You slept well'), findsOneWidget);
await tester.scrollUntilVisible(find.text('Nightly Recharge'), 200);
expect(
find.text('Heart rate variability 48 ms, your usual 45 ms'),
findsOneWidget,
);
});
});
group('workouts (ADR 0030, D24)', () {
late MemoryWorkoutGateway rows;
late TrainKeys keys;

View file

@ -0,0 +1,134 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_payload.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// A day, a night and a day's heart rate as their rows' payloads (ADR 0030,
/// D37): versioned JSON, gzipped before sealing.
void main() {
final date = DateTime.utc(2026, 10, 27);
final day = DayContent(
date: date,
steps: 9876,
metres: 7012.5,
activityCalories: 420,
trainingCalories: 120,
bmrCalories: 1500,
goal: 100,
achieved: 87.5,
times: const {
ActivityClass.sleep: Duration(hours: 7, minutes: 30),
ActivityClass.light: Duration(hours: 3),
},
recharge: const Recharge(ansStatus: 2.5, recovery: 4, rri: 1050, rmssd: 48),
skin: const [(Duration.zero, 33.1), (Duration(minutes: 1), 33.3)],
);
final night = NightContent(
date: date,
start: DateTime.utc(2026, 10, 26, 22, 15),
end: DateTime.utc(2026, 10, 27, 6, 5),
goalMinutes: 480,
phases: const [
(Duration.zero, SleepState.wake),
(Duration(minutes: 10), SleepState.light),
(Duration(hours: 2), SleepState.deep),
],
cycles: const [(Duration(minutes: 10), 1.5)],
rating: SleepRating.somewhatWell,
batteryRanOut: false,
skinCelsius: 33.4,
skinDeviation: -0.2,
);
const heart = [
HeartRun(
at: Duration(minutes: 10),
trigger: HeartTrigger.timed,
bpm: [58, 57, 59],
),
HeartRun(
at: Duration(hours: 2),
trigger: HeartTrigger.highActivity,
bpm: [141],
),
];
Map<String, Object?> json(ByteData payload) => jsonDecode(
utf8.decode(GZipCodec().decode(Uint8List.sublistView(payload))),
) as Map<String, Object?>;
test('a day, a night and heart rate come back as they were written', () {
expect(decodeDay(encodeDay(day)), day);
expect(decodeNight(encodeNight(night)), night);
expect(decodeHeart(encodeHeart(heart)), heart);
});
test('a day with nothing but its date comes back so', () {
final bare = DayContent(date: date);
expect(decodeDay(encodeDay(bare)), bare);
expect(
decodeNight(encodeNight(NightContent(date: date))),
NightContent(date: date),
);
});
test('each is gzipped JSON with its schema version and the date', () {
expect(
json(encodeDay(day)),
containsPair('schemaVersion', daySchemaVersion),
);
expect(json(encodeDay(day)), containsPair('date', '2026-10-27'));
expect(json(encodeNight(night)), containsPair('date', '2026-10-27'));
expect(
json(encodeHeart(heart)),
containsPair('schemaVersion', daySchemaVersion),
);
});
test('a blank payload, a newer schema and garbage are skipped', () {
ByteData gzipped(Map<String, Object?> json) => ByteData.sublistView(
Uint8List.fromList(GZipCodec().encode(utf8.encode(jsonEncode(json)))),
);
final newer = gzipped({
'schemaVersion': daySchemaVersion + 1,
'date': '2026-10-27',
});
for (final payload in [ByteData(0), newer, ByteData(3)]) {
expect(decodeDay(payload), isNull);
expect(decodeNight(payload), isNull);
expect(decodeHeart(payload), isNull);
}
});
test("a state, a class or a trigger this app doesn't know is unknown or "
'left out, the rest read', () {
final payload = ByteData.sublistView(
Uint8List.fromList(
GZipCodec().encode(
utf8.encode(
jsonEncode({
'schemaVersion': daySchemaVersion,
'date': '2026-10-27',
'phases': [
[0, 'dreaming'],
],
'rating': 'blissfully',
}),
),
),
),
);
expect(
decodeNight(payload),
NightContent(
date: date,
phases: const [(Duration.zero, SleepState.unknown)],
),
);
});
}

View file

@ -0,0 +1,296 @@
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/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_day_gateway.dart';
/// The person's days and nights from the Loop (ADR 0030, D37), sealed on the
/// way to the instance and opened on the way back (D7), with libsodium as
/// its build hook compiles it.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late MemoryContentKeyGateway keyRows;
late MemoryDayGateway rows;
final opened = <TrainKeys>[];
final monday = DateTime.utc(2026, 10, 26);
final tuesday = DateTime.utc(2026, 10, 27);
Map<String, Object?> summary(DateTime date, int steps) => {
'kind': 'summary',
'date': dayKey(date),
'steps': steps,
};
Map<String, Object?> heart(DateTime date, List<int> hours) => {
'kind': 'heart',
'date': dayKey(date),
'runs': [
for (final hour in hours)
{
'at': hour * 3600,
'trigger': 'timed',
'bpm': [60 + hour],
},
],
};
final sleep = {
'kind': 'sleep',
'date': dayKey(tuesday),
'start': '2026-10-26T22:00:00Z',
'end': '2026-10-27T06:00:00Z',
'rating': 'well',
};
final recharge = {'kind': 'recharge', 'date': dayKey(tuesday), 'rmssd': 48};
final skin = {
'kind': 'skin',
'date': dayKey(tuesday),
'samples': [
[0, 33.1],
],
};
LoopLines linesOf(List<Map<String, Object?>> lines) {
final read = LoopLines();
lines.forEach(read.add);
return read;
}
/// A device of the account whose keys are [account] (none: signed in
/// without them), on the shared rows.
Future<DayRepository> device(UnlockedAccountKeys? account) async {
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
opened.add(keys);
await keys.refresh();
return DayRepository(rows, keys, person: 'alice');
}
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemoryDayGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('what a read brought is kept, one day and one night a date, and '
'reads on another device', () async {
final phone = await device(alice);
await phone.take(
linesOf([
summary(tuesday, 9876),
heart(tuesday, [1, 2, 7]),
sleep,
recharge,
skin,
]),
);
final laptop = await device(alice);
final [day] = await laptop.all();
expect(day.date, tuesday);
expect(day.day?.steps, 9876);
expect(day.day?.recharge?.rmssd, 48);
expect(day.day?.skin, const [(Duration.zero, 33.1)]);
expect(day.night?.rating, SleepRating.well);
expect((await laptop.heart(tuesday)).map((run) => run.at.inHours), [
1,
2,
7,
]);
});
test("a day's heart rate goes in chunks of three hours, made from the "
"day's ID, so a read again rewrites the same rows", () async {
final phone = await device(alice);
await phone.take(
linesOf([
heart(tuesday, [1, 2, 7, 23]),
]),
);
final ids = rows.seriesRows.keys.toSet();
await phone.take(
linesOf([
heart(tuesday, [1, 2, 7, 23, 8]),
]),
);
expect(ids, hasLength(3));
expect(rows.seriesRows.keys.toSet(), ids);
expect((await phone.heart(tuesday)).map((run) => run.at.inHours), [
1,
2,
7,
8,
23,
]);
});
test('a chunk the day no longer has goes', () async {
final phone = await device(alice);
await phone.take(
linesOf([
heart(tuesday, [1, 7]),
]),
);
await phone.take(
linesOf([
heart(tuesday, [1]),
]),
);
expect(rows.seriesRows, hasLength(1));
});
test('a later read replaces what it brought and keeps what it '
"didn't", () async {
final phone = await device(alice);
await phone.take(
linesOf([
summary(tuesday, 5000),
heart(tuesday, [1]),
recharge,
skin,
]),
);
await phone.take(linesOf([summary(tuesday, 9876)]));
final [day] = await phone.all();
expect(day.day?.steps, 9876);
expect(day.day?.recharge?.rmssd, 48);
expect(day.day?.skin, isNotEmpty);
expect(await phone.heart(tuesday), hasLength(1));
});
test('nothing is written where a read brought nothing new', () async {
final phone = await device(alice);
final lines = linesOf([
summary(tuesday, 9876),
heart(tuesday, [1]),
sleep,
]);
await phone.take(lines);
final writes = rows.written.length;
await phone.take(lines);
expect(rows.written, hasLength(writes));
});
test('days come newest first, each with the night it ended', () async {
final phone = await device(alice);
await phone.take(linesOf([summary(monday, 1), summary(tuesday, 2), sleep]));
final days = await phone.all();
expect(days.map((day) => day.date), [tuesday, monday]);
expect(days.map((day) => day.night != null), [true, false]);
});
test('nothing the instance gets is readable: every row is sealed, no '
'JSON', () async {
final phone = await device(alice);
await phone.take(
linesOf([
summary(tuesday, 9876),
heart(tuesday, [1]),
sleep,
]),
);
expect(rows.written, hasLength(3));
for (final envelope in rows.written) {
expect(envelope.keyId, startsWith('train1.'));
expect(envelope.nonce, isNotNull);
final bytes = Uint8List.sublistView(envelope.payload);
expect(utf8.decode(bytes, allowMalformed: true), isNot(contains('2026')));
expect(bytes.take(2), isNot([0x1f, 0x8b]));
}
});
test(
"a device without the account's keys sees no day and keeps none",
() async {
final phone = await device(alice);
await phone.take(linesOf([summary(tuesday, 9876)]));
final writes = rows.written.length;
final elsewhere = await device(null);
expect(await elsewhere.all(), isEmpty);
await expectLater(
elsewhere.take(linesOf([summary(monday, 1)])),
throwsA(isA<KeysMissing>()),
);
expect(rows.written, hasLength(writes));
},
);
test("a row the instance moved, made up or changed doesn't open: a day "
'as a night, a chunk on another day, a readable day', () async {
final phone = await device(alice);
await phone.take(
linesOf([
summary(monday, 1),
heart(monday, [1]),
summary(tuesday, 2),
sleep,
]),
);
final tuesdayId = (await phone.all()).first.id;
final mondayId = (await phone.all()).last.id;
final nightId = rows.nightRows.keys.single;
final chunk = rows.seriesRows.values.single;
rows.nightRows[nightId] = rows.dayRows[tuesdayId]!;
rows.seriesRows[chunk.id] = (
id: chunk.id,
dayId: tuesdayId,
envelope: chunk.envelope,
);
rows.dayRows[mondayId] = (
keyId: null,
nonce: null,
payload: ByteData.sublistView(
Uint8List.fromList(
utf8.encode('{"schemaVersion":1,"date":"2026-10-26"}'),
),
),
);
final days = await phone.all();
expect(days.map((day) => day.date), [tuesday]);
expect(days.single.night, isNull);
expect(await phone.heart(tuesday), isEmpty);
expect(await phone.heart(monday), isEmpty);
});
test('changes, here and from other devices, are announced', () async {
final phone = await device(alice);
final announced = expectLater(phone.changes, emitsInOrder([null, null]));
await phone.take(linesOf([summary(tuesday, 1)]));
rows.bringRemoteChanges();
await announced;
});
}

View file

@ -0,0 +1,79 @@
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/day/bloc/day_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/day/bloc/days_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_day_gateway.dart';
/// Home's days and a day's page (ADR 0030, D37), following what is kept.
void main() {
late TrainKeys keys;
late MemoryDayGateway rows;
late DayRepository days;
final tuesday = DateTime.utc(2026, 10, 27);
LoopLines read(int steps) => LoopLines()
..add({'kind': 'summary', 'date': '2026-10-27', 'steps': steps})
..add({
'kind': 'heart',
'date': '2026-10-27',
'runs': [
{
'at': 600,
'trigger': 'timed',
'bpm': [58],
},
],
})
..add({'kind': 'sleep', 'date': '2026-10-27', 'rating': 'well'});
setUp(() {
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
rows = MemoryDayGateway();
days = DayRepository(rows, keys, person: 'alice');
});
tearDown(() async {
await days.dispose();
keys.dispose();
});
test('home lists the days kept, and follows them as they change', () async {
final cubit = DaysCubit(days, null);
addTearDown(cubit.close);
await cubit.open();
expect(cubit.state.loaded, isTrue);
expect(cubit.state.days, isEmpty);
expect(cubit.state.holdsLoop, isFalse);
await days.take(read(9876));
await pumpEventQueue();
expect(cubit.state.days.single.day?.steps, 9876);
});
test("a day's page shows the day, its night and its heart rate, and "
'follows them', () async {
await days.take(read(5000));
final cubit = DayCubit(days, tuesday);
addTearDown(cubit.close);
await cubit.open();
expect(cubit.state.day?.steps, 5000);
expect(cubit.state.night, isNotNull);
expect(cubit.state.heart, hasLength(1));
await days.take(read(9876));
await pumpEventQueue();
expect(cubit.state.day?.steps, 9876);
});
}

View file

@ -0,0 +1,215 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// The lines `LoopDays.kt` makes of a read (ADR 0030, D37), as the app
/// takes them: the same names and units as `LoopDaysTest.kt` checks.
void main() {
final yesterday = DateTime.utc(2026, 10, 27);
final today = DateTime.utc(2026, 10, 28);
const read = {
'kind': 'read',
'on': '2026-10-28',
'from': '2026-10-27',
'to': '2026-10-28',
};
const summary = {
'kind': 'summary',
'date': '2026-10-27',
'steps': 9876,
'metres': 7012.5,
'activityCalories': 420,
'trainingCalories': 120,
'bmrCalories': 1500,
'goal': 100.0,
'achieved': 87.5,
'times': {
'sleep': 27000,
'intermittentVigorous': 300,
'flying': 60,
'light': null,
},
};
const heart = {
'kind': 'heart',
'date': '2026-10-27',
'runs': [
{
'at': 600,
'trigger': 'timed',
'bpm': [58, 57, 59],
},
{
'at': 63005,
'trigger': 'high_activity',
'bpm': [141],
},
{
'at': 70000,
'trigger': 'sideways',
'bpm': [80],
},
],
};
const sleep = {
'kind': 'sleep',
'date': '2026-10-28',
'start': '2026-10-27T23:15+01:00',
'end': '2026-10-28T07:05+01:00',
'goalMinutes': 480,
'phases': [
[0, 'wake'],
[600, 'nonrem12'],
[7200, 'nonrem3'],
[9000, 'rem'],
],
'cycles': [
[600, 1.5],
],
'rating': 'somewhat_well',
'batteryRanOut': false,
'skinCelsius': 33.4,
'skinDeviation': -0.2,
};
const recharge = {
'kind': 'recharge',
'date': '2026-10-28',
'ansStatus': 2.5,
'recovery': 4,
'rri': 1050,
'rmssd': 48,
};
const skin = {
'kind': 'skin',
'date': '2026-10-28',
'samples': [
[0, 33.1],
[60000, 33.3],
],
};
test('a summary keeps its figures and its time in each class', () {
final lines = LoopLines()..add(summary);
expect(
lines.summaries[yesterday],
DayContent(
date: yesterday,
steps: 9876,
metres: 7012.5,
activityCalories: 420,
trainingCalories: 120,
bmrCalories: 1500,
goal: 100,
achieved: 87.5,
times: const {
ActivityClass.sleep: Duration(hours: 7, minutes: 30),
ActivityClass.intermittentVigorous: Duration(minutes: 5),
},
),
);
});
test('heart rate keeps each run, a trigger unknown as such', () {
final lines = LoopLines()..add(heart);
expect(lines.hearts[yesterday], const [
HeartRun(
at: Duration(minutes: 10),
trigger: HeartTrigger.timed,
bpm: [58, 57, 59],
),
HeartRun(
at: Duration(hours: 17, minutes: 30, seconds: 5),
trigger: HeartTrigger.highActivity,
bpm: [141],
),
HeartRun(
at: Duration(seconds: 70000),
trigger: HeartTrigger.unknown,
bpm: [80],
),
]);
});
test(
"a night counts for the day it ended, Polar's stages as light and deep",
() {
final lines = LoopLines()..add(sleep);
expect(
lines.nights[today],
NightContent(
date: today,
start: DateTime.utc(2026, 10, 27, 22, 15),
end: DateTime.utc(2026, 10, 28, 6, 5),
goalMinutes: 480,
phases: const [
(Duration.zero, SleepState.wake),
(Duration(minutes: 10), SleepState.light),
(Duration(hours: 2), SleepState.deep),
(Duration(hours: 2, minutes: 30), SleepState.rem),
],
cycles: const [(Duration(minutes: 10), 1.5)],
rating: SleepRating.somewhatWell,
batteryRanOut: false,
skinCelsius: 33.4,
skinDeviation: -0.2,
),
);
},
);
test('Nightly Recharge and skin temperature keep their figures', () {
final lines = LoopLines()
..add(recharge)
..add(skin);
expect(
lines.recharges[today],
const Recharge(ansStatus: 2.5, recovery: 4, rri: 1050, rmssd: 48),
);
expect(lines.skins[today], const [
(Duration.zero, 33.1),
(Duration(minutes: 1), 33.3),
]);
expect(lines.dates, {today});
});
test(
'a read tells until when every day was seen whole: the day before it',
() {
final lines = LoopLines()..add(read);
expect(lines.isEmpty, isTrue);
expect(lines.completeUntil, yesterday);
expect(LoopLines().completeUntil, isNull);
},
);
test('a later line of a kind and date replaces an earlier one', () {
final lines = LoopLines()
..add(summary)
..add({...summary, 'steps': 10000});
expect(lines.summaries[yesterday]?.steps, 10000);
});
test("lines without a date, of a kind unknown or that aren't JSON are "
'skipped', () {
final lines = LoopLines()
..add({...summary, 'date': null})
..add({'kind': 'weather', 'date': '2026-10-27'})
..add('summary')
..addText(
'{"kind":"summary","date":"2026-10-27","steps":5}\n'
'\n'
'{"kind":"heart","date":"2026-10-27","runs":[{"at":6',
);
expect(lines.dates, {yesterday});
expect(lines.summaries[yesterday]?.steps, 5);
expect(lines.hearts, isEmpty);
});
}

View file

@ -0,0 +1,100 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/sync_day_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
// The days on the device's own database, as the sync engine keeps them, with
// libsodium as its build hook compiles it.
final _person = protocol.UuidValue.withValidation(
'01a16000-0000-7000-8000-000000000001',
);
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late Directory folder;
late OfflineSyncDatabaseSession db;
late SyncService sync;
late TrainKeys keys;
late DayRepository days;
final tuesday = DateTime.utc(2026, 10, 27);
LoopLines read(List<int> hours, {int steps = 9876}) => LoopLines()
..add({'kind': 'summary', 'date': '2026-10-27', 'steps': steps})
..add({
'kind': 'heart',
'date': '2026-10-27',
'runs': [
for (final hour in hours)
{
'at': hour * 3600,
'trigger': 'timed',
'bpm': [60],
},
],
})
..add({'kind': 'sleep', 'date': '2026-10-27', 'rating': 'well'});
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('days');
db = await protocol.Client(
'http://127.0.0.1:9/',
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
sync = SyncService(
({required onMerge}) async {},
debounce: const Duration(days: 1),
);
keys = TrainKeys(
SyncContentKeyGateway(db, sync),
SodiumContentCipher(),
() async => alice,
);
await keys.refresh();
days = DayRepository(SyncDayGateway(db, sync), keys, person: 'alice');
});
tearDown(() async {
await days.dispose();
keys.dispose();
await sync.dispose();
await db.close();
await folder.delete(recursive: true);
});
test('a day, its heart rate and its night are kept, and read back '
'opened', () async {
await days.take(read([1, 7]));
final [stored] = await days.all();
expect(stored.date, tuesday);
expect(stored.day?.steps, 9876);
expect(stored.night, isNotNull);
expect((await days.heart(tuesday)).map((run) => run.at.inHours), [1, 7]);
});
test('a day read again rewrites its rows, a chunk it no longer has '
'deleted', () async {
await days.take(read([1, 7]));
await days.take(read([1], steps: 10000));
expect(await protocol.Day.db.count(db), 1);
expect(await protocol.Night.db.count(db), 1);
expect(await protocol.DaySeries.db.count(db), 1);
expect((await days.all()).single.day?.steps, 10000);
expect(await days.heart(tuesday), hasLength(1));
});
}

View file

@ -3,6 +3,7 @@ import 'dart:io';
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/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
@ -13,6 +14,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platfo
import '../../support/fake_loop_platform.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_day_gateway.dart';
import '../../support/memory_profile_gateway.dart';
/// The person's Loop on this phone (ADR 0030, D34, D35): set up with their
@ -35,14 +37,35 @@ void main() {
late Directory folder;
late TrainKeys keys;
late ProfileRepository profile;
late DayRepository days;
late FakeLoopPlatform platform;
var now = DateTime.utc(2026, 10, 28, 9);
final opened = <PolarLoop>[];
/// What was asked of the band but its days, which their own group checks.
List<String> bandCalls() => [
for (final call in platform.calls)
if (!call.startsWith('readDays') && !call.startsWith('background')) call,
];
/// The reads of the band's days asked for, in order.
List<String> reads() => [
for (final call in platform.calls)
if (call.startsWith('readDays')) call,
];
LoopStore store() => LoopStore(File('${folder.path}/loop.json'));
PolarLoop open() {
final loop = PolarLoop(platform, store(), profile, today: () => today)
..start();
final loop = PolarLoop(
platform,
store(),
profile,
days: days,
backgroundLines: File('${folder.path}/loop_days.txt'),
today: () => today,
now: () => now,
)..start();
opened.add(loop);
return loop;
}
@ -66,6 +89,8 @@ void main() {
person: 'person',
);
await profile.save(body);
days = DayRepository(MemoryDayGateway(), keys, person: 'person');
now = DateTime.utc(2026, 10, 28, 9);
platform = FakeLoopPlatform()..inReach.add('A1');
});
@ -75,6 +100,7 @@ void main() {
}
opened.clear();
await profile.dispose();
await days.dispose();
keys.dispose();
folder.deleteSync(recursive: true);
});
@ -124,7 +150,7 @@ void main() {
final loop = open();
await settled(loop);
expect(platform.calls, ['connect A1', 'setClock A1']);
expect(bandCalls(), ['connect A1', 'setClock A1']);
platform.goAway('A1');
await settled(loop);
@ -133,7 +159,7 @@ void main() {
platform.comeInReach('A1');
await settled(loop);
expect(platform.calls, ['connect A1', 'setClock A1', 'setClock A1']);
expect(bandCalls(), ['connect A1', 'setClock A1', 'setClock A1']);
expect(loop.status.connected, isTrue);
});
@ -177,7 +203,7 @@ void main() {
await settled(loop);
expect(loop.status.profileChanged, isTrue);
expect(platform.calls, isEmpty);
expect(bandCalls(), isEmpty);
await loop.sendProfile();
await settled(loop);
@ -226,4 +252,140 @@ void main() {
expect(platform.calls.last, 'disconnect A1');
});
group("the band's days (D37, D38)", () {
final yesterday = DateTime.utc(2026, 10, 27);
Map<String, Object?> summary(String date, int steps) => {
'kind': 'summary',
'date': date,
'steps': steps,
};
/// A band set up and remembered, as the app opens again.
Future<PolarLoop> setUpBand() async {
final first = open();
await first.setUp(band, loopFiguresOf(body, today)!);
await settled(first);
await opened.removeLast().dispose();
platform.calls.clear();
return open();
}
test(
'as the band is ready, its days are read from four weeks back, '
'kept sealed, and from then on deleted on it up to yesterday',
() async {
platform.days.add([
summary('2026-10-27', 9876),
summary('2026-10-28', 1200),
]);
final loop = await setUpBand();
await settled(loop);
expect(platform.calls, contains('readDays A1 2026-09-30 -'));
final all = await days.all();
expect(all.map((day) => day.day?.steps), [1200, 9876]);
expect(loop.status.loop?.keptUntil, yesterday);
expect(store().read()?.keptUntil, yesterday);
await loop.readDays();
expect(reads().last, 'readDays A1 2026-10-28 2026-10-27');
},
);
test('the band is read again on a connection only after half an hour, '
'or when asked', () async {
final loop = await setUpBand();
await settled(loop);
platform
..goAway('A1')
..comeInReach('A1');
await settled(loop);
expect(
reads(),
hasLength(1),
);
now = now.add(const Duration(minutes: 31));
platform
..goAway('A1')
..comeInReach('A1');
await settled(loop);
expect(
reads(),
hasLength(2),
);
});
test("what wasn't sealed isn't deleted: a read without the keys keeps "
'nothing, and the next deletes nothing', () async {
final keyless = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => null,
);
addTearDown(keyless.dispose);
await keyless.refresh();
days = DayRepository(MemoryDayGateway(), keyless, person: 'person');
platform.days.add([summary('2026-10-27', 9876)]);
final loop = await setUpBand();
await settled(loop);
expect(loop.status.loop?.keptUntil, isNull);
await loop.readDays();
expect(reads().last, 'readDays A1 2026-09-30 -');
});
test('the background job reads into the file what the app keeps as it '
'opens; the file goes once kept', () async {
final loop = await setUpBand();
await settled(loop);
await opened.removeLast().dispose();
platform.inReach.remove('A1');
File('${folder.path}/loop_days.txt').writeAsStringSync(
'{"kind":"read","on":"2026-10-30","from":"2026-10-28","to":"2026-10-30"}\n'
'{"kind":"summary","date":"2026-10-29","steps":4321}\n',
);
final again = open();
await settled(again);
expect((await days.all()).first.day?.steps, 4321);
expect(again.status.loop?.keptUntil, DateTime.utc(2026, 10, 29));
expect(File('${folder.path}/loop_days.txt').existsSync(), isFalse);
expect(platform.backgroundConfig?.toMap(), {
'folder': folder.path,
'id': 'A1',
'from': '2026-10-30',
'keptUntil': '2026-10-29',
});
});
test("a read that fails keeps what was kept, and isn't counted", () async {
final loop = await setUpBand();
await settled(loop);
final before = loop.status.loop;
platform.inReach.remove('A1');
now = now.add(const Duration(hours: 1));
await loop.readDays();
expect(reads().last, startsWith('readDays'));
expect(loop.status.loop?.lastRead, before?.lastRead);
expect(loop.status.loop?.keptUntil, before?.keptUntil);
});
test('letting the band go stops the background job', () async {
final loop = await setUpBand();
await settled(loop);
expect(platform.backgroundConfig, isNotNull);
await loop.letGo();
expect(platform.backgroundConfig, isNull);
});
});
}

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
@ -19,6 +20,16 @@ class FakeLoopPlatform({
/// What was asked, in order: `connect a`, `setUp a`, `setClock a`, …
final calls = <String>[];
/// What each read of the days gives, after its `read` line, one read's
/// at a time; none left, nothing but the `read` line.
final days = <List<Map<String, Object?>>>[];
/// The day reads happen on, as the band's calendar has it.
DateTime today = DateTime.utc(2026, 10, 28);
/// The background job's config, as last given; null where none runs.
LoopBackground? backgroundConfig;
/// The figures each band was set up with.
final setUpWith = <String, LoopFigures>{};
@ -90,6 +101,36 @@ class FakeLoopPlatform({
calls.add('setClock $id');
}
@override
Future<List<Object?>> readDays(
String id, {
required DateTime from,
DateTime? keptUntil,
}) async {
calls.add(
'readDays $id ${dayKey(from)} '
'${keptUntil == null ? '-' : dayKey(keptUntil)}',
);
if (!inReach.contains(id)) {
throw const LoopFailed('not in reach');
}
return [
{
'kind': 'read',
'on': dayKey(today),
'from': dayKey(from),
'to': dayKey(today),
},
if (days.isNotEmpty) ...days.removeAt(0),
];
}
@override
Future<void> background(LoopBackground? config) async {
calls.add('background ${config == null ? 'off' : 'on'}');
backgroundConfig = config;
}
@override
Future<void> reset(String id) async {
calls.add('reset $id');

View file

@ -0,0 +1,66 @@
import 'dart:async';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_gateway.dart';
/// [DayGateway] in memory, whose rows a test changes as an instance could;
/// it records the envelopes written, so a test sees what reached it.
class MemoryDayGateway() implements DayGateway {
final dayRows = <String, Envelope>{};
final seriesRows = <String, DaySeriesRow>{};
final nightRows = <String, Envelope>{};
/// Every envelope ever written, in order.
final written = <Envelope>[];
final _remote = StreamController<void>.broadcast();
/// As a sync bringing others' changes.
void bringRemoteChanges() => _remote.add(null);
@override
Future<List<DayRow>> days() async => [
for (final MapEntry(:key, :value) in dayRows.entries)
(id: key, envelope: value),
];
@override
Future<Envelope?> day(String id) async => dayRows[id];
@override
Future<List<DaySeriesRow>> seriesOf(String dayId) async => [
for (final row in seriesRows.values)
if (row.dayId == dayId) row,
];
@override
Future<List<DayRow>> nights() async => [
for (final MapEntry(:key, :value) in nightRows.entries)
(id: key, envelope: value),
];
@override
Future<Envelope?> night(String id) async => nightRows[id];
@override
Future<void> writeDay(DayRow day, List<DaySeriesRow>? series) async {
dayRows[day.id] = day.envelope;
written.add(day.envelope);
if (series != null) {
seriesRows.removeWhere((_, row) => row.dayId == day.id);
for (final row in series) {
seriesRows[row.id] = row;
written.add(row.envelope);
}
}
}
@override
Future<void> writeNight(DayRow night) async {
nightRows[night.id] = night.envelope;
written.add(night.envelope);
}
@override
Stream<void> get remoteChanges => _remote.stream;
}