feat: ui rework and gear generator

This commit is contained in:
2026-04-28 17:13:30 +02:00
parent 82ea8125e1
commit 57a14134a6
300 changed files with 2901 additions and 135 deletions

View File

@ -0,0 +1,72 @@
import 'package:abawo_bt_app/model/gear_configurator.dart';
import 'package:abawo_bt_app/model/gear_ratio_codec.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('gear ratio codec', () {
test('maps one byte across the configured drivetrain range', () {
expect(decodeGearRatioByte(1), gearRatioMin);
expect(decodeGearRatioByte(255), gearRatioMax);
expect(encodeGearRatioByte(0), 0);
expect(encodeGearRatioByte(gearRatioMax + 1), 255);
});
test('quantizes ratios through the byte encoding', () {
final quantized = quantizeGearRatio(50 / 11);
expect(quantized, closeTo(4.55, 0.02));
expect(encodeGearRatioByte(quantized), encodeGearRatioByte(50 / 11));
});
});
group('calculateGearRatios', () {
test('keep all deduplicates and sorts ascending', () {
final result = calculateGearRatios(
chainrings: [40, 20],
sprockets: [20, 10],
mode: GearRetentionMode.keepAll,
);
expect(result.ratios, orderedEquals(result.ratios.toList()..sort()));
expect(result.ratios.length, 3);
expect(result.duplicateCount, 1);
});
test('keep highest drops lower overlapping larger-chainring ratios', () {
final result = calculateGearRatios(
chainrings: [30, 50],
sprockets: [10, 20, 30],
mode: GearRetentionMode.keepHighest,
);
expect(result.ratios.first, closeTo(1, 0.02));
expect(result.ratios.last, closeTo(5, 0.02));
expect(result.ratios.length, 4);
expect(result.ratios.where((ratio) => ratio < 1).length, 0);
});
test('discard ratios outside the one-byte range', () {
final result = calculateGearRatios(
chainrings: [20, 60],
sprockets: [5, 100],
mode: GearRetentionMode.keepAll,
);
expect(result.discardedBelowRange, 1);
expect(result.discardedAboveRange, 1);
expect(result.ratios.length, 2);
});
test('truncates to the configured maximum', () {
final result = calculateGearRatios(
chainrings: [30, 40, 50],
sprockets: [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21],
mode: GearRetentionMode.keepAll,
maxRatios: 8,
);
expect(result.ratios.length, 8);
expect(result.truncatedCount, greaterThan(0));
});
});
}

View File

@ -1,12 +1,11 @@
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:cbor/simple.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('CentralStatus.fromCborBytes', () {
test('decodes firmware packed status with FTMS ready', () {
final status = CentralStatus.fromCborBytes(
_packedStatusBytes(
group('CentralStatus.fromBytes', () {
test('decodes status with FTMS ready', () {
final status = CentralStatus.fromBytes(
_statusBytes(
controlVariant: 1,
trainerVariant: 5,
hasSavedBond: true,
@ -22,7 +21,7 @@ void main() {
expect(status.lastFailure, isNull);
});
test('decodes all firmware packed trainer unit variants', () {
test('decodes all trainer state variants', () {
final expectedStates = <int, TrainerConnectionState>{
0: TrainerConnectionState.idle,
1: TrainerConnectionState.connecting,
@ -34,8 +33,8 @@ void main() {
};
for (final entry in expectedStates.entries) {
final status = CentralStatus.fromCborBytes(
_packedStatusBytes(
final status = CentralStatus.fromBytes(
_statusBytes(
controlVariant: 1,
trainerVariant: entry.key,
),
@ -49,11 +48,12 @@ void main() {
}
});
test('decodes firmware packed trainer error newtype variant', () {
final status = CentralStatus.fromCborBytes(
_packedStatusBytes(
test('decodes trainer error code and last failure', () {
final status = CentralStatus.fromBytes(
_statusBytes(
controlVariant: 1,
trainerRaw: [6, errorFtmsRequiredCharMissing],
trainerVariant: 6,
trainerErrorCode: errorFtmsRequiredCharMissing,
lastFailure: errorFtmsRequiredCharMissing,
),
);
@ -63,48 +63,99 @@ void main() {
expect(status.lastFailure, errorFtmsRequiredCharMissing);
});
test('decodes non-packed status maps with text keys', () {
final status = CentralStatus.fromCborBytes(
cbor.encode({
'control': 'Connected',
'trainer': 'FtmsReady',
'has_saved_bond': false,
'connected_trainer_addr': [10, 11, 12, 13, 14, 15],
'last_failure': null,
}),
test('omits optional values when flags are absent', () {
final status = CentralStatus.fromBytes(
_statusBytes(
controlVariant: 1,
trainerVariant: 5,
connectedTrainerAddr: [10, 11, 12, 13, 14, 15],
includeAddressFlag: false,
lastFailure: errorSequence,
includeLastFailureFlag: false,
),
);
expect(status.control, ControlConnectionState.connected);
expect(status.trainer.state, TrainerConnectionState.ftmsReady);
expect(status.connectedTrainerAddr, [10, 11, 12, 13, 14, 15]);
expect(status.connectedTrainerAddr, isNull);
expect(status.lastFailure, isNull);
});
test('throws for invalid status payloads instead of hiding them', () {
expect(
() => CentralStatus.fromCborBytes(const []),
() => CentralStatus.fromBytes(const []),
throwsFormatException,
);
expect(
() => CentralStatus.fromCborBytes(cbor.encode([1, 2, 3])),
() => CentralStatus.fromBytes(const [1, 1, 5]),
throwsFormatException,
);
expect(
() => CentralStatus.fromBytes(
_statusBytes(controlVariant: 1, trainerVariant: 5)..[0] = 2,
),
throwsFormatException,
);
});
});
group('standard GATT telemetry parsing', () {
test('decodes battery level percentage', () {
expect(parseBatteryLevelPercent([0]), 0);
expect(parseBatteryLevelPercent([87]), 87);
expect(parseBatteryLevelPercent([100]), 100);
});
test('rejects invalid battery payloads', () {
expect(
() => parseBatteryLevelPercent(const []),
throwsFormatException,
);
expect(
() => parseBatteryLevelPercent([101]),
throwsFormatException,
);
});
test('decodes trimmed firmware revision strings', () {
expect(parseGattUtf8String(' 1.2.3 '.codeUnits), '1.2.3');
expect(parseGattUtf8String([0x31, 0x2e, 0x32, 0x00]), '1.2');
expect(parseGattUtf8String(const []), isNull);
});
});
}
List<int> _packedStatusBytes({
List<int> _statusBytes({
required int controlVariant,
int? trainerVariant,
Object? trainerRaw,
required int trainerVariant,
int trainerErrorCode = 0,
bool hasSavedBond = false,
List<int>? connectedTrainerAddr,
int? lastFailure,
bool includeAddressFlag = true,
bool includeLastFailureFlag = true,
}) {
return cbor.encode({
0: controlVariant,
1: trainerRaw ?? trainerVariant,
2: hasSavedBond,
3: connectedTrainerAddr,
4: lastFailure,
});
var flags = 0;
if (hasSavedBond) {
flags |= 0x01;
}
if (connectedTrainerAddr != null && includeAddressFlag) {
flags |= 0x02;
}
if (lastFailure != null && includeLastFailureFlag) {
flags |= 0x04;
}
final payload = List<int>.filled(12, 0, growable: false);
payload[0] = 1;
payload[1] = controlVariant;
payload[2] = trainerVariant;
payload[3] = trainerErrorCode;
payload[4] = flags;
final addr = connectedTrainerAddr;
if (addr != null) {
payload.setRange(5, 11, addr.take(6));
}
payload[11] = lastFailure ?? 0;
return payload;
}