feat(train): rowing on the WP 5000 over FTMS (ADR 0030, D28)
Rowing indoors is offered again, with the rower chosen on the Record sheet: a sheet looks for Fitness Machine Service rowers, and the one chosen is remembered in the person's folder. The recorder service connects to it as to the strap (one NotifyLink for both), parses FTMS's Rower Data in Kotlin (tested on the JVM on frames written from the specification and D8's), counts its totals from its first frame, pauses 5 s after the last stroke, and gets the connectedDevice type. A row's screen shows its metres, split per 500 m, stroke rate and watts; its page its average split, stroke rate and power, the highest power, its strokes, splits per 500 m and charts of the split, stroke rate and power. Not yet tried on the WP 5000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
de5fd43e06
commit
f3d08e4e69
8 changed files with 2266 additions and 514 deletions
|
|
@ -10,9 +10,9 @@ 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/map/data/map_source_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/strap_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
|
@ -327,7 +327,8 @@ void main() {
|
|||
recording: (
|
||||
platform: platform,
|
||||
file: file(),
|
||||
straps: StrapStore(File('${folder.path}/strap.json')),
|
||||
straps: DeviceStore(File('${folder.path}/strap.json')),
|
||||
rowers: DeviceStore(File('${folder.path}/rower.json')),
|
||||
),
|
||||
workouts: workouts,
|
||||
);
|
||||
|
|
@ -495,18 +496,18 @@ void main() {
|
|||
});
|
||||
});
|
||||
group('with a strap (D27)', () {
|
||||
const polar = FoundStrap(
|
||||
const polar = FoundDevice(
|
||||
address: 'A0:9E:1A:00:00:01',
|
||||
name: 'Polar H10 1234',
|
||||
rssi: -50,
|
||||
);
|
||||
|
||||
StrapStore straps() => StrapStore(File('${folder.path}/strap.json'));
|
||||
DeviceStore straps() => DeviceStore(File('${folder.path}/strap.json'));
|
||||
|
||||
testWidgets('one is chosen from the Record sheet, nearby devices asked '
|
||||
'first, and used from then on', (tester) async {
|
||||
platform.nearby = [
|
||||
const FoundStrap(address: 'B', name: 'HRM-Dual', rssi: -80),
|
||||
const FoundDevice(address: 'B', name: 'HRM-Dual', rssi: -80),
|
||||
polar,
|
||||
];
|
||||
await withClock(Clock.fixed(at), () async {
|
||||
|
|
@ -538,7 +539,7 @@ void main() {
|
|||
testWidgets('switched off on the sheet, a workout records without it', (
|
||||
tester,
|
||||
) async {
|
||||
straps().save(Strap(address: polar.address, name: polar.name));
|
||||
straps().save(KnownDevice(address: polar.address, name: polar.name));
|
||||
await withClock(Clock.fixed(at), () async {
|
||||
await startRecording(tester);
|
||||
await tester.tap(find.text('Record'));
|
||||
|
|
@ -560,7 +561,7 @@ void main() {
|
|||
});
|
||||
|
||||
testWidgets('forgotten on its sheet, none is used', (tester) async {
|
||||
straps().save(Strap(address: polar.address, name: polar.name));
|
||||
straps().save(KnownDevice(address: polar.address, name: polar.name));
|
||||
await withClock(Clock.fixed(at), () async {
|
||||
await startRecording(tester);
|
||||
await tester.tap(find.text('Record'));
|
||||
|
|
@ -594,7 +595,7 @@ void main() {
|
|||
|
||||
testWidgets('the recording shows the heart rate, and says when the '
|
||||
'strap is lost', (tester) async {
|
||||
straps().save(Strap(address: polar.address, name: polar.name));
|
||||
straps().save(KnownDevice(address: polar.address, name: polar.name));
|
||||
var now = at;
|
||||
await withClock(Clock(() => now), () async {
|
||||
await startRecording(tester);
|
||||
|
|
@ -625,6 +626,85 @@ void main() {
|
|||
});
|
||||
});
|
||||
});
|
||||
group('on the rower (D28)', () {
|
||||
const rower = FoundDevice(
|
||||
address: 'C4:00:00:00:00:01',
|
||||
name: 'MRK-950R-8021',
|
||||
rssi: -60,
|
||||
);
|
||||
|
||||
testWidgets('a row needs its rower, chosen on the Record sheet, and '
|
||||
'records with it, pausing by its strokes', (tester) async {
|
||||
platform.nearbyRowers = [rower];
|
||||
await withClock(Clock.fixed(at), () async {
|
||||
await startRecording(tester);
|
||||
await tester.tap(find.text('Record'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('Rowing indoors'));
|
||||
await tester.tap(find.text('Rowing indoors'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.ensureVisible(find.text('Rowing machine'));
|
||||
await tester.pump();
|
||||
expect(find.text('Choose your rowing machine'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Start'),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
await tester.tap(find.text('Rowing machine'));
|
||||
await pass(tester, 2);
|
||||
await tester.tap(find.text('MRK-950R-8021'));
|
||||
await pass(tester, 2);
|
||||
expect(find.text('MRK-950R-8021'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Start'));
|
||||
await pass(tester, 2);
|
||||
expect(platform.started.single.rower, rower.address);
|
||||
expect(platform.started.single.autoPause, AutoPauseBy.strokes);
|
||||
expect(platform.scanned, [DeviceKind.rower]);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('the recording shows the metres, the split, the stroke rate '
|
||||
'and the watts', (tester) async {
|
||||
DeviceStore(
|
||||
File('${folder.path}/rower.json'),
|
||||
).save(KnownDevice(address: rower.address, name: rower.name));
|
||||
var now = at;
|
||||
await withClock(Clock(() => now), () async {
|
||||
await startRecording(tester);
|
||||
await tester.tap(find.text('Record'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('Rowing indoors'));
|
||||
await tester.tap(find.text('Rowing indoors'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await pass(tester, 2);
|
||||
|
||||
platform.write('rower @ connecting');
|
||||
await pass(tester);
|
||||
expect(find.text('Looking for the rowing machine'), findsOneWidget);
|
||||
|
||||
platform
|
||||
..write('rower @ connected')
|
||||
..write('r @ 24.0 0 0 0')
|
||||
..now = at.add(const Duration(seconds: 24))
|
||||
..write('r @ 25.5 10 100 182');
|
||||
now = at.add(const Duration(seconds: 24));
|
||||
await pass(tester);
|
||||
|
||||
expect(find.text('Looking for the rowing machine'), findsNothing);
|
||||
expect(find.text('100'), findsOneWidget);
|
||||
expect(find.text('2:00'), findsOneWidget);
|
||||
expect(find.text('25.5'), findsOneWidget);
|
||||
expect(find.text('182'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('workouts (ADR 0030, D24)', () {
|
||||
|
|
@ -786,6 +866,44 @@ void main() {
|
|||
expect(find.text('Heart rate'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("a row's page shows its split, stroke rate and power, its "
|
||||
'splits by 500 m and no map', (tester) async {
|
||||
final start = DateTime(2026, 10, 28, 18).toUtc();
|
||||
final (content, series) = recordedWorkout(
|
||||
Recording(
|
||||
activity: Activity.indoorRowing,
|
||||
startedAt: start,
|
||||
rowing: [
|
||||
for (var s = 0; s <= 300; s += 2)
|
||||
RowingSample(
|
||||
time: start.add(Duration(seconds: s)),
|
||||
strokeRate: 24,
|
||||
strokes: s * 24 ~/ 60,
|
||||
metres: (s * 500 / 120).round(),
|
||||
watts: 160,
|
||||
),
|
||||
],
|
||||
endedAt: start.add(const Duration(seconds: 300)),
|
||||
),
|
||||
);
|
||||
await workouts.save(content, series);
|
||||
await startWith(tester);
|
||||
|
||||
expect(find.text('2:00 /500 m'), findsOneWidget);
|
||||
await tester.tap(find.text('Evening row'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FlutterMap), findsNothing);
|
||||
expect(find.text('average split'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(find.text('strokes'), 100);
|
||||
expect(find.text('120'), findsOneWidget);
|
||||
expect(find.text('average power'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(find.text('1000–1250 m'), 100);
|
||||
expect(find.text('0–500 m'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(find.text('Power'), 200);
|
||||
expect(find.text('Stroke rate'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a workout without GPS has no map, splits nor charts', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/device_search_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
|
||||
import '../../support/fake_recorder_platform.dart';
|
||||
|
||||
/// The heart-rate strap (ADR 0030, D27): remembered on the device, in the
|
||||
/// person's folder, and looked for nearby.
|
||||
void main() {
|
||||
late Directory folder;
|
||||
|
||||
setUp(() => folder = Directory.systemTemp.createTempSync('strap'));
|
||||
tearDown(() => folder.deleteSync(recursive: true));
|
||||
|
||||
group('DeviceStore', () {
|
||||
test('a strap saved is read back, and gone once forgotten', () {
|
||||
final store = DeviceStore(File('${folder.path}/strap.json'));
|
||||
expect(store.read(), isNull);
|
||||
|
||||
store.save(const KnownDevice(address: 'A0:9E:1A:00:00:01', name: 'H10'));
|
||||
expect(
|
||||
DeviceStore(File('${folder.path}/strap.json')).read(),
|
||||
const KnownDevice(address: 'A0:9E:1A:00:00:01', name: 'H10'),
|
||||
);
|
||||
|
||||
store.forget();
|
||||
expect(store.read(), isNull);
|
||||
});
|
||||
|
||||
test("a file it can't read is no strap", () {
|
||||
final file = File('${folder.path}/strap.json')..writeAsStringSync('{');
|
||||
|
||||
expect(DeviceStore(file).read(), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('DeviceSearchCubit', () {
|
||||
late FakeRecorderPlatform platform;
|
||||
|
||||
setUp(() => platform = FakeRecorderPlatform());
|
||||
|
||||
const near = FoundDevice(address: 'A', name: 'Polar H10', rssi: -50);
|
||||
const far = FoundDevice(address: 'B', name: 'HRM-Pro', rssi: -85);
|
||||
|
||||
test('it asks for nearby devices, then lists the straps found, the '
|
||||
'nearest first, each once', () async {
|
||||
platform.nearby = [far, near, const FoundDevice(address: 'B', rssi: -70)];
|
||||
final cubit = DeviceSearchCubit(platform, DeviceKind.strap);
|
||||
addTearDown(cubit.close);
|
||||
|
||||
await cubit.open();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(platform.asked.single, {RecorderPermission.bluetooth});
|
||||
expect(platform.scanned, [DeviceKind.strap]);
|
||||
expect(cubit.state.phase, DeviceSearchPhase.searching);
|
||||
expect(cubit.state.found.map((s) => (s.address, s.rssi)), [
|
||||
('A', -50),
|
||||
('B', -70),
|
||||
]);
|
||||
// A name seen before is kept where a later sighting has none.
|
||||
expect(cubit.state.found.last.name, 'HRM-Pro');
|
||||
});
|
||||
|
||||
test('refused, it looks for none and says so', () async {
|
||||
platform
|
||||
..allows = {RecorderPermission.location}
|
||||
..nearby = [near];
|
||||
final cubit = DeviceSearchCubit(platform, DeviceKind.strap);
|
||||
addTearDown(cubit.close);
|
||||
|
||||
await cubit.open();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(cubit.state.phase, DeviceSearchPhase.refused);
|
||||
expect(cubit.state.found, isEmpty);
|
||||
expect(platform.scanned, isEmpty);
|
||||
});
|
||||
|
||||
test('with Bluetooth off it says so', () async {
|
||||
platform.bluetoothOn = false;
|
||||
final cubit = DeviceSearchCubit(platform, DeviceKind.strap);
|
||||
addTearDown(cubit.close);
|
||||
|
||||
await cubit.open();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(cubit.state.phase, DeviceSearchPhase.bluetoothOff);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@ void main() {
|
|||
});
|
||||
|
||||
group('with a strap (D27)', () {
|
||||
Recording withStrap(List<StrapMark> straps, List<HeartRate> rates) =>
|
||||
Recording withStrap(List<LinkMark> straps, List<HeartRate> rates) =>
|
||||
Recording(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
|
|
@ -110,13 +110,13 @@ void main() {
|
|||
test('connected, it shows the last heart rate while it is fresh', () {
|
||||
final recording = withStrap(
|
||||
[
|
||||
StrapMark(time: at(0), state: StrapState.connecting),
|
||||
StrapMark(time: at(2), state: StrapState.connected),
|
||||
LinkMark(time: at(0), state: LinkState.connecting),
|
||||
LinkMark(time: at(2), state: LinkState.connected),
|
||||
],
|
||||
[HeartRate(time: at(9), bpm: 131), HeartRate(time: at(10), bpm: 134)],
|
||||
);
|
||||
|
||||
expect(liveOf(recording, at(11)).strap, StrapState.connected);
|
||||
expect(liveOf(recording, at(11)).strap, LinkState.connected);
|
||||
expect(liveOf(recording, at(11)).bpm, 134);
|
||||
// Nothing for more than 5 s: no figure rather than an old one.
|
||||
expect(liveOf(recording, at(16)).bpm, isNull);
|
||||
|
|
@ -124,19 +124,19 @@ void main() {
|
|||
|
||||
test('connecting or lost, it shows no heart rate', () {
|
||||
final connecting = withStrap([
|
||||
StrapMark(time: at(0), state: StrapState.connecting),
|
||||
LinkMark(time: at(0), state: LinkState.connecting),
|
||||
], const []);
|
||||
final lost = withStrap(
|
||||
[
|
||||
StrapMark(time: at(0), state: StrapState.connected),
|
||||
StrapMark(time: at(10), state: StrapState.lost),
|
||||
LinkMark(time: at(0), state: LinkState.connected),
|
||||
LinkMark(time: at(10), state: LinkState.lost),
|
||||
],
|
||||
[HeartRate(time: at(10), bpm: 140)],
|
||||
);
|
||||
|
||||
expect(liveOf(connecting, at(5)).strap, StrapState.connecting);
|
||||
expect(liveOf(connecting, at(5)).strap, LinkState.connecting);
|
||||
expect(liveOf(connecting, at(5)).bpm, isNull);
|
||||
expect(liveOf(lost, at(10)).strap, StrapState.lost);
|
||||
expect(liveOf(lost, at(10)).strap, LinkState.lost);
|
||||
expect(liveOf(lost, at(10)).bpm, isNull);
|
||||
});
|
||||
|
||||
|
|
@ -147,4 +147,52 @@ void main() {
|
|||
expect(live.bpm, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('on the rower (D28)', () {
|
||||
test('it shows the metres, the split per 500 m of the last 30 s, the '
|
||||
'stroke rate and the watts', () {
|
||||
final live = liveOf(
|
||||
Recording(
|
||||
activity: Activity.indoorRowing,
|
||||
startedAt: start,
|
||||
rowers: [LinkMark(time: at(0), state: LinkState.connected)],
|
||||
rowing: [
|
||||
RowingSample(time: at(0), metres: 0, strokes: 0),
|
||||
// Slow at first, then 100 m in 24 s: 2:00 a 500 m.
|
||||
RowingSample(time: at(60), metres: 150),
|
||||
RowingSample(time: at(70), metres: 192),
|
||||
RowingSample(
|
||||
time: at(84),
|
||||
metres: 250,
|
||||
strokeRate: 26,
|
||||
strokes: 30,
|
||||
watts: 203,
|
||||
),
|
||||
],
|
||||
),
|
||||
at(85),
|
||||
);
|
||||
|
||||
expect(live.rower, LinkState.connected);
|
||||
expect(live.distanceMetres, 250);
|
||||
expect(live.split!.inSeconds, closeTo(120, 2));
|
||||
expect(live.strokeRate, 26);
|
||||
expect(live.watts, 203);
|
||||
expect(live.waitingForGps, isFalse);
|
||||
});
|
||||
|
||||
test('the stroke rate and the watts go once they are old', () {
|
||||
final live = liveOf(
|
||||
Recording(
|
||||
activity: Activity.indoorRowing,
|
||||
startedAt: start,
|
||||
rowing: [RowingSample(time: at(0), strokeRate: 26, watts: 200)],
|
||||
),
|
||||
at(10),
|
||||
);
|
||||
|
||||
expect(live.strokeRate, isNull);
|
||||
expect(live.watts, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,4 +174,47 @@ void main() {
|
|||
),
|
||||
);
|
||||
});
|
||||
|
||||
group('a row (D28)', () {
|
||||
RowingSample stroke(int seconds, int metres, {int strokes = 0}) =>
|
||||
RowingSample(
|
||||
time: at(seconds),
|
||||
strokeRate: 24,
|
||||
strokes: strokes,
|
||||
metres: metres,
|
||||
watts: 120,
|
||||
);
|
||||
|
||||
test(
|
||||
"its distance is the rower's, what it rowed while paused left out",
|
||||
() {
|
||||
final (content, series) = recordedWorkout(
|
||||
Recording(
|
||||
activity: Activity.indoorRowing,
|
||||
startedAt: start,
|
||||
rowing: [
|
||||
stroke(0, 0),
|
||||
stroke(60, 250),
|
||||
// Paused by hand, and rowing on: 50 m that count for nothing.
|
||||
stroke(80, 280),
|
||||
stroke(120, 300),
|
||||
stroke(180, 550),
|
||||
],
|
||||
pauses: [
|
||||
PauseMark(time: at(61)),
|
||||
PauseMark(time: at(121), paused: false),
|
||||
],
|
||||
endedAt: at(180),
|
||||
),
|
||||
);
|
||||
|
||||
expect(content.distanceMetres, 500);
|
||||
expect(content.movingTime, const Duration(seconds: 120));
|
||||
final rowing = series.singleWhere((c) => c.kind == SeriesKind.rowing);
|
||||
expect(rowing.columns['metres'], [0, 250, 280, 300, 550]);
|
||||
// In half strokes, as the rower counts them.
|
||||
expect(rowing.columns['rate2'], [48, 48, 48, 48, 48]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ 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/recording/bloc/recording_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/strap_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
|
|
@ -60,7 +60,8 @@ void main() {
|
|||
platform,
|
||||
workouts,
|
||||
file,
|
||||
StrapStore(File('${folder.path}/strap.json')),
|
||||
DeviceStore(File('${folder.path}/strap.json')),
|
||||
DeviceStore(File('${folder.path}/rower.json')),
|
||||
notice: notice,
|
||||
ticks: ticks.stream,
|
||||
)..add(const RecordingEventOpened());
|
||||
|
|
@ -333,13 +334,16 @@ void main() {
|
|||
});
|
||||
|
||||
group('the strap (D27)', () {
|
||||
const polar = Strap(address: 'A0:9E:1A:00:00:01', name: 'Polar H10 1234');
|
||||
const polar = KnownDevice(
|
||||
address: 'A0:9E:1A:00:00:01',
|
||||
name: 'Polar H10 1234',
|
||||
);
|
||||
|
||||
test("one chosen is remembered in the person's folder, and used as a "
|
||||
'recording starts', () async {
|
||||
final bloc = await open();
|
||||
expect(bloc.state.strap, isNull);
|
||||
bloc.add(const RecordingEventStrapChosen(polar));
|
||||
bloc.add(const RecordingEventDeviceChosen(DeviceKind.strap, polar));
|
||||
await bloc.stream.firstWhere((s) => s.strap == polar);
|
||||
|
||||
expect((await open()).state.strap, polar);
|
||||
|
|
@ -352,7 +356,7 @@ void main() {
|
|||
'switched off for a workout, it is left out of that one alone',
|
||||
() async {
|
||||
final bloc = await open();
|
||||
bloc.add(const RecordingEventStrapChosen(polar));
|
||||
bloc.add(const RecordingEventDeviceChosen(DeviceKind.strap, polar));
|
||||
await bloc.stream.firstWhere((s) => s.strap == polar);
|
||||
|
||||
bloc.add(
|
||||
|
|
@ -369,9 +373,9 @@ void main() {
|
|||
|
||||
test('forgotten, none is used', () async {
|
||||
final bloc = await open();
|
||||
bloc.add(const RecordingEventStrapChosen(polar));
|
||||
bloc.add(const RecordingEventDeviceChosen(DeviceKind.strap, polar));
|
||||
await bloc.stream.firstWhere((s) => s.strap == polar);
|
||||
bloc.add(const RecordingEventStrapForgotten());
|
||||
bloc.add(const RecordingEventDeviceForgotten(DeviceKind.strap));
|
||||
await bloc.stream.firstWhere((s) => s.strap == null);
|
||||
|
||||
expect((await open()).state.strap, isNull);
|
||||
|
|
@ -380,4 +384,55 @@ void main() {
|
|||
expect(platform.started.single.strap, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('the rower (D28)', () {
|
||||
const rower = KnownDevice(address: 'C4:00:00:00:00:01', name: 'MRK-950R');
|
||||
|
||||
test('a row asks for nearby devices and notifications, records with the '
|
||||
'rower, and pauses by its strokes', () async {
|
||||
final bloc = await open();
|
||||
bloc.add(const RecordingEventDeviceChosen(DeviceKind.rower, rower));
|
||||
await bloc.stream.firstWhere((s) => s.rower == rower);
|
||||
|
||||
bloc.add(const RecordingEventStartRequested(Activity.indoorRowing));
|
||||
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
|
||||
|
||||
expect(platform.asked.single, {
|
||||
RecorderPermission.bluetooth,
|
||||
RecorderPermission.notifications,
|
||||
});
|
||||
expect(platform.started.single.rower, rower.address);
|
||||
expect(platform.started.single.autoPause, AutoPauseBy.strokes);
|
||||
expect((await open()).state.rower, rower);
|
||||
});
|
||||
|
||||
test(
|
||||
'without a rower, or without nearby devices, a row starts not',
|
||||
() async {
|
||||
final bloc = await open();
|
||||
bloc.add(const RecordingEventStartRequested(Activity.indoorRowing));
|
||||
await bloc.stream.firstWhere((s) => s.rowerMissing);
|
||||
expect(platform.started, isEmpty);
|
||||
|
||||
bloc.add(const RecordingEventDeviceChosen(DeviceKind.rower, rower));
|
||||
await bloc.stream.firstWhere((s) => s.rower == rower);
|
||||
platform.allows = {RecorderPermission.notifications};
|
||||
bloc.add(const RecordingEventStartRequested(Activity.indoorRowing));
|
||||
await bloc.stream.firstWhere((s) => s.bluetoothRefused);
|
||||
|
||||
expect(platform.started, isEmpty);
|
||||
expect(bloc.state.phase, RecordingPhase.idle);
|
||||
},
|
||||
);
|
||||
|
||||
test('a rower forgotten is gone', () async {
|
||||
final bloc = await open();
|
||||
bloc.add(const RecordingEventDeviceChosen(DeviceKind.rower, rower));
|
||||
await bloc.stream.firstWhere((s) => s.rower == rower);
|
||||
bloc.add(const RecordingEventDeviceForgotten(DeviceKind.rower));
|
||||
await bloc.stream.firstWhere((s) => s.rower == null);
|
||||
|
||||
expect((await open()).state.rower, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,10 +133,34 @@ void main() {
|
|||
|
||||
DateTime time(int seconds) => start.add(Duration(seconds: seconds));
|
||||
expect(recording.straps, [
|
||||
StrapMark(time: time(0), state: StrapState.connecting),
|
||||
StrapMark(time: time(3), state: StrapState.connected),
|
||||
StrapMark(time: time(9), state: StrapState.lost),
|
||||
LinkMark(time: time(0), state: LinkState.connecting),
|
||||
LinkMark(time: time(3), state: LinkState.connected),
|
||||
LinkMark(time: time(9), state: LinkState.lost),
|
||||
]);
|
||||
expect(recording.heartRates.single.bpm, 128);
|
||||
});
|
||||
|
||||
test("the rower's frames and state are read (D28)", () {
|
||||
final recording = readRecording(
|
||||
'$header'
|
||||
'rower ${at(0)} connected\n'
|
||||
'r ${at(1)} 25.5 6 18 100\n'
|
||||
'r ${at(2)} - - 21 -\n',
|
||||
)!;
|
||||
|
||||
DateTime time(int seconds) => start.add(Duration(seconds: seconds));
|
||||
expect(recording.rowers, [
|
||||
LinkMark(time: time(0), state: LinkState.connected),
|
||||
]);
|
||||
expect(recording.rowing, [
|
||||
RowingSample(
|
||||
time: time(1),
|
||||
strokeRate: 25.5,
|
||||
strokes: 6,
|
||||
metres: 18,
|
||||
watts: 100,
|
||||
),
|
||||
RowingSample(time: time(2), metres: 21),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,4 +255,60 @@ void main() {
|
|||
expect(analysis.splits.single.averageBpm, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('a row (D28)', () {
|
||||
// 1250 m at 2:00 a 500 m, a frame every 2 s, 24 strokes a minute,
|
||||
// 150 W, then 200 W from 600 m on.
|
||||
final rowing = Recording(
|
||||
activity: Activity.indoorRowing,
|
||||
startedAt: start,
|
||||
rowing: [
|
||||
for (var s = 0; s <= 300; s += 2)
|
||||
RowingSample(
|
||||
time: at(s),
|
||||
strokeRate: 24,
|
||||
strokes: s * 24 ~/ 60,
|
||||
metres: (s * 500 / 120).round(),
|
||||
watts: s * 500 / 120 < 600 ? 150 : 200,
|
||||
),
|
||||
],
|
||||
endedAt: at(300),
|
||||
);
|
||||
|
||||
test('it splits by 500 m, each with its split, stroke rate and watts', () {
|
||||
final analysis = analysed(rowing);
|
||||
|
||||
expect(analysis.track, isEmpty);
|
||||
expect(analysis.splits.map((s) => s.metres), [500, 500, 250]);
|
||||
expect(analysis.splits.first.movingTime.inSeconds, closeTo(120, 1));
|
||||
expect(analysis.splits.first.strokeRate, closeTo(24, 0.1));
|
||||
expect(analysis.splits.first.watts, 150);
|
||||
expect(analysis.splits[1].watts, closeTo(190, 5));
|
||||
expect(analysis.splits.last.watts, 200);
|
||||
});
|
||||
|
||||
test('its totals: the average stroke rate and watts, the highest watts '
|
||||
'and the strokes', () {
|
||||
final analysis = analysed(rowing);
|
||||
|
||||
expect(analysis.averageStrokeRate, closeTo(24, 0.1));
|
||||
expect(analysis.averageWatts, closeTo(176, 2));
|
||||
expect(analysis.maxWatts, 200);
|
||||
expect(analysis.strokes, 120);
|
||||
});
|
||||
|
||||
test('its charts run over the distance: the split, the stroke rate and '
|
||||
'the watts', () {
|
||||
final analysis = analysed(rowing);
|
||||
|
||||
expect(analysis.chart, isNotEmpty);
|
||||
for (final point in analysis.chart) {
|
||||
// Seconds 500 m take.
|
||||
expect(point.value, closeTo(120, 3));
|
||||
}
|
||||
expect(analysis.chart.last.x, closeTo(1250, 15));
|
||||
expect(analysis.strokeRate.first.value, closeTo(24, 0.1));
|
||||
expect(analysis.watts.last.value, 200);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,16 +26,18 @@ class FakeRecorderPlatform({
|
|||
Activity activity,
|
||||
AutoPauseBy? autoPause,
|
||||
String? strap,
|
||||
String? rower,
|
||||
})
|
||||
>[];
|
||||
|
||||
/// The straps nearby, seen as soon as a scan starts, and whether
|
||||
/// Bluetooth is on.
|
||||
List<FoundStrap> nearby = [];
|
||||
/// The straps and the rowers nearby, seen as soon as a scan starts, and
|
||||
/// whether Bluetooth is on.
|
||||
List<FoundDevice> nearby = [];
|
||||
List<FoundDevice> nearbyRowers = [];
|
||||
bool bluetoothOn = true;
|
||||
|
||||
/// How many scans were started.
|
||||
int scans = 0;
|
||||
/// The scans started, of which kind.
|
||||
final scanned = <DeviceKind>[];
|
||||
|
||||
/// The notices shown, the first one the start's.
|
||||
final notices = <RecorderNotice>[];
|
||||
|
|
@ -72,12 +74,14 @@ class FakeRecorderPlatform({
|
|||
required AutoPauseBy? autoPause,
|
||||
required RecorderNotice notice,
|
||||
String? strap,
|
||||
String? rower,
|
||||
}) async {
|
||||
started.add((
|
||||
path: path,
|
||||
activity: activity,
|
||||
autoPause: autoPause,
|
||||
strap: strap,
|
||||
rower: rower,
|
||||
));
|
||||
notices.add(notice);
|
||||
_file = File(path)
|
||||
|
|
@ -126,12 +130,13 @@ class FakeRecorderPlatform({
|
|||
}
|
||||
|
||||
@override
|
||||
Stream<FoundStrap> scanStraps() {
|
||||
scans++;
|
||||
Stream<FoundDevice> scan(DeviceKind kind) {
|
||||
scanned.add(kind);
|
||||
if (!bluetoothOn) {
|
||||
return Stream.error(BluetoothOff());
|
||||
}
|
||||
final found = kind == DeviceKind.strap ? nearby : nearbyRowers;
|
||||
// Open until cancelled, as a scan is.
|
||||
return Stream.multi((listener) => nearby.forEach(listener.add));
|
||||
return Stream.multi((listener) => found.forEach(listener.add));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue